diff --git a/.project-docs/30-worklog/tasks/20260826-ml07-data-service-sdk-skill-6b4e91c2.md b/.project-docs/30-worklog/tasks/20260826-ml07-data-service-sdk-skill-6b4e91c2.md new file mode 100644 index 0000000..a3955ad --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260826-ml07-data-service-sdk-skill-6b4e91c2.md @@ -0,0 +1,133 @@ +# Task: Implement ML-07 generated Data Service SDK and bundled skill + +## Identity + +- Task ID: 20260826-ml07-data-service-sdk-skill-6b4e91c2 +- Mode: Feature +- Branch: codex/20260826-ml07-data-service-sdk-skill-6b4e91c2-ml07-data-service-sdk-skill +- Worktree: D:\Datas\OthersProjects\makelore-ml07-data-service-sdk-skill-6b4e91c2 +- Base commit: 552c6162a57894b713ea1f47d356fa48c0fc0cf1 +- Owner: ml07-sdk-skill +- Status: Ready for integration + +## Scope + +- Implement ML-07 from Data Service P0 spec sections 11–13 at exact base + `552c6162a57894b713ea1f47d356fa48c0fc0cf1`. +- Add the canonical TypeScript/JavaScript ESM SDK templates and bundled + `data-service` Skill, register/package them through the existing coding-skill + loader, and add focused SDK/install/registry/workflow tests. +- Preserve the Main-owned ephemeral runtime boundary and leave ML-08 merge + integration and ML-09 review to the repository coordinator. + +## Intent And Constraints + +- The generated SDK reads and validates `globalThis.__MAKELORE_DATA__` on each + call, uses only local injected endpoint/token capability, and exposes the + frozen get/list/put/delete surface plus optional local `add`. +- SDK requests encode collection/document path segments, send exactly one + strong `If-Match` for `ifRevision`, strictly parse DTO/error responses, avoid + retries/cache/offline/subscription/schema/policy behavior, and return stable + `runtime_unavailable` without network access for absent or invalid injection. +- Skill workflow is inspect → explain/propose → explicit intent → one + `data_service_configure` → layout-aware verbatim template install → minimal + source edit → data-enabled preview write/read-back → report configured + collections and observed quota only after matching read-back. Configuration + failure or `project_identity_required` causes zero source edits. +- Follow the existing bundled skill frontmatter/registry convention and use + directly packaged assets/references; no npm package, seed endpoint, manifest, + hash, migration, cloud URL, Works credential, retry/cache, or ML-08 changes. + +## Planning Gate + +- Result: Passed on 2026-08-26 in the isolated worktree after `check_project_docs.py` + and `task_context.py start/status --json` confirmed this task owns the exact + branch, worktree, and base. +- Loaded repository `AGENTS.md`, `maintain-project-docs`, `writing-for-agents` + and `SKILL-MECHANICS`, project-memory entry/current/decision/architecture/ + domain/evidence/reflection/commitment/stale documents, the parent client task + record, and server canonical spec sections 11–13, ML-07 ticket, integration + contract, and accepted ADR. +- The parent client coordinator is a separate owner at its recorded frontier; + its ML-01 through ML-06 integration is the stated exact base. No active peer + declared a conflicting SDK/Skill ownership; historical planning records with + placeholder scopes remain unknown but are outside these paths. Canonical + project-positioning and success-criteria files retain placeholders/stale + language, so the server spec/contract/ADR and parent task record are the + authoritative task pointers. + +## Implementation Plan + +1. Inspect current SDK/runtime seams, bundled skill registry/resource packaging, + and existing test conventions; record the exact interfaces before writing + red tests. +2. Add failing tests for the SDK contract, layout-aware install/repeat/conflict, + Skill registration/package contents, and ordered workflow guards. +3. Implement the smallest canonical TS/JS assets, direct SDK helper behavior, + registry/resource exposure, and Skill instructions/fixtures required by the + tests. +4. Run focused tests, packaged-resource listing, typecheck/lint/build and + deterministic regression checks; then complete this task record and drift + gate with actual results. + +## Outcome + +- Implemented ML-07 as a bundled, auditable Data Service Skill and paired + TypeScript/JavaScript ESM SDK templates. The registry now advertises the + opt-in `data-service` skill while the existing default skill selection stays + unchanged. The Skill documents inspect/propose/explicit-intent/configure-once, + layout-aware verbatim installation, conflict/no-op behavior, data-enabled + preview put/get read-back, and post-verification reporting boundaries. +- The SDK reads and validates the injected loopback capability at every public + operation, enforces contract version 1, encodes path segments, maps one + strong `If-Match`, parses direct DTOs/errors strictly, freezes safe error + projections, avoids retries and cloud fallbacks, and returns stable + `runtime_unavailable` without network access when the capability is absent or + invalid. Optional `add` only creates a local UUID before delegating to PUT. +- No ML-08 changes, npm package, seed endpoint, manifest/hash/migration layer, + cloud URL/Works credential, or forbidden runtime feature was added. Skill + installation remains agent-driven by the canonical markdown workflow; tests + cover the documented direct-text policy with src TypeScript, src JavaScript, + and root TypeScript fixtures. + +## Verification + +- Initial red focused run before implementation: the new SDK/Skill test file + failed because the canonical asset directory and workflow did not yet exist. +- Focused SDK/Skill suite after implementation: + `pnpm exec vitest run tests/unit/data-service-sdk-assets.test.ts + --maxWorkers=1` — 1 file / 14 tests passed, covering both assets, runtime + injection/no-network behavior, path/ETag/body/delete semantics, strict + DTO/error projection, no retry, registry/package listing, workflow ordering, + and first/repeat/conflict fixture policy. +- Adjacent focused suite: + `pnpm exec vitest run tests/unit/data-service-sdk-assets.test.ts + tests/unit/skill-display.test.ts tests/unit/pi-resource-loader.test.ts + tests/unit/pi-product-tools.test.ts --maxWorkers=1` — 4 files / 29 tests + passed. +- `pnpm test` — 188 files / 1610 tests passed, 2 skipped. +- `pnpm typecheck` — passed, including direct TypeScript asset compilation. +- `pnpm lint:check` — 0 errors and 5 pre-existing warnings in + `src/pages/Home/index.tsx` and `src/pages/Makelore/index.tsx`. +- `pnpm run build:vite` — renderer, Main, preload, and utility builds passed; + existing dynamic-import and large-chunk warnings remain. +- `pnpm test:electron:windows` — 2 files / 4 tests passed. +- `pnpm exec playwright test tests/e2e/project-configuration-skills.spec.ts + --config=playwright.config.ts` — 1 test passed. +- `git diff --check` — passed. Task-aware drift check passed and + `task_context.py complete` returned `READY_FOR_INTEGRATION` for this task. +- The parent coordinator's inherited full E2E evidence records one unrelated, + pre-existing timeout at `tests/e2e/pi-coding-first-chat.spec.ts:575` while + the current-model combobox remained disabled; this ML-07 task did not rerun + or mask that failure. + +## Follow-ups + +- Parent coordinator must cherry-pick the single ML-07 commit into its client + integration branch, run code review/remediation, and perform the required + real PostgreSQL plus signed-in data-enabled preview acceptance. Repository + tests and the focused E2E above do not claim live-service success. + +## Promotion Candidates + +- None recorded. diff --git a/resources/coding-skills/data-service/SKILL.md b/resources/coding-skills/data-service/SKILL.md new file mode 100644 index 0000000..d896e4b --- /dev/null +++ b/resources/coding-skills/data-service/SKILL.md @@ -0,0 +1,93 @@ +--- +name: data-service +description: 当用户明确要求为当前 MakeLore 项目添加持久化开发数据、集合或数据读写示例时使用;只在本地预览中验证,不用于已发布作品。 +--- + +# MakeLore 开发数据 + +为当前项目添加开发数据时,使用本 Skill 的顺序和完成条件。数据能力是 +显式 opt-in,必须得到 explicit user intent;读取项目、创建项目、打开预览或复制项目本身都不会配置服务、 +安装 SDK 或编辑源码。 + +## 1. Inspect + +先 inspect 当前项目的实际目录、`package.json`、`tsconfig.json`(如果存在)、 +已有入口文件和数据需求。识别完成当前功能所需的最小集合名称及每个集合的 +最小文档形状;按实际源文件而不是 `projectType` 标签判断 TypeScript 或 +JavaScript。 + +完成条件:已经列出实际布局、候选集合和最小示例,并且还没有修改应用源码、 +安装 SDK 或调用 `data_service_configure`。 + +## 2. Explain and wait + +向用户说明候选集合、示例文档、将要写入的目标文件和预览验证动作。等待用户 +明确同意这个具体集合方案;推测用户意图、提前配置或先写模板都不算同意。 + +完成条件:用户明确同意本次集合方案;若用户拒绝或改变需求,回到 Inspect, +不要产生源码编辑。 + +## 3. Configure once + +在明确同意后,用 `data_service_configure` 对这一个最终集合列表调用一次,且 +只传实际项目上下文。不要因为响应慢、限流或不确定结果重复调用;配置响应是 +后续步骤的唯一门槛。 + +完成条件:一次调用明确成功并返回配置/实例 DTO。若失败或返回 +`project_identity_required`,立即停止,保持应用源码零编辑,并如实报告结果; +不要安装模板、创建示例或声称服务已配置。 + +## 4. Install the canonical SDK + +配置成功后,根据实际布局选择唯一目标: + +1. 有 `src/` 时,若源代码/`tsconfig.json` 证明项目使用 TypeScript,目标是 + `src/lib/makelore-data.ts`,否则是 `src/lib/makelore-data.js`。 +2. 没有 `src/` 时,若已验证 TypeScript 工具链,目标是项目根的 + `makelore-data.ts`,否则是根目录 `makelore-data.js`。 + +从本 Skill 的 `assets/makelore-data.ts` 或 `assets/makelore-data.js` 逐字复制 +选中的文件;不要让模型从说明重写 transport。目标不存在时创建它;目标文本 +已与选中的 asset 完全相同则保持精确文本 no-op。目标存在但文本不同,先展示 +实际路径和冲突事实,明确询问“替换 canonical 文件”或“保留并由用户自行 +适配”;没有明确选择时不覆盖、不继续报告成功。 + +在最小有用的现有应用文件中加入指向该精确目标的相对 import,并添加最小 +示例调用。只编辑完成示例所需的 import/应用代码;不要加入 cloud URL、账号 +凭据、缓存、重试、离线同步、订阅、schema 或 policy 层。SDK 的 DELETE 是 +普通程序操作,不接受 `confirmed`。 + +完成条件:配置成功后,canonical asset 已按实际布局逐字落到唯一目标;重复 +运行会得到字节级 no-op;修改过的目标会先产生清晰冲突询问;最小应用编辑 +只引用该目标且没有凭据或远端地址。 + +## 5. Preview verification + +用 `agent_browser` 打开当前项目的 data-enabled preview,要求其使用 +`inject_project_data: true` 的本地预览能力;不要在外部浏览器、发布运行时或 +通用 Host 路径中寻找替代能力。实际运行最小示例,先执行一次真实 `put`,再 +用返回的文档标识执行 `get`,读取并直接比较返回的文档数据与刚写入的数据。 + +完成条件:同一次数据预览会话中的真实 `put` 已成功,随后真实 `get` 的数据 +逐项匹配且带有服务端返回的 revision。没有匹配的 read-back、没有预览注入、 +或出现 `runtime_unavailable` 时,报告阻塞事实,不报告配置成功或配额状态。 + +## 6. report + +只有 read-back 匹配后,才可用 `data_service_inspect` 读取并报告实际配置的 +集合和响应中的 quota/usage 状态。报告只引用本次工具响应观察到的字段,不猜 +测实例、owner、project、路径或剩余配额;如果 inspect 失败,报告验证失败而 +不是补造状态。 + +完成条件:报告明确区分配置响应、真实 put/get 结果和观察到的 quota/usage, +并没有暴露凭据、云端地址、绝对路径或未观察到的服务端状态。 + +## Fixed SDK surface + +程序只使用 `assets/makelore-data.ts` 或 `assets/makelore-data.js` 提供的 +`data.get`、`data.list`、`data.put`、`data.delete` 和可选 `data.add`。SDK 每次 +调用读取 `globalThis.__MAKELORE_DATA__`,只接受 `contractVersion === 1`,将 +集合/文档路径片段用 `encodeURIComponent` 编码,并把 `ifRevision` 转成一个 +strong `If-Match`。它严格解析直接的文档/page/error DTO;缺失注入时返回稳定 +`runtime_unavailable` 且不发网络请求。SDK 不持有凭据,不访问云端,不重试, +不缓存,也不实现离线、订阅、schema 或 policy。 diff --git a/resources/coding-skills/data-service/assets/makelore-data.js b/resources/coding-skills/data-service/assets/makelore-data.js new file mode 100644 index 0000000..caced06 --- /dev/null +++ b/resources/coding-skills/data-service/assets/makelore-data.js @@ -0,0 +1,369 @@ +const runtimeGlobal = globalThis; +const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; +const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; +const MAX_CURSOR_LENGTH = 1024; +const MAX_ERROR_MESSAGE_LENGTH = 256; +const MAX_RETRY_AFTER_SECONDS = 86400; +const ERROR_STATUS = { + runtime_unavailable: 503, + upstream_invalid_response: 502, + invalid_request: [400, 422], + invalid_content_type: 415, + invalid_revision: 422, + invalid_cursor: 400, + cursor_expired: 410, + invalid_collection_name: 422, + invalid_document_id: 422, + invalid_document_data: 422, + invalid_project_id: 422, + authentication_required: 401, + origin_not_allowed: 403, + method_not_allowed: 405, + route_not_found: 404, + instance_not_found: 404, + collection_not_found: 404, + document_not_found: 404, + revision_conflict: 409, + document_too_large: 413, + quota_exceeded: 409, + rate_limited: 429, + data_service_unavailable: 503, + request_too_large: 413 +}; +const ERROR_MESSAGE = { + runtime_unavailable: "Preview data runtime is unavailable", + upstream_invalid_response: "Data Service returned an invalid response", + invalid_request: "Data Service request is invalid", + invalid_content_type: "Data Service request content type is invalid", + invalid_revision: "Data Service document revision is invalid", + invalid_cursor: "Data Service cursor is invalid", + cursor_expired: "Data Service cursor has expired", + invalid_collection_name: "Data Service collection is invalid", + invalid_document_id: "Data Service document ID is invalid", + invalid_document_data: "Data Service document data is invalid", + invalid_project_id: "Data Service project ID is invalid", + authentication_required: "Preview data authorization is required", + origin_not_allowed: "Preview data Origin is not allowed", + method_not_allowed: "Preview data method is not allowed", + route_not_found: "Preview data route was not found", + instance_not_found: "Data Service project instance was not found", + collection_not_found: "Data Service collection was not found", + document_not_found: "Data Service document was not found", + revision_conflict: "Data Service document revision conflicts", + document_too_large: "Data Service document is too large", + quota_exceeded: "Data Service quota exceeded", + rate_limited: "Preview data request rate limit exceeded", + data_service_unavailable: "Data Service is temporarily unavailable", + request_too_large: "Preview data request is too large" +}; +const CONTEXT_KEYS = /* @__PURE__ */ new Set([ + "resource", + "limit", + "current", + "attempted", + "actual", + "allowed", + "current_revision", + "retry_after_seconds" +]); +const CONTEXT_RESOURCES = /* @__PURE__ */ new Set(["instances", "collections", "documents", "bytes"]); +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function positiveInteger(value) { + return Number.isSafeInteger(value) && value > 0; +} +function boundedText(value, maximum) { + return typeof value === "string" && value.length > 0 && value.length <= maximum; +} +function boundedTimestamp(value) { + return boundedText(value, 64) && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) && Number.isFinite(Date.parse(value)); +} +function hasOnlyKeys(value, required, optional = []) { + const allowed = /* @__PURE__ */ new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) && Object.keys(value).every((key) => allowed.has(key)); +} +function statusMatches(code, status) { + const expected = ERROR_STATUS[code]; + return Array.isArray(expected) ? expected.includes(status) : expected === status; +} +function frozenContext(value) { + return value === void 0 ? void 0 : Object.freeze({ ...value }); +} +class DataServiceError extends Error { + code; + status; + retryable; + retryAfterSeconds; + context; + constructor(code, status, retryable, retryAfterSeconds, context) { + super(ERROR_MESSAGE[code]); + Object.defineProperty(this, "name", { value: "DataServiceError", enumerable: false }); + this.code = code; + this.status = status; + this.retryable = retryable; + if (retryAfterSeconds !== void 0) this.retryAfterSeconds = retryAfterSeconds; + const safeContext = frozenContext(context); + if (safeContext !== void 0) this.context = safeContext; + Object.freeze(this); + } +} +function runtimeUnavailable() { + return new DataServiceError("runtime_unavailable", 503, true); +} +function invalidRequest() { + return new DataServiceError("invalid_request", 422, false); +} +function upstreamInvalidResponse() { + return new DataServiceError("upstream_invalid_response", 502, false); +} +function readRuntime() { + const candidate = globalThis.__MAKELORE_DATA__; + if (!isRecord(candidate) || candidate.contractVersion !== 1 || !boundedText(candidate.token, 256) || !/^[A-Za-z0-9_-]+$/.test(candidate.token) || !boundedText(candidate.endpoint, 256)) { + throw runtimeUnavailable(); + } + let endpoint; + try { + endpoint = new URL(candidate.endpoint); + } catch { + throw runtimeUnavailable(); + } + if (endpoint.protocol !== "http:" || endpoint.hostname !== "127.0.0.1" || !endpoint.port || endpoint.username || endpoint.password || endpoint.pathname !== "/api/runtime/data/v1" || endpoint.search || endpoint.hash) { + throw runtimeUnavailable(); + } + return { + endpoint: `${endpoint.origin}${endpoint.pathname}`, + token: candidate.token, + contractVersion: 1 + }; +} +function requireCollection(value) { + if (typeof value !== "string" || !COLLECTION_PATTERN.test(value)) throw invalidRequest(); + return value; +} +function requireDocumentId(value) { + if (typeof value !== "string" || value === "." || value === ".." || !DOCUMENT_ID_PATTERN.test(value)) { + throw invalidRequest(); + } + return value; +} +function requireData(value) { + if (!isRecord(value)) throw invalidRequest(); + try { + if (JSON.stringify(value) === void 0) throw new Error("data is not JSON"); + } catch { + throw invalidRequest(); + } + return value; +} +function requireOptions(value, allowed) { + if (value === void 0) return {}; + if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) throw invalidRequest(); + return value; +} +function readIfRevision(value) { + if (value === void 0) return void 0; + if (!positiveInteger(value)) throw invalidRequest(); + return value; +} +function buildDocumentPath(endpoint, collection, documentId) { + return `${endpoint}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`; +} +function parseContext(value) { + if (value === void 0) return void 0; + if (!isRecord(value)) throw upstreamInvalidResponse(); + const context = {}; + for (const [key, item] of Object.entries(value)) { + if (!CONTEXT_KEYS.has(key)) throw upstreamInvalidResponse(); + if (key === "resource") { + if (!boundedText(item, 16) || !CONTEXT_RESOURCES.has(item)) throw upstreamInvalidResponse(); + context.resource = item; + } else { + if (!positiveInteger(item) && item !== 0) throw upstreamInvalidResponse(); + context[key] = item; + } + } + return Object.keys(context).length === 0 ? void 0 : context; +} +function readRetryAfter(response) { + const raw = response.headers?.get("retry-after")?.trim(); + if (!raw || !/^\d+$/.test(raw)) return void 0; + const seconds = Number(raw); + return Number.isSafeInteger(seconds) && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : void 0; +} +async function parseError(response) { + try { + const payload = await response.json(); + if (!isRecord(payload) || !isRecord(payload.detail)) throw upstreamInvalidResponse(); + const detail = payload.detail; + if (!hasOnlyKeys(detail, ["code", "message", "retryable"], ["context"]) || !boundedText(detail.code, 64) || !Object.prototype.hasOwnProperty.call(ERROR_STATUS, detail.code) || !boundedText(detail.message, MAX_ERROR_MESSAGE_LENGTH) || typeof detail.retryable !== "boolean" || !statusMatches(detail.code, response.status)) { + throw upstreamInvalidResponse(); + } + const context = parseContext(detail.context); + return new DataServiceError( + detail.code, + response.status, + detail.retryable, + readRetryAfter(response), + context + ); + } catch (error) { + if (error instanceof DataServiceError) throw error; + throw upstreamInvalidResponse(); + } +} +async function parseJson(response) { + try { + return await response.json(); + } catch { + throw upstreamInvalidResponse(); + } +} +function parseDocument(value) { + if (!isRecord(value) || !hasOnlyKeys(value, ["id", "data", "revision", "created_at", "updated_at"]) || typeof value.id !== "string" || value.id === "." || value.id === ".." || !DOCUMENT_ID_PATTERN.test(value.id) || !isRecord(value.data) || !positiveInteger(value.revision) || !boundedTimestamp(value.created_at) || !boundedTimestamp(value.updated_at)) { + throw upstreamInvalidResponse(); + } + return { + id: value.id, + data: value.data, + revision: value.revision, + created_at: value.created_at, + updated_at: value.updated_at + }; +} +function parsePage(value) { + if (!isRecord(value) || !hasOnlyKeys(value, ["items", "next_cursor", "limit"]) || !Array.isArray(value.items) || value.items.length > 100 || !positiveInteger(value.limit) || value.limit > 100 || value.next_cursor !== null && !boundedText(value.next_cursor, MAX_CURSOR_LENGTH)) { + throw upstreamInvalidResponse(); + } + const items = value.items.map(parseDocument); + if (new Set(items.map((item) => item.id)).size !== items.length) throw upstreamInvalidResponse(); + return { + items, + next_cursor: value.next_cursor === null ? null : value.next_cursor, + limit: value.limit + }; +} +async function request(method, url, expectedStatus, parse, body, ifRevision) { + const runtime = readRuntime(); + const fetchImpl = runtimeGlobal.fetch; + if (typeof fetchImpl !== "function") throw runtimeUnavailable(); + const headers = { + Accept: "application/json", + Authorization: `Bearer ${runtime.token}` + }; + const serializedBody = body === void 0 ? void 0 : (() => { + try { + const encoded = JSON.stringify(body); + return encoded === void 0 ? null : encoded; + } catch { + return null; + } + })(); + if (serializedBody === null) throw invalidRequest(); + if (serializedBody !== void 0) headers["Content-Type"] = "application/json"; + if (ifRevision !== void 0) headers["If-Match"] = `"${ifRevision}"`; + let response; + try { + response = await fetchImpl(url, { + method, + headers, + ...serializedBody === void 0 ? {} : { body: serializedBody } + }); + } catch { + throw runtimeUnavailable(); + } + if (!response || !Number.isSafeInteger(response.status)) throw upstreamInvalidResponse(); + if (response.status !== expectedStatus) throw await parseError(response); + if (expectedStatus === 204) return void 0; + return parse(await parseJson(response)); +} +function listPath(endpoint, collection, options) { + const params = new URLSearchParams(); + if (options.limit !== void 0) params.set("limit", String(options.limit)); + if (options.cursor !== void 0) params.set("cursor", options.cursor); + const query = params.toString(); + return `${endpoint}/collections/${encodeURIComponent(collection)}/documents${query ? `?${query}` : ""}`; +} +async function getDocument(collection, documentId) { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + return await request( + "GET", + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 200, + parseDocument + ); +} +async function listDocuments(collection, input = {}) { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const options = requireOptions(input, ["limit", "cursor"]); + const limit = options.limit; + if (limit !== void 0 && (!positiveInteger(limit) || limit > 100)) throw invalidRequest(); + const cursor = options.cursor; + if (cursor !== void 0 && !boundedText(cursor, MAX_CURSOR_LENGTH)) throw invalidRequest(); + return await request( + "GET", + listPath(runtime.endpoint, safeCollection, { + ...limit === void 0 ? {} : { limit }, + ...cursor === void 0 ? {} : { cursor } + }), + 200, + parsePage + ); +} +async function putDocument(collection, documentId, value, input = {}) { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + const data2 = requireData(value); + const options = requireOptions(input, ["ifRevision"]); + const ifRevision = readIfRevision(options.ifRevision); + return await request( + "PUT", + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 200, + parseDocument, + { data: data2 }, + ifRevision + ); +} +async function deleteDocument(collection, documentId, input = {}) { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + const options = requireOptions(input, ["ifRevision"]); + const ifRevision = readIfRevision(options.ifRevision); + await request( + "DELETE", + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 204, + () => void 0, + void 0, + ifRevision + ); +} +async function addDocument(collection, value) { + readRuntime(); + requireCollection(collection); + requireData(value); + const randomUuid = runtimeGlobal.crypto?.randomUUID; + if (typeof randomUuid !== "function") throw runtimeUnavailable(); + const id = randomUuid(); + if (!boundedText(id, 128) || !DOCUMENT_ID_PATTERN.test(id)) throw runtimeUnavailable(); + return await putDocument(collection, id, value); +} +const data = Object.freeze({ + get: getDocument, + list: listDocuments, + put: putDocument, + delete: deleteDocument, + add: addDocument +}); +var makelore_data_default = data; +export { + DataServiceError, + data, + makelore_data_default as default +}; diff --git a/resources/coding-skills/data-service/assets/makelore-data.ts b/resources/coding-skills/data-service/assets/makelore-data.ts new file mode 100644 index 0000000..6155cf3 --- /dev/null +++ b/resources/coding-skills/data-service/assets/makelore-data.ts @@ -0,0 +1,550 @@ +export type DataServiceDocument = Record> = { + id: string; + data: T; + revision: number; + created_at: string; + updated_at: string; +}; + +export type DataServiceDocumentPage = Record> = { + items: Array>; + next_cursor: string | null; + limit: number; +}; + +export type DataServiceListOptions = { + limit?: number; + cursor?: string; +}; + +export type DataServiceWriteOptions = { + ifRevision?: number; +}; + +export type DataServiceErrorContext = { + resource?: 'instances' | 'collections' | 'documents' | 'bytes'; + limit?: number; + current?: number; + attempted?: number; + actual?: number; + allowed?: number; + current_revision?: number; + retry_after_seconds?: number; +}; + +export type DataServiceErrorCode = + | 'runtime_unavailable' + | 'upstream_invalid_response' + | 'invalid_request' + | 'invalid_content_type' + | 'invalid_revision' + | 'invalid_cursor' + | 'cursor_expired' + | 'invalid_collection_name' + | 'invalid_document_id' + | 'invalid_document_data' + | 'invalid_project_id' + | 'authentication_required' + | 'origin_not_allowed' + | 'method_not_allowed' + | 'route_not_found' + | 'instance_not_found' + | 'collection_not_found' + | 'document_not_found' + | 'revision_conflict' + | 'document_too_large' + | 'quota_exceeded' + | 'rate_limited' + | 'data_service_unavailable' + | 'request_too_large'; + +declare global { + var __MAKELORE_DATA__: unknown; +} + +type FetchResponse = { + status: number; + headers?: { get(name: string): string | null }; + json(): Promise; +}; + +type FetchOptions = { + method: 'GET' | 'PUT' | 'DELETE'; + headers: Record; + body?: string; +}; + +type RuntimeBinding = { + endpoint: string; + token: string; + contractVersion: 1; +}; + +type RuntimeGlobal = typeof globalThis & { + __MAKELORE_DATA__?: unknown; + fetch?: (input: string, init: FetchOptions) => Promise; + crypto?: { randomUUID?: () => string }; +}; + +const runtimeGlobal = globalThis as RuntimeGlobal; +const COLLECTION_PATTERN = /^[a-z][a-z0-9_-]{0,47}$/; +const DOCUMENT_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/; +const MAX_CURSOR_LENGTH = 1_024; +const MAX_ERROR_MESSAGE_LENGTH = 256; +const MAX_RETRY_AFTER_SECONDS = 86_400; +const ERROR_STATUS: Readonly> = { + runtime_unavailable: 503, + upstream_invalid_response: 502, + invalid_request: [400, 422], + invalid_content_type: 415, + invalid_revision: 422, + invalid_cursor: 400, + cursor_expired: 410, + invalid_collection_name: 422, + invalid_document_id: 422, + invalid_document_data: 422, + invalid_project_id: 422, + authentication_required: 401, + origin_not_allowed: 403, + method_not_allowed: 405, + route_not_found: 404, + instance_not_found: 404, + collection_not_found: 404, + document_not_found: 404, + revision_conflict: 409, + document_too_large: 413, + quota_exceeded: 409, + rate_limited: 429, + data_service_unavailable: 503, + request_too_large: 413, +}; + +const ERROR_MESSAGE: Readonly> = { + runtime_unavailable: 'Preview data runtime is unavailable', + upstream_invalid_response: 'Data Service returned an invalid response', + invalid_request: 'Data Service request is invalid', + invalid_content_type: 'Data Service request content type is invalid', + invalid_revision: 'Data Service document revision is invalid', + invalid_cursor: 'Data Service cursor is invalid', + cursor_expired: 'Data Service cursor has expired', + invalid_collection_name: 'Data Service collection is invalid', + invalid_document_id: 'Data Service document ID is invalid', + invalid_document_data: 'Data Service document data is invalid', + invalid_project_id: 'Data Service project ID is invalid', + authentication_required: 'Preview data authorization is required', + origin_not_allowed: 'Preview data Origin is not allowed', + method_not_allowed: 'Preview data method is not allowed', + route_not_found: 'Preview data route was not found', + instance_not_found: 'Data Service project instance was not found', + collection_not_found: 'Data Service collection was not found', + document_not_found: 'Data Service document was not found', + revision_conflict: 'Data Service document revision conflicts', + document_too_large: 'Data Service document is too large', + quota_exceeded: 'Data Service quota exceeded', + rate_limited: 'Preview data request rate limit exceeded', + data_service_unavailable: 'Data Service is temporarily unavailable', + request_too_large: 'Preview data request is too large', +}; + +const CONTEXT_KEYS = new Set([ + 'resource', + 'limit', + 'current', + 'attempted', + 'actual', + 'allowed', + 'current_revision', + 'retry_after_seconds', +]); +const CONTEXT_RESOURCES = new Set(['instances', 'collections', 'documents', 'bytes']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function positiveInteger(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +function boundedText(value: unknown, maximum: number): value is string { + return typeof value === 'string' && value.length > 0 && value.length <= maximum; +} + +function boundedTimestamp(value: unknown): value is string { + return boundedText(value, 64) + && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/.test(value) + && Number.isFinite(Date.parse(value)); +} + +function hasOnlyKeys(value: Record, required: readonly string[], optional: readonly string[] = []): boolean { + const allowed = new Set([...required, ...optional]); + return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) + && Object.keys(value).every((key) => allowed.has(key)); +} + +function statusMatches(code: DataServiceErrorCode, status: number): boolean { + const expected = ERROR_STATUS[code]; + return Array.isArray(expected) ? expected.includes(status) : expected === status; +} + +function frozenContext(value: DataServiceErrorContext | undefined): DataServiceErrorContext | undefined { + return value === undefined ? undefined : Object.freeze({ ...value }); +} + +export class DataServiceError extends Error { + readonly code: DataServiceErrorCode; + readonly status: number; + readonly retryable: boolean; + readonly retryAfterSeconds?: number; + readonly context?: DataServiceErrorContext; + + constructor( + code: DataServiceErrorCode, + status: number, + retryable: boolean, + retryAfterSeconds?: number, + context?: DataServiceErrorContext, + ) { + super(ERROR_MESSAGE[code]); + Object.defineProperty(this, 'name', { value: 'DataServiceError', enumerable: false }); + this.code = code; + this.status = status; + this.retryable = retryable; + if (retryAfterSeconds !== undefined) this.retryAfterSeconds = retryAfterSeconds; + const safeContext = frozenContext(context); + if (safeContext !== undefined) this.context = safeContext; + Object.freeze(this); + } +} + +function runtimeUnavailable(): DataServiceError { + return new DataServiceError('runtime_unavailable', 503, true); +} + +function invalidRequest(): DataServiceError { + return new DataServiceError('invalid_request', 422, false); +} + +function upstreamInvalidResponse(): DataServiceError { + return new DataServiceError('upstream_invalid_response', 502, false); +} + +function readRuntime(): RuntimeBinding { + const candidate = globalThis.__MAKELORE_DATA__; + if (!isRecord(candidate) + || candidate.contractVersion !== 1 + || !boundedText(candidate.token, 256) + || !/^[A-Za-z0-9_-]+$/.test(candidate.token) + || !boundedText(candidate.endpoint, 256)) { + throw runtimeUnavailable(); + } + let endpoint: URL; + try { + endpoint = new URL(candidate.endpoint); + } catch { + throw runtimeUnavailable(); + } + if (endpoint.protocol !== 'http:' + || endpoint.hostname !== '127.0.0.1' + || !endpoint.port + || endpoint.username + || endpoint.password + || endpoint.pathname !== '/api/runtime/data/v1' + || endpoint.search + || endpoint.hash) { + throw runtimeUnavailable(); + } + return { + endpoint: `${endpoint.origin}${endpoint.pathname}`, + token: candidate.token, + contractVersion: 1, + }; +} + +function requireCollection(value: string): string { + if (typeof value !== 'string' || !COLLECTION_PATTERN.test(value)) throw invalidRequest(); + return value; +} + +function requireDocumentId(value: string): string { + if (typeof value !== 'string' || value === '.' || value === '..' || !DOCUMENT_ID_PATTERN.test(value)) { + throw invalidRequest(); + } + return value; +} + +function requireData(value: Record): Record { + if (!isRecord(value)) throw invalidRequest(); + try { + if (JSON.stringify(value) === undefined) throw new Error('data is not JSON'); + } catch { + throw invalidRequest(); + } + return value; +} + +function requireOptions(value: unknown, allowed: string[]): Record { + if (value === undefined) return {}; + if (!isRecord(value) || Object.keys(value).some((key) => !allowed.includes(key))) throw invalidRequest(); + return value; +} + +function readIfRevision(value: unknown): number | undefined { + if (value === undefined) return undefined; + if (!positiveInteger(value)) throw invalidRequest(); + return value; +} + +function buildDocumentPath(endpoint: string, collection: string, documentId: string): string { + return `${endpoint}/collections/${encodeURIComponent(collection)}/documents/${encodeURIComponent(documentId)}`; +} + +function parseContext(value: unknown): DataServiceErrorContext | undefined { + if (value === undefined) return undefined; + if (!isRecord(value)) throw upstreamInvalidResponse(); + const context: DataServiceErrorContext = {}; + for (const [key, item] of Object.entries(value)) { + if (!CONTEXT_KEYS.has(key)) throw upstreamInvalidResponse(); + if (key === 'resource') { + if (!boundedText(item, 16) || !CONTEXT_RESOURCES.has(item)) throw upstreamInvalidResponse(); + context.resource = item as DataServiceErrorContext['resource']; + } else { + if (!positiveInteger(item) && item !== 0) throw upstreamInvalidResponse(); + context[key as Exclude] = item as never; + } + } + return Object.keys(context).length === 0 ? undefined : context; +} + +function readRetryAfter(response: FetchResponse): number | undefined { + const raw = response.headers?.get('retry-after')?.trim(); + if (!raw || !/^\d+$/.test(raw)) return undefined; + const seconds = Number(raw); + return Number.isSafeInteger(seconds) && seconds <= MAX_RETRY_AFTER_SECONDS ? seconds : undefined; +} + +async function parseError(response: FetchResponse): Promise { + try { + const payload = await response.json(); + if (!isRecord(payload) || !isRecord(payload.detail)) throw upstreamInvalidResponse(); + const detail = payload.detail; + if (!hasOnlyKeys(detail, ['code', 'message', 'retryable'], ['context']) + || !boundedText(detail.code, 64) + || !Object.prototype.hasOwnProperty.call(ERROR_STATUS, detail.code) + || !boundedText(detail.message, MAX_ERROR_MESSAGE_LENGTH) + || typeof detail.retryable !== 'boolean' + || !statusMatches(detail.code as DataServiceErrorCode, response.status)) { + throw upstreamInvalidResponse(); + } + const context = parseContext(detail.context); + return new DataServiceError( + detail.code as DataServiceErrorCode, + response.status, + detail.retryable, + readRetryAfter(response), + context, + ); + } catch (error: unknown) { + if (error instanceof DataServiceError) throw error; + throw upstreamInvalidResponse(); + } +} + +async function parseJson(response: FetchResponse): Promise { + try { + return await response.json(); + } catch { + throw upstreamInvalidResponse(); + } +} + +function parseDocument(value: unknown): DataServiceDocument { + if (!isRecord(value) + || !hasOnlyKeys(value, ['id', 'data', 'revision', 'created_at', 'updated_at']) + || typeof value.id !== 'string' + || value.id === '.' + || value.id === '..' + || !DOCUMENT_ID_PATTERN.test(value.id) + || !isRecord(value.data) + || !positiveInteger(value.revision) + || !boundedTimestamp(value.created_at) + || !boundedTimestamp(value.updated_at)) { + throw upstreamInvalidResponse(); + } + return { + id: value.id, + data: value.data, + revision: value.revision, + created_at: value.created_at, + updated_at: value.updated_at, + }; +} + +function parsePage(value: unknown): DataServiceDocumentPage { + if (!isRecord(value) + || !hasOnlyKeys(value, ['items', 'next_cursor', 'limit']) + || !Array.isArray(value.items) + || value.items.length > 100 + || !positiveInteger(value.limit) + || value.limit > 100 + || (value.next_cursor !== null && !boundedText(value.next_cursor, MAX_CURSOR_LENGTH))) { + throw upstreamInvalidResponse(); + } + const items = value.items.map(parseDocument); + if (new Set(items.map((item) => item.id)).size !== items.length) throw upstreamInvalidResponse(); + return { + items, + next_cursor: value.next_cursor === null ? null : value.next_cursor, + limit: value.limit, + }; +} + +async function request( + method: FetchOptions['method'], + url: string, + expectedStatus: number, + parse: (value: unknown) => T, + body?: Record, + ifRevision?: number, +): Promise { + const runtime = readRuntime(); + const fetchImpl = runtimeGlobal.fetch; + if (typeof fetchImpl !== 'function') throw runtimeUnavailable(); + const headers: Record = { + Accept: 'application/json', + Authorization: `Bearer ${runtime.token}`, + }; + const serializedBody = body === undefined ? undefined : (() => { + try { + const encoded = JSON.stringify(body); + return encoded === undefined ? null : encoded; + } catch { + return null; + } + })(); + if (serializedBody === null) throw invalidRequest(); + if (serializedBody !== undefined) headers['Content-Type'] = 'application/json'; + if (ifRevision !== undefined) headers['If-Match'] = `"${ifRevision}"`; + let response: FetchResponse; + try { + response = await fetchImpl(url, { + method, + headers, + ...(serializedBody === undefined ? {} : { body: serializedBody }), + }); + } catch { + throw runtimeUnavailable(); + } + if (!response || !Number.isSafeInteger(response.status)) throw upstreamInvalidResponse(); + if (response.status !== expectedStatus) throw await parseError(response); + if (expectedStatus === 204) return undefined as T; + return parse(await parseJson(response)); +} + +function listPath(endpoint: string, collection: string, options: DataServiceListOptions): string { + const params = new URLSearchParams(); + if (options.limit !== undefined) params.set('limit', String(options.limit)); + if (options.cursor !== undefined) params.set('cursor', options.cursor); + const query = params.toString(); + return `${endpoint}/collections/${encodeURIComponent(collection)}/documents${query ? `?${query}` : ''}`; +} + +async function getDocument(collection: string, documentId: string): Promise { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + return await request( + 'GET', + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 200, + parseDocument, + ); +} + +async function listDocuments( + collection: string, + input: DataServiceListOptions = {}, +): Promise { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const options = requireOptions(input, ['limit', 'cursor']); + const limit = options.limit; + if (limit !== undefined && (!positiveInteger(limit) || limit > 100)) throw invalidRequest(); + const cursor = options.cursor; + if (cursor !== undefined && (!boundedText(cursor, MAX_CURSOR_LENGTH))) throw invalidRequest(); + return await request( + 'GET', + listPath(runtime.endpoint, safeCollection, { + ...(limit === undefined ? {} : { limit }), + ...(cursor === undefined ? {} : { cursor }), + }), + 200, + parsePage, + ); +} + +async function putDocument( + collection: string, + documentId: string, + value: Record, + input: DataServiceWriteOptions = {}, +): Promise { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + const data = requireData(value); + const options = requireOptions(input, ['ifRevision']); + const ifRevision = readIfRevision(options.ifRevision); + return await request( + 'PUT', + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 200, + parseDocument, + { data }, + ifRevision, + ); +} + +async function deleteDocument( + collection: string, + documentId: string, + input: DataServiceWriteOptions = {}, +): Promise { + const runtime = readRuntime(); + const safeCollection = requireCollection(collection); + const safeDocumentId = requireDocumentId(documentId); + const options = requireOptions(input, ['ifRevision']); + const ifRevision = readIfRevision(options.ifRevision); + await request( + 'DELETE', + buildDocumentPath(runtime.endpoint, safeCollection, safeDocumentId), + 204, + () => undefined, + undefined, + ifRevision, + ); +} + +async function addDocument( + collection: string, + value: Record, +): Promise { + readRuntime(); + requireCollection(collection); + requireData(value); + const randomUuid = runtimeGlobal.crypto?.randomUUID; + if (typeof randomUuid !== 'function') throw runtimeUnavailable(); + const id = randomUuid(); + if (!boundedText(id, 128) || !DOCUMENT_ID_PATTERN.test(id)) throw runtimeUnavailable(); + return await putDocument(collection, id, value); +} + +export const data = Object.freeze({ + get: getDocument, + list: listDocuments, + put: putDocument, + delete: deleteDocument, + add: addDocument, +}); + +export default data; diff --git a/shared/coding-skills.ts b/shared/coding-skills.ts index d934316..14e5595 100644 --- a/shared/coding-skills.ts +++ b/shared/coding-skills.ts @@ -1,5 +1,6 @@ export const BUNDLED_CODING_SKILL_IDS = [ 'agent-browser', + 'data-service', 'frontend-slides', 'grilling', 'planning-with-files', diff --git a/src/lib/skill-display.ts b/src/lib/skill-display.ts index fb63ba8..c7f43a3 100644 --- a/src/lib/skill-display.ts +++ b/src/lib/skill-display.ts @@ -8,6 +8,10 @@ const SKILL_DISPLAY_BY_ID: Record = { name: '开发浏览器', description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。', }, + 'data-service': { + name: '开发数据服务', + description: '为本地项目配置受控的开发数据,并在预览中验证真实读写。', + }, 'frontend-slides': { name: '项目演示', description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。', diff --git a/tests/unit/data-service-sdk-assets.test.ts b/tests/unit/data-service-sdk-assets.test.ts new file mode 100644 index 0000000..40b828a --- /dev/null +++ b/tests/unit/data-service-sdk-assets.test.ts @@ -0,0 +1,300 @@ +// @vitest-environment node + +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import ts from 'typescript'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { listProductCodingSkills } from '@electron/coding-projects/skill-registry'; + +const ASSET_ROOT = path.resolve('resources/coding-skills/data-service/assets'); +const ASSETS = ['makelore-data.ts', 'makelore-data.js'] as const; +const temporaryRoots: string[] = []; +let moduleCounter = 0; + +afterEach(async () => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + delete (globalThis as { __MAKELORE_DATA__?: unknown }).__MAKELORE_DATA__; + await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function loadSdk(assetName: (typeof ASSETS)[number]): Promise<{ + data: { + get(collection: string, documentId: string): Promise; + list(collection: string, options?: { limit?: number; cursor?: string }): Promise; + put(collection: string, documentId: string, data: Record, options?: { ifRevision?: number }): Promise; + delete(collection: string, documentId: string, options?: { ifRevision?: number }): Promise; + add(collection: string, data: Record): Promise; + }; + DataServiceError: new (...args: unknown[]) => Error; +}> { + const source = await readFile(path.join(ASSET_ROOT, assetName), 'utf8'); + const root = await mkdtemp(path.join(tmpdir(), 'makelore-data-sdk-')); + temporaryRoots.push(root); + const moduleName = `${path.basename(assetName, path.extname(assetName))}-${moduleCounter += 1}.mjs`; + const javascript = assetName.endsWith('.ts') + ? ts.transpileModule(source, { + compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 }, + fileName: assetName, + }).outputText + : source; + const modulePath = path.join(root, moduleName); + await writeFile(modulePath, javascript, 'utf8'); + return await import(`${pathToFileURL(modulePath).href}?v=${moduleCounter}`) as { + data: { + get(collection: string, documentId: string): Promise; + list(collection: string, options?: { limit?: number; cursor?: string }): Promise; + put(collection: string, documentId: string, data: Record, options?: { ifRevision?: number }): Promise; + delete(collection: string, documentId: string, options?: { ifRevision?: number }): Promise; + add(collection: string, data: Record): Promise; + }; + DataServiceError: new (...args: unknown[]) => Error; + }; +} + +function injectRuntime(overrides: Record = {}): void { + Object.defineProperty(globalThis, '__MAKELORE_DATA__', { + configurable: true, + value: { + endpoint: 'http://127.0.0.1:4173/api/runtime/data/v1', + token: 'preview-token', + contractVersion: 1, + ...overrides, + }, + }); +} + +function response(payload: unknown, status = 200, headers: Record = {}): Response { + return new Response(payload === undefined ? null : JSON.stringify(payload), { + status, + headers: { 'content-type': 'application/json', ...headers }, + }); +} + +const documentDto = { + id: 'todo.1', + data: { title: 'Ship P0', done: false }, + revision: 7, + created_at: '2026-08-26T00:00:00.000Z', + updated_at: '2026-08-26T00:00:00.000Z', +}; + +// Skill installation is agent-driven; this fixture keeps its direct-text policy +// executable without adding a second runtime installer or template abstraction. +async function installFixtureAsset( + projectRoot: string, + target: string, + source: string, +): Promise<'installed' | 'noop' | 'conflict'> { + const targetPath = path.join(projectRoot, target); + let existing: string | undefined; + try { + existing = await readFile(targetPath, 'utf8'); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (existing === source) return 'noop'; + if (existing !== undefined) return 'conflict'; + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, source, 'utf8'); + return 'installed'; +} + +describe.each(ASSETS)('generated Data Service SDK (%s)', (assetName) => { + it('returns runtime_unavailable without a network call for absent or invalid injection', async () => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + const sdk = await loadSdk(assetName); + + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'runtime_unavailable', status: 503, retryable: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + + injectRuntime({ contractVersion: 2 }); + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'runtime_unavailable', status: 503, + }); + expect(fetchMock).not.toHaveBeenCalled(); + + injectRuntime({ endpoint: 'https://cloud.example/api/runtime/data/v1' }); + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'runtime_unavailable', status: 503, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('uses the injected local capability, encoded paths, direct DTOs, and one strong If-Match', async () => { + injectRuntime(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(response(documentDto)) + .mockResolvedValueOnce(response({ items: [documentDto], next_cursor: null, limit: 50 })) + .mockResolvedValueOnce(response(documentDto)) + .mockResolvedValueOnce(response(undefined, 204)); + vi.stubGlobal('fetch', fetchMock); + const sdk = await loadSdk(assetName); + + await expect(sdk.data.get('todos', 'todo.1')).resolves.toEqual(documentDto); + await expect(sdk.data.list('todos', { limit: 50, cursor: 'next cursor' })).resolves.toEqual({ + items: [documentDto], next_cursor: null, limit: 50, + }); + await expect(sdk.data.put('todos', 'todo.1', { title: 'Ship P0' }, { ifRevision: 7 })) + .resolves.toEqual(documentDto); + await expect(sdk.data.delete('todos', 'todo.1', { ifRevision: 7 })).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(4); + expect(fetchMock.mock.calls[0][0]).toBe( + 'http://127.0.0.1:4173/api/runtime/data/v1/collections/todos/documents/todo.1', + ); + expect(fetchMock.mock.calls[1][0]).toContain('/collections/todos/documents?'); + expect(fetchMock.mock.calls[1][0]).toContain('cursor=next+cursor'); + expect(fetchMock.mock.calls[2][1]).toMatchObject({ + method: 'PUT', + headers: expect.objectContaining({ + Authorization: 'Bearer preview-token', + 'If-Match': '"7"', + }), + body: JSON.stringify({ data: { title: 'Ship P0' } }), + }); + expect(fetchMock.mock.calls[3][1]).toMatchObject({ + method: 'DELETE', + headers: expect.objectContaining({ Authorization: 'Bearer preview-token', 'If-Match': '"7"' }), + }); + expect(fetchMock.mock.calls[3][1]).not.toHaveProperty('body'); + expect(JSON.stringify(fetchMock.mock.calls[3][1])).not.toContain('confirmed'); + }); + + it('strictly projects safe errors and never retries a failed request', async () => { + injectRuntime(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(response({ + detail: { + code: 'revision_conflict', + message: 'secret upstream detail', + retryable: false, + context: { current_revision: 8 }, + }, + }, 409, { 'retry-after': '10' })) + .mockRejectedValueOnce(new Error('socket detail should not escape')); + vi.stubGlobal('fetch', fetchMock); + const sdk = await loadSdk(assetName); + + const conflict = await sdk.data.put('todos', 'todo.1', { title: 'Ship P0' }, { ifRevision: 7 }) + .catch((error: unknown) => error as { + code: string; + status: number; + retryable: boolean; + retryAfterSeconds?: number; + context?: Record; + }); + expect(conflict).toMatchObject({ + code: 'revision_conflict', status: 409, retryable: false, retryAfterSeconds: 10, + context: { current_revision: 8 }, + }); + expect(Object.isFrozen(conflict)).toBe(true); + expect(Object.isFrozen(conflict.context!)).toBe(true); + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'runtime_unavailable', status: 503, retryable: true, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(JSON.stringify(await Promise.resolve(fetchMock.mock.calls))).not.toContain('secret upstream detail'); + }); + + it('rejects malformed DTOs and unknown error codes without exposing upstream detail', async () => { + injectRuntime(); + const fetchMock = vi.fn() + .mockResolvedValueOnce(response({ ...documentDto, extra: 'not allowed' })) + .mockResolvedValueOnce(response({ detail: { + code: 'toString', message: 'should not be accepted', retryable: false, + } }, 502)); + vi.stubGlobal('fetch', fetchMock); + const sdk = await loadSdk(assetName); + + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'upstream_invalid_response', status: 502, + }); + await expect(sdk.data.get('todos', 'todo.1')).rejects.toMatchObject({ + code: 'upstream_invalid_response', status: 502, + }); + expect(JSON.stringify(fetchMock.mock.calls)).not.toContain('should not be accepted'); + }); + + it('keeps DELETE as a document operation and add creates a local UUID before put', async () => { + injectRuntime(); + const fetchMock = vi.fn().mockResolvedValue(response(documentDto)); + vi.stubGlobal('fetch', fetchMock); + const uuid = '11111111-1111-4111-8111-111111111111'; + vi.stubGlobal('crypto', { randomUUID: vi.fn(() => uuid) }); + const sdk = await loadSdk(assetName); + + await expect(sdk.data.add('todos', { title: 'Ship P0' })).resolves.toEqual(documentDto); + expect(fetchMock.mock.calls[0][0]).toContain(`/documents/${uuid}`); + }); +}); + +describe('bundled Data Service Skill packaging', () => { + it('ships both canonical assets and an ordered workflow with explicit completion criteria', async () => { + const skill = await readFile(path.resolve('resources/coding-skills/data-service/SKILL.md'), 'utf8'); + const tsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.ts'), 'utf8'); + const jsAsset = await readFile(path.join(ASSET_ROOT, 'makelore-data.js'), 'utf8'); + + expect(skill).toMatch(/^---\nname: data-service\n/m); + expect(skill.indexOf('inspect')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('explicit')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('data_service_configure')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('makelore-data.ts')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('agent_browser')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('read-back')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('report')).toBeGreaterThanOrEqual(0); + expect(skill.indexOf('inspect')).toBeLessThan(skill.indexOf('data_service_configure')); + expect(skill.indexOf('data_service_configure')).toBeLessThan(skill.indexOf('agent_browser')); + expect(skill.indexOf('agent_browser')).toBeLessThan(skill.indexOf('read-back')); + expect(skill.indexOf('read-back')).toBeLessThan(skill.indexOf('report')); + expect(skill).toContain('src/lib/makelore-data.js'); + expect(skill).toContain('makelore-data.js'); + expect(skill).toContain('逐字复制'); + expect(skill).toContain('no-op'); + expect(skill).toContain('冲突'); + const packaged = await listProductCodingSkills(path.resolve('resources/coding-skills')); + const dataService = packaged.find(({ id }) => id === 'data-service'); + expect(dataService).toMatchObject({ id: 'data-service', name: 'data-service' }); + expect(dataService?.entries).toEqual(expect.arrayContaining([ + { path: 'SKILL.md', type: 'file' }, + { path: 'assets', type: 'directory' }, + { path: 'assets/makelore-data.js', type: 'file' }, + { path: 'assets/makelore-data.ts', type: 'file' }, + ])); + for (const source of [tsAsset, jsAsset]) { + expect(source).toContain('globalThis.__MAKELORE_DATA__'); + expect(source).toContain('encodeURIComponent'); + expect(source).not.toMatch(/firebase|works square|https?:\/\/[^'"`$]*api/i); + expect(source).not.toContain('confirmed'); + expect(source).not.toMatch(/retry\s*\(/i); + } + }); + + it.each([ + { + label: 'src TypeScript', target: 'src/lib/makelore-data.ts', asset: 'makelore-data.ts', + }, + { + label: 'src JavaScript', target: 'src/lib/makelore-data.js', asset: 'makelore-data.js', + }, + { + label: 'root TypeScript', target: 'makelore-data.ts', asset: 'makelore-data.ts', + }, + ])('keeps first/repeat/conflict install semantics for $label fixtures', async ({ target, asset }) => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-data-install-')); + temporaryRoots.push(root); + const source = await readFile(path.join(ASSET_ROOT, asset), 'utf8'); + + await expect(installFixtureAsset(root, target, source)).resolves.toBe('installed'); + await expect(readFile(path.join(root, target), 'utf8')).resolves.toBe(source); + await expect(installFixtureAsset(root, target, source)).resolves.toBe('noop'); + await writeFile(path.join(root, target), `${source}\n// local edit\n`, 'utf8'); + await expect(installFixtureAsset(root, target, source)).resolves.toBe('conflict'); + await expect(readFile(path.join(root, target), 'utf8')).resolves.toContain('// local edit'); + }); +}); diff --git a/tests/unit/skill-display.test.ts b/tests/unit/skill-display.test.ts index 85812f6..e25be44 100644 --- a/tests/unit/skill-display.test.ts +++ b/tests/unit/skill-display.test.ts @@ -7,6 +7,10 @@ describe('skill display metadata', () => { name: '开发浏览器', description: '打开、查看或调试本地及公网网页,读取 Console、Network、DOM 和样式信息。', }); + expect(getSkillDisplayInfo('data-service')).toEqual({ + name: '开发数据服务', + description: '为本地项目配置受控的开发数据,并在预览中验证真实读写。', + }); expect(getSkillDisplayInfo('frontend-slides')).toEqual({ name: '项目演示', description: '把项目内容整理成可播放的 16:9 HTML 幻灯片,不生成 PPTX 或云端发布。',