diff --git a/package.json b/package.json index d994ef8e4..ec8b7451b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.22", + "version": "0.10.23", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 1038fe0d6..83848c768 100644 --- a/packages/data-ai/.claude-plugin/plugin.json +++ b/packages/data-ai/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "adobe-data-ai", - "version": "0.10.22", + "version": "0.10.23", "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.", "author": { "name": "Adobe" diff --git a/packages/data-ai/README.md b/packages/data-ai/README.md index d2ed97f49..66fbe6fe2 100644 --- a/packages/data-ai/README.md +++ b/packages/data-ai/README.md @@ -55,6 +55,14 @@ self-update**, editing your `package.json` and `.gitignore` to: scripts, so a `postinstall` shipped inside the package would silently not fire; 3. gitignore the managed bundle folders. +Each managed folder also gets a short "externally managed — do not edit" notice so +an agent that opens it is warned before touching the regenerated files. The notice +follows your repo's instruction convention: it is written as `AGENTS.md` by default +(the cross-agent standard, which Claude Code also reads) and only as `CLAUDE.md` for a +repo that is still CLAUDE.md-only. `install` never drops a `CLAUDE.md` into a repo that +uses `AGENTS.md` — a stray `CLAUDE.md` anywhere makes Claude Code ignore every +`AGENTS.md` project-wide. + The copied files are regenerated artifacts — never committed, never edited in place. The wiring is idempotent, so re-running is harmless; with `--global` or in a directory with no `package.json`, `install` just copies (no wiring). diff --git a/packages/data-ai/bin/cli.mjs b/packages/data-ai/bin/cli.mjs index 96640618d..1551040c1 100755 --- a/packages/data-ai/bin/cli.mjs +++ b/packages/data-ai/bin/cli.mjs @@ -41,10 +41,13 @@ const { name: PKG_NAME, version: VERSION } = pkg; // each agent root. const BUNDLE = "adobe-data-ai"; -// A do-not-edit notice dropped at each bundle root. Claude Code loads nested -// CLAUDE.md files when working in a subtree, so an agent that opens one of these -// folders sees the notice before touching anything. `extra` adds a pointer to -// the companion bundle. +// A do-not-edit notice dropped at each bundle root so an agent that opens one of +// these managed folders sees it before touching anything. The file is named for the +// project's instruction convention (see `noticeFilename`) — never unconditionally +// `CLAUDE.md`: Claude Code treats any CLAUDE.md in the tree as switching the whole +// project into CLAUDE.md mode and then ignores every AGENTS.md project-wide (observed +// on Claude Code 2.1.286), which would silently blank out instructions in an +// AGENTS.md-based repo. `extra` adds a pointer to the companion bundle. function notice(extra) { return `# Externally managed — do not edit @@ -58,11 +61,23 @@ version, then reinstall — do not modify these files in place. ${extra ? `\n${extra}\n` : ""}`; } -function writeMeta(bundleDir, noticeBody, extraMeta) { - writeFileSync(join(bundleDir, "CLAUDE.md"), noticeBody); +// Choose the managed notice's filename from the project's instruction convention. +// Preference is AGENTS.md (the cross-agent standard, which Claude Code also reads); we +// fall back to CLAUDE.md only for a repo still on the CLAUDE.md convention (a root +// CLAUDE.md and no AGENTS.md), so the notice keeps auto-loading there. An AGENTS.md +// repo (and a greenfield one) therefore never receives a data-ai CLAUDE.md — a stray +// CLAUDE.md anywhere flips Claude Code out of AGENTS.md mode for the whole project. +function noticeFilename(base) { + const hasClaude = existsSync(join(base, "CLAUDE.md")); + const hasAgents = existsSync(join(base, "AGENTS.md")); + return hasClaude && !hasAgents ? "CLAUDE.md" : "AGENTS.md"; +} + +function writeMeta(bundleDir, noticeBody, extraMeta, noticeFile) { + writeFileSync(join(bundleDir, noticeFile), noticeBody); writeFileSync( join(bundleDir, ".data-ai.json"), - JSON.stringify({ package: PKG_NAME, version: VERSION, ...extraMeta }, null, 2) + "\n", + JSON.stringify({ package: PKG_NAME, version: VERSION, notice: noticeFile, ...extraMeta }, null, 2) + "\n", ); } @@ -107,6 +122,7 @@ function installSkills(base, skills) { bundleDir, notice("The architecture rules these skills follow are installed at\n`.claude/rules/adobe-data-ai/` (referenced by name from each SKILL.md)."), { skills }, + noticeFilename(base), ); return bundleDir; } @@ -118,7 +134,7 @@ function installRules(base) { // Copy the whole rules tree, minus the top-level README.md meta doc. const readme = join(rulesSrc, "README.md"); cpSync(rulesSrc, bundleDir, { recursive: true, filter: (src) => src !== readme }); - writeMeta(bundleDir, notice(), { rules: countRules(rulesSrc) }); + writeMeta(bundleDir, notice(), { rules: countRules(rulesSrc) }, noticeFilename(base)); return bundleDir; } diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 7a565c387..12c1efee7 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.22", + "version": "0.10.23", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-ai/test/install.test.mjs b/packages/data-ai/test/install.test.mjs new file mode 100644 index 000000000..b3a8dceba --- /dev/null +++ b/packages/data-ai/test/install.test.mjs @@ -0,0 +1,68 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// The managed notice must follow the project's instruction convention. In +// particular it must NEVER drop a CLAUDE.md into an AGENTS.md repo: on Claude Code +// 2.1.286 any CLAUDE.md in the tree switches the whole project into CLAUDE.md mode +// and suppresses AGENTS.md discovery, blanking out the repo's instructions. + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const cli = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "cli.mjs"); + +function install(dir) { + execFileSync(process.execPath, [cli, "install", `--dir=${dir}`], { stdio: "pipe" }); +} + +// The two managed bundle folders that each receive a notice. +function bundleDirs(dir) { + return [ + join(dir, ".claude", "rules", "adobe-data-ai"), + join(dir, ".agents", "skills", "adobe-data-ai"), + ]; +} + +function expectNotice(dir, present, absent) { + for (const bundle of bundleDirs(dir)) { + expect(existsSync(join(bundle, present)), `${present} in ${bundle}`).toBe(true); + expect(existsSync(join(bundle, absent)), `${absent} in ${bundle}`).toBe(false); + } +} + +describe("data-ai install — managed notice filename", () => { + let dir; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "data-ai-install-")); + }); + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it("writes AGENTS.md (never CLAUDE.md) in an AGENTS.md repo", () => { + writeFileSync(join(dir, "AGENTS.md"), "# root instructions\n"); + install(dir); + expectNotice(dir, "AGENTS.md", "CLAUDE.md"); + }); + + it("writes CLAUDE.md in a CLAUDE.md-only repo so the notice still auto-loads", () => { + writeFileSync(join(dir, "CLAUDE.md"), "# root instructions\n"); + install(dir); + expectNotice(dir, "CLAUDE.md", "AGENTS.md"); + }); + + it("prefers AGENTS.md when neither marker exists (greenfield)", () => { + install(dir); + expectNotice(dir, "AGENTS.md", "CLAUDE.md"); + }); + + it("prefers AGENTS.md when both markers exist (mid-migration)", () => { + writeFileSync(join(dir, "CLAUDE.md"), "# root instructions\n"); + writeFileSync(join(dir, "AGENTS.md"), "# root instructions\n"); + install(dir); + expectNotice(dir, "AGENTS.md", "CLAUDE.md"); + }); +}); diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index 55527c8c5..b6d906ba2 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.22", + "version": "0.10.23", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 78a8e7173..2f45cc2bd 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.22", + "version": "0.10.23", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index 00a90a335..de9460b53 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.22", + "version": "0.10.23", "description": "Worker-based incremental persistence layer for @adobe/data ECS over OPFS (browser) and node:fs (server).", "type": "module", "sideEffects": false, diff --git a/packages/data-react/package.json b/packages/data-react/package.json index b71215e8f..157f429d8 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.22", + "version": "0.10.23", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc-iframe/src/shared/main-service.ts b/packages/data-rpc-iframe/src/shared/main-service.ts index f452c0091..433ff1dec 100644 --- a/packages/data-rpc-iframe/src/shared/main-service.ts +++ b/packages/data-rpc-iframe/src/shared/main-service.ts @@ -29,9 +29,9 @@ export namespace MainService { properties: { time: { type: "observe", value: { type: "number" } }, logs: { type: "observe", value: { type: "array", items: { type: "string" } } }, - echo: { type: "function", signature: { parameters: [{ type: "string" }], returns: { type: "promise", value: { type: "string" } } } }, - countUp: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "generator", value: { type: "number" } } } }, - log: { type: "function", signature: { parameters: [{ type: "string" }] } }, + echo: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }], returns: { type: "promise", value: { type: "string" } } } }, + countUp: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "generator", value: { type: "number" } } } }, + log: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }] } }, }, required: ["time", "logs", "echo", "countUp", "log"], additionalProperties: false, diff --git a/packages/data-rpc-iframe/src/shared/sub-service.ts b/packages/data-rpc-iframe/src/shared/sub-service.ts index e6afa14bf..3eceb59a7 100644 --- a/packages/data-rpc-iframe/src/shared/sub-service.ts +++ b/packages/data-rpc-iframe/src/shared/sub-service.ts @@ -34,14 +34,14 @@ export namespace SubService { properties: { status: { type: "observe", value: { type: "string" } }, inbox: { type: "observe", value: { type: "array", items: { type: "string" } } }, - echo: { type: "function", signature: { parameters: [{ type: "string" }], returns: { type: "promise", value: { type: "string" } } } }, - countUp: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "generator", value: { type: "number" } } } }, - notify: { type: "function", signature: { parameters: [{ type: "string" }] } }, + echo: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }], returns: { type: "promise", value: { type: "string" } } } }, + countUp: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "generator", value: { type: "number" } } } }, + notify: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }] } }, calc: { type: "object", properties: { total: { type: "observe", value: { type: "number" } }, - add: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "promise", value: { type: "number" } } } }, + add: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "promise", value: { type: "number" } } } }, reset: { type: "function" }, }, required: ["total", "add", "reset"], diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index d474a72e4..6987a57c3 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.22", + "version": "0.10.23", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-rpc/src/arg-async.test.ts b/packages/data-rpc/src/arg-async.test.ts index 848a3e271..92aa59265 100644 --- a/packages/data-rpc/src/arg-async.test.ts +++ b/packages/data-rpc/src/arg-async.test.ts @@ -23,18 +23,21 @@ namespace AsyncArgService { export const schema = { type: "object", properties: { - awaitAndDouble: { type: "function", signature: { parameters: [promiseNum], returns: promiseNum } }, - sumStream: { type: "function", signature: { parameters: [genNum], returns: promiseNum } }, - first: { type: "function", signature: { parameters: [genNum], returns: promiseNum } }, + awaitAndDouble: { type: "function", signature: { parameters: [{ name: "arg0", schema: promiseNum }], returns: promiseNum } }, + sumStream: { type: "function", signature: { parameters: [{ name: "arg0", schema: genNum }], returns: promiseNum } }, + first: { type: "function", signature: { parameters: [{ name: "arg0", schema: genNum }], returns: promiseNum } }, combine: { type: "function", signature: { parameters: [ { - type: "object", - properties: { label: { type: "promise", value: { type: "string" } }, nums: genNum }, - required: ["label", "nums"], - additionalProperties: false, + name: "arg0", + schema: { + type: "object", + properties: { label: { type: "promise", value: { type: "string" } }, nums: genNum }, + required: ["label", "nums"], + additionalProperties: false, + }, }, ], returns: { type: "promise", value: { type: "string" } }, diff --git a/packages/data-rpc/src/arg-observe.test.ts b/packages/data-rpc/src/arg-observe.test.ts index 6f26779b1..9450b4833 100644 --- a/packages/data-rpc/src/arg-observe.test.ts +++ b/packages/data-rpc/src/arg-observe.test.ts @@ -24,13 +24,16 @@ namespace DisplayService { signature: { parameters: [ { - type: "object", - properties: { - alpha: { type: "observe", value: { type: "number" } }, - beta: { type: "observe", value: { type: "string" } }, + name: "arg0", + schema: { + type: "object", + properties: { + alpha: { type: "observe", value: { type: "number" } }, + beta: { type: "observe", value: { type: "string" } }, + }, + required: ["alpha", "beta"], + additionalProperties: false, }, - required: ["alpha", "beta"], - additionalProperties: false, }, ], returns: { type: "observe", value: { type: "string" } }, diff --git a/packages/data-rpc/src/consume/synthesize-service.ts b/packages/data-rpc/src/consume/synthesize-service.ts index 8d8ef6fe7..b692be895 100644 --- a/packages/data-rpc/src/consume/synthesize-service.ts +++ b/packages/data-rpc/src/consume/synthesize-service.ts @@ -29,7 +29,7 @@ function buildMembers( const memberPath = [...path, key]; if (member.type === "observe" || member.type === "function") { const kind = AsyncDataService.memberKind(member); - const params = member.signature?.parameters; + const params = member.signature?.parameters?.map((p) => p.schema); switch (kind) { case "observe": out[key] = makeObserve(ctx, service, memberPath, []); diff --git a/packages/data-rpc/src/host/register-service.ts b/packages/data-rpc/src/host/register-service.ts index 108e59984..e69bb54d2 100644 --- a/packages/data-rpc/src/host/register-service.ts +++ b/packages/data-rpc/src/host/register-service.ts @@ -52,7 +52,7 @@ function resolve( } memberSchema = props[key]; } - const params = memberSchema.signature?.parameters; + const params = memberSchema.signature?.parameters?.map((p) => p.schema); if (params !== undefined) { const errors: string[] = []; params.forEach((p, i) => { diff --git a/packages/data-rpc/src/nested.test.ts b/packages/data-rpc/src/nested.test.ts index 949227832..4fa595d14 100644 --- a/packages/data-rpc/src/nested.test.ts +++ b/packages/data-rpc/src/nested.test.ts @@ -34,13 +34,13 @@ namespace ParentService { type: "object", properties: { value: { type: "observe", value: { type: "number" } }, - add: { type: "function", signature: { parameters: [{ type: "number" }, { type: "number" }], returns: { type: "promise", value: { type: "number" } } } }, - stream: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "generator", value: { type: "number" } } } }, + add: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }, { name: "arg1", schema: { type: "number" } }], returns: { type: "promise", value: { type: "number" } } } }, + stream: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "generator", value: { type: "number" } } } }, bump: { type: "function" }, deep: { type: "object", properties: { - greet: { type: "function", signature: { parameters: [{ type: "string" }], returns: { type: "promise", value: { type: "string" } } } }, + greet: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }], returns: { type: "promise", value: { type: "string" } } } }, }, required: ["greet"], additionalProperties: false, diff --git a/packages/data-rpc/src/review-fixes.test.ts b/packages/data-rpc/src/review-fixes.test.ts index a5e9d17fb..7b8cb6019 100644 --- a/packages/data-rpc/src/review-fixes.test.ts +++ b/packages/data-rpc/src/review-fixes.test.ts @@ -24,7 +24,7 @@ interface IgnoreService extends Service { const ignoreSchema = { type: "object", properties: { - ignoreArg: { type: "function", signature: { parameters: [{ type: "promise", value: { type: "number" } }], returns: { type: "promise" } } }, + ignoreArg: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "promise", value: { type: "number" } } }], returns: { type: "promise" } } }, }, required: ["ignoreArg"], additionalProperties: false, @@ -73,9 +73,9 @@ interface LateService extends Service { const lateSchema = { type: "object", properties: { - lateAwait: { type: "function", signature: { parameters: [{ type: "promise", value: { type: "number" } }], returns: { type: "promise" } } }, + lateAwait: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "promise", value: { type: "number" } } }], returns: { type: "promise" } } }, result: { type: "observe", value: { type: "string" } }, - lateDrain: { type: "function", signature: { parameters: [{ type: "generator", value: { type: "number" } }], returns: { type: "promise" } } }, + lateDrain: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "generator", value: { type: "number" } } }], returns: { type: "promise" } } }, drained: { type: "observe", value: { type: "string" } }, }, required: ["lateAwait", "result", "lateDrain", "drained"], @@ -201,7 +201,7 @@ interface VoidArgService extends Service { const voidArgSchema = { type: "object", properties: { - logObs: { type: "function", signature: { parameters: [{ type: "observe", value: { type: "number" } }] } }, + logObs: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "observe", value: { type: "number" } } }] } }, }, required: ["logObs"], additionalProperties: false, @@ -235,7 +235,7 @@ interface AwaitService extends Service { const awaitSchema = { type: "object", properties: { - awaitIt: { type: "function", signature: { parameters: [{ type: "promise", value: { type: "number" } }], returns: { type: "promise", value: { type: "number" } } } }, + awaitIt: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "promise", value: { type: "number" } } }], returns: { type: "promise", value: { type: "number" } } } }, }, required: ["awaitIt"], additionalProperties: false, diff --git a/packages/data-rpc/src/rpc.test.ts b/packages/data-rpc/src/rpc.test.ts index c042c51a6..7bd4ee173 100644 --- a/packages/data-rpc/src/rpc.test.ts +++ b/packages/data-rpc/src/rpc.test.ts @@ -33,11 +33,11 @@ namespace DemoService { properties: { value: { type: "observe", value: { type: "number" } }, tracked: { type: "observe", value: { type: "number" } }, - selectDouble: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "observe", value: { type: "number" } } } }, - add: { type: "function", signature: { parameters: [{ type: "number" }, { type: "number" }], returns: { type: "promise", value: { type: "number" } } } }, - fail: { type: "function", signature: { parameters: [{ type: "string" }], returns: { type: "promise", value: { type: "number" } } } }, - countUp: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "generator", value: { type: "number" } } } }, - explode: { type: "function", signature: { parameters: [{ type: "number" }], returns: { type: "generator", value: { type: "number" } } } }, + selectDouble: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "observe", value: { type: "number" } } } }, + add: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }, { name: "arg1", schema: { type: "number" } }], returns: { type: "promise", value: { type: "number" } } } }, + fail: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "string" } }], returns: { type: "promise", value: { type: "number" } } } }, + countUp: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "generator", value: { type: "number" } } } }, + explode: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "generator", value: { type: "number" } } } }, bump: { type: "function" }, boom: { type: "function" }, }, diff --git a/packages/data-rpc/src/transports/message-port-transport.test.ts b/packages/data-rpc/src/transports/message-port-transport.test.ts index c098842f2..cd1af33b7 100644 --- a/packages/data-rpc/src/transports/message-port-transport.test.ts +++ b/packages/data-rpc/src/transports/message-port-transport.test.ts @@ -16,7 +16,7 @@ const calcSchema = { type: "object", properties: { value: { type: "observe", value: { type: "number" } }, - add: { type: "function", signature: { parameters: [{ type: "number" }, { type: "number" }], returns: { type: "promise", value: { type: "number" } } } }, + add: { type: "function", signature: { parameters: [{ name: "arg0", schema: { type: "number" } }, { name: "arg1", schema: { type: "number" } }], returns: { type: "promise", value: { type: "number" } } } }, }, required: ["value", "add"], additionalProperties: false, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index c5dda014a..2f8bbac04 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.22", + "version": "0.10.23", "description": "Adobe data SolidJS bindings — context and provider for ECS database", "type": "module", "private": false, diff --git a/packages/data-sync/package.json b/packages/data-sync/package.json index 865fd1727..c2bafedc6 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.22", + "version": "0.10.23", "description": "Multi-user real-time synchronisation for @adobe/data ECS — server, client, and in-process loopback.", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json index e3d102195..7b8f6cd42 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.22", + "version": "0.10.23", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data/package.json b/packages/data/package.json index 89851ac23..1eeea5c40 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.22", + "version": "0.10.23", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index f33dd1aa9..2083e48e3 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -1,13 +1,13 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema as SchemaType, Layout, Conditional, JSONPath, JSONMergePatch } from "./schema.js"; +import type { Schema as SchemaType, Layout, Conditional, JSONPath, JSONMergePatch, Parameter, PropertyMeta } from "./schema.js"; export type Schema = SchemaType; export * as Schema from "./public.js"; export type { FromSchemas } from "./from-schemas.js"; -export type { Layout, Conditional, JSONPath, JSONMergePatch }; +export type { Layout, Conditional, JSONPath, JSONMergePatch, Parameter, PropertyMeta }; export type { getDynamicSchema } from "./dynamic/index.js"; export * from "./validation/index.js"; diff --git a/packages/data/src/schema/public.ts b/packages/data/src/schema/public.ts index 3edfe078e..308e4ce44 100644 --- a/packages/data/src/schema/public.ts +++ b/packages/data/src/schema/public.ts @@ -4,6 +4,7 @@ export * from "./schema.js"; export * from "./to-vertex-buffer-layout.js"; export * from "./nullable.js"; export * from "./to-type.js"; +export * from "./to-typescript.js"; export * from "./resolve-external-invocation.js"; export * from "./from-object-properties.js"; export * from "./from-archetype.js"; diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index f783c297a..cf496cea5 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -87,9 +87,15 @@ export interface Schema { * `{ type: "function", signature: { parameters, returns } }` → * `(...args) => ToType`. Absent `parameters` ⇒ no args; absent * `returns` ⇒ void; absent `signature` entirely ⇒ `() => void`. + * + * Each parameter is a named descriptor (`Parameter`) rather than a bare schema, + * so the argument's name and its own documentation are known independently of + * its type (`parameter.schema`). `parameter.description` documents the argument; + * `parameter.schema.description` documents the type. Neither affects `ToType`, + * which derives the call signature positionally from `parameter.schema`. */ signature?: { - readonly parameters?: readonly Schema[]; + readonly parameters?: readonly Parameter[]; readonly returns?: Schema; /** * Invocation policy — who may call this function from an **untrusted @@ -114,6 +120,12 @@ export interface Schema { readonly external?: { readonly agent?: boolean; readonly link?: boolean }; }; properties?: { readonly [key: string]: Schema }; + // Per-property metadata kept OUT of `properties` so a property whose type is a + // shared/by-reference schema can carry field-level docs without mutating that + // shared type. Keyed by the same property name as `properties`. Pure metadata: + // does NOT affect `ToType` (a schema differing only in `propertyMeta` derives + // the same type). See `PropertyMeta`. + propertyMeta?: { readonly [key: string]: PropertyMeta }; required?: readonly string[]; additionalProperties?: boolean | Schema; oneOf?: readonly Schema[]; @@ -134,3 +146,23 @@ export interface Schema { readonly cubicSpline?: string; }; } + +/** + * A named function parameter: the argument's `name` and `description` are known + * independently of its type (`schema`). `description` documents the argument; + * `schema.description` documents the type. Only `schema` affects `ToType`. + */ +export interface Parameter { + readonly name: string; + readonly description?: string; + readonly schema: Schema; +} + +/** + * Field-level metadata for one object property, held in `Schema.propertyMeta` so it + * stays independent of the property's (possibly shared) type. This interface is the + * extension point for future per-field metadata that must not live on the type. + */ +export interface PropertyMeta { + readonly description?: string; +} diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts index ebce187e2..8f129efa6 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -3,7 +3,7 @@ import { TypedBuffer } from "../typed-buffer/typed-buffer.js"; import { DeepReadonly, EquivalentTypes, False, True } from "../types/types.js"; import type { Observe } from "../observe/index.js"; -import { Schema } from "./schema.js"; +import { Parameter, Schema } from "./schema.js"; export type ToType = T extends false ? void : @@ -74,10 +74,12 @@ type FromSchemaFunction = // The signature's parameters tuple; absent ⇒ no args. type SignatureParams = - Sig extends { parameters: infer P } ? P extends readonly Schema[] ? P : readonly [] : readonly []; + Sig extends { parameters: infer P } ? P extends readonly Parameter[] ? P : readonly [] : readonly []; -type FromSchemaArgs

= { - -readonly [K in keyof P]: ToType; +// Each parameter contributes its `schema` positionally; the parameter `name` and +// `description` are metadata for tooling and do not affect the derived signature. +type FromSchemaArgs

= { + -readonly [K in keyof P]: ToType; }; // Absent `returns` ⇒ void (a function that returns nothing meaningful). @@ -330,7 +332,10 @@ type CheckGenerator = True; // (a: number, b: string) => boolean type CheckFunction = True boolean>>; @@ -343,7 +348,7 @@ type CheckFunctionNoParams = True vo // `external` invocation-policy metadata never affects the derived function type. type TestFunctionExternalIgnored = ToType<{ type: 'function', signature: { - parameters: [{ type: 'number' }], returns: { type: 'promise', value: { type: 'number' } }, + parameters: [{ name: 'a', schema: { type: 'number' } }], returns: { type: 'promise', value: { type: 'number' } }, external: { link: true, agent: false } } }>; // (a: number) => Promise @@ -355,10 +360,13 @@ type TestStreamingAction = ToType<{ type: 'function', signature: { parameters: [{ - type: 'object', - properties: { chunks: { type: 'generator', value: { type: 'number' } } }, - required: ['chunks'], - additionalProperties: false + name: 'arg', + schema: { + type: 'object', + properties: { chunks: { type: 'generator', value: { type: 'number' } } }, + required: ['chunks'], + additionalProperties: false + } }], returns: { type: 'promise', value: { type: 'null' } } } diff --git a/packages/data/src/schema/to-typescript.test.ts b/packages/data/src/schema/to-typescript.test.ts new file mode 100644 index 000000000..2a9cccb28 --- /dev/null +++ b/packages/data/src/schema/to-typescript.test.ts @@ -0,0 +1,768 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, expect, it } from "vitest"; +import { Schema } from "./index.js"; + +describe("Schema.toTypeScript", () => { + describe("primitives", () => { + it("maps number/integer to number", () => { + expect(Schema.toTypeScript({ type: "number" }, "N")).toBe("type N = number;"); + expect(Schema.toTypeScript({ type: "integer" }, "N")).toBe("type N = number;"); + }); + + it("maps string, boolean, null and blob", () => { + expect(Schema.toTypeScript({ type: "string" }, "S")).toBe("type S = string;"); + expect(Schema.toTypeScript({ type: "boolean" }, "B")).toBe("type B = boolean;"); + expect(Schema.toTypeScript({ type: "null" }, "Nul")).toBe("type Nul = null;"); + expect(Schema.toTypeScript({ type: "blob" }, "Bl")).toBe("type Bl = Blob;"); + }); + }); + + describe("const, enum, default", () => { + it("renders const literals", () => { + expect(Schema.toTypeScript({ const: 42 }, "C")).toBe("type C = 42;"); + expect(Schema.toTypeScript({ const: "x" }, "C")).toBe('type C = "x";'); + }); + + it("renders enum as a union", () => { + expect(Schema.toTypeScript({ enum: [1, 2, 3] }, "E")).toBe("type E = 1 | 2 | 3;"); + expect(Schema.toTypeScript({ enum: ["a", "b"] }, "E")).toBe('type E = "a" | "b";'); + }); + + it("renders a typeless default as its literal", () => { + expect(Schema.toTypeScript({ default: 7 }, "D")).toBe("type D = 7;"); + }); + }); + + describe("objects", () => { + it("emits an interface with readonly members and optional keys", () => { + const schema = { + type: "object", + properties: { a: { type: "number" }, b: { type: "string" } }, + required: ["a"], + additionalProperties: false, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Obj")).toBe( + ["interface Obj {", " readonly a: number;", " readonly b?: string;", "}"].join("\n"), + ); + }); + + it("drops readonly when the object node is mutable", () => { + const schema = { + type: "object", + mutable: true, + properties: { a: { type: "number" } }, + required: ["a"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Obj")).toBe( + ["interface Obj {", " a: number;", "}"].join("\n"), + ); + }); + + it("emits an index signature for schema additionalProperties", () => { + const schema = { + type: "object", + additionalProperties: { type: "number" }, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Dict")).toBe( + ["interface Dict {", " readonly [key: string]: number;", "}"].join("\n"), + ); + }); + + it("emits [key: string]: any for additionalProperties true", () => { + const schema = { type: "object", additionalProperties: true } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Any")).toBe( + ["interface Any {", " readonly [key: string]: any;", "}"].join("\n"), + ); + }); + + it("emits {} for an empty object", () => { + expect(Schema.toTypeScript({ type: "object" }, "Empty")).toBe("interface Empty {}"); + }); + + it("quotes non-identifier property keys", () => { + const schema = { + type: "object", + properties: { "with-dash": { type: "number" } }, + required: ["with-dash"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Q")).toBe( + ["interface Q {", ' readonly "with-dash": number;', "}"].join("\n"), + ); + }); + + it("inlines nested objects with increasing indentation", () => { + const schema = { + type: "object", + properties: { + inner: { + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + }, + }, + required: ["inner"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Outer")).toBe( + [ + "interface Outer {", + " readonly inner: {", + " readonly x: number;", + " };", + "}", + ].join("\n"), + ); + }); + }); + + describe("arrays and tuples", () => { + it("renders a readonly array", () => { + expect(Schema.toTypeScript({ type: "array", items: { type: "string" } }, "A")).toBe( + "type A = readonly string[];", + ); + }); + + it("renders a fixed-length tuple when minItems === maxItems", () => { + const schema = { + type: "array", + items: { type: "number" }, + minItems: 3, + maxItems: 3, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Vec3")).toBe("type Vec3 = readonly [number, number, number];"); + }); + + it("drops readonly on a mutable array", () => { + const schema = { type: "array", mutable: true, items: { type: "number" } } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "A")).toBe("type A = number[];"); + }); + + it("parenthesizes a union element before the array suffix", () => { + const schema = { type: "array", items: { enum: [1, 2] } } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "A")).toBe("type A = readonly (1 | 2)[];"); + }); + + it("renders a bare any[] when items are absent", () => { + expect(Schema.toTypeScript({ type: "array" }, "A")).toBe("type A = readonly any[];"); + }); + }); + + describe("combinators", () => { + it("renders oneOf as a union", () => { + const schema = { oneOf: [{ type: "string" }, { type: "number" }] } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "U")).toBe("type U = string | number;"); + }); + + it("renders allOf as an intersection", () => { + const schema = { + allOf: [ + { type: "object", properties: { a: { type: "number" } }, required: ["a"] }, + { type: "object", properties: { b: { type: "string" } }, required: ["b"] }, + ], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "I")).toBe( + [ + "type I = {", + " readonly a: number;", + "} & {", + " readonly b: string;", + "};", + ].join("\n"), + ); + }); + + it("renders a Nullable schema as a union with null", () => { + expect(Schema.toTypeScript(Schema.Nullable({ type: "string" }), "MaybeStr")).toBe( + "type MaybeStr = string | null;", + ); + }); + }); + + describe("service constructors", () => { + it("wraps observe/promise/generator value types", () => { + expect(Schema.toTypeScript({ type: "observe", value: { type: "number" } }, "O")).toBe( + "type O = Observe;", + ); + expect(Schema.toTypeScript({ type: "promise", value: { type: "string" } }, "P")).toBe( + "type P = Promise;", + ); + expect(Schema.toTypeScript({ type: "generator", value: { type: "boolean" } }, "G")).toBe( + "type G = AsyncGenerator;", + ); + }); + + it("defaults an absent wrapped value to any", () => { + expect(Schema.toTypeScript({ type: "observe" }, "O")).toBe("type O = Observe;"); + }); + + it("renders a function signature with named parameters", () => { + const schema = { + type: "function", + signature: { + parameters: [ + { name: "a", schema: { type: "number" } }, + { name: "b", schema: { type: "string" } }, + ], + returns: { type: "boolean" }, + }, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "F")).toBe("type F = (a: number, b: string) => boolean;"); + }); + + it("renders a parameterless function as () => void", () => { + expect(Schema.toTypeScript({ type: "function" }, "F")).toBe("type F = () => void;"); + }); + + it("renders an action returning a promise inside a service interface", () => { + const schema = { + type: "object", + properties: { + run: { + type: "function", + signature: { + parameters: [ + { name: "input", schema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, + ], + returns: { type: "promise", value: { type: "null" } }, + }, + }, + status: { type: "observe", value: { enum: ["idle", "busy"] } }, + }, + required: ["run", "status"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Service")).toBe( + [ + "interface Service {", + " readonly run: (input: {", + " readonly id: string;", + " }) => Promise;", + ' readonly status: Observe<"idle" | "busy">;', + "}", + ].join("\n"), + ); + }); + }); + + describe("recursion", () => { + it("descends through several levels of nested objects", () => { + const schema = { + type: "object", + properties: { + a: { + type: "object", + properties: { + b: { + type: "object", + properties: { c: { type: "number" } }, + required: ["c"], + }, + }, + required: ["b"], + }, + }, + required: ["a"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Deep")).toBe( + [ + "interface Deep {", + " readonly a: {", + " readonly b: {", + " readonly c: number;", + " };", + " };", + "}", + ].join("\n"), + ); + }); + + it("recurses into array items and function return types", () => { + const schema = { + type: "object", + properties: { + items: { type: "array", items: { type: "object", properties: { id: { type: "string" } }, required: ["id"] } }, + make: { + type: "function", + signature: { returns: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"] } }, + }, + }, + required: ["items", "make"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Bag")).toBe( + [ + "interface Bag {", + " readonly items: readonly {", + " readonly id: string;", + " }[];", + " readonly make: () => {", + " readonly ok: boolean;", + " };", + "}", + ].join("\n"), + ); + }); + }); + + describe("shared shape deduplication", () => { + it("hoists a repeated shape and names it from the first property key", () => { + const point = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] } as const; + const schema = { + type: "object", + properties: { from: point, to: point, label: { type: "string" } }, + required: ["from", "to"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Line")).toBe( + [ + "interface From {", + " readonly x: number;", + " readonly y: number;", + "}", + "", + "interface Line {", + " readonly from: From;", + " readonly to: From;", + " readonly label?: string;", + "}", + ].join("\n"), + ); + }); + + it("uses the shape title as the common semantic name", () => { + const point = { + title: "Point", + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + } as const; + const schema = { + type: "object", + properties: { from: point, to: point }, + required: ["from", "to"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Line")).toBe( + [ + "interface Point {", + " readonly x: number;", + " readonly y: number;", + "}", + "", + "interface Line {", + " readonly from: Point;", + " readonly to: Point;", + "}", + ].join("\n"), + ); + }); + + it("inlines a shape used only once alongside a hoisted repeated shape", () => { + const point = { type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] } as const; + const schema = { + type: "object", + properties: { + from: point, + to: point, + meta: { type: "object", properties: { created: { type: "number" } }, required: ["created"] }, + }, + required: ["from", "to", "meta"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Line")).toBe( + [ + "interface From {", + " readonly x: number;", + " readonly y: number;", + "}", + "", + "interface Line {", + " readonly from: From;", + " readonly to: From;", + " readonly meta: {", + " readonly created: number;", + " };", + "}", + ].join("\n"), + ); + }); + + it("does not merge distinct shapes", () => { + const schema = { + type: "object", + properties: { + a: { type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + b: { type: "object", properties: { y: { type: "number" } }, required: ["y"] }, + }, + required: ["a", "b"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Pair")).toBe( + [ + "interface Pair {", + " readonly a: {", + " readonly x: number;", + " };", + " readonly b: {", + " readonly y: number;", + " };", + "}", + ].join("\n"), + ); + }); + + it("deduplicates identical shapes ignoring title/description differences", () => { + const schema = { + type: "object", + properties: { + from: { title: "Point", type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + to: { description: "the end", type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + }, + required: ["from", "to"], + } as const satisfies Schema; + // First-seen title ("Point") wins the common name; both reference it. + expect(Schema.toTypeScript(schema, "Seg")).toBe( + [ + "interface Point {", + " readonly x: number;", + "}", + "", + "interface Seg {", + " readonly from: Point;", + " // the end", + " readonly to: Point;", + "}", + ].join("\n"), + ); + }); + + it("hoists an object used as a repeated fixed-tuple element", () => { + const schema = { + type: "array", + items: { type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + minItems: 2, + maxItems: 2, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Pair")).toBe( + [ + "interface Shape1 {", + " readonly x: number;", + "}", + "", + "type Pair = readonly [Shape1, Shape1];", + ].join("\n"), + ); + }); + + it("disambiguates when two distinct repeated shapes derive the same name", () => { + const shapeP = { type: "object", properties: { p: { type: "number" } }, required: ["p"] } as const; + const shapeQ = { type: "object", properties: { q: { type: "number" } }, required: ["q"] } as const; + // Both inner shapes repeat via the `item` key, so both derive the name "Item". + const schema = { + type: "object", + properties: { + first: { type: "object", properties: { w: { type: "number" }, item: shapeP }, required: ["w", "item"] }, + second: { type: "object", properties: { z: { type: "number" }, item: shapeP }, required: ["z", "item"] }, + third: { type: "object", properties: { m: { type: "number" }, item: shapeQ }, required: ["m", "item"] }, + fourth: { type: "object", properties: { n: { type: "number" }, item: shapeQ }, required: ["n", "item"] }, + }, + required: ["first", "second", "third", "fourth"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Root")).toBe( + [ + "interface Item {", + " readonly p: number;", + "}", + "", + "interface Item2 {", + " readonly q: number;", + "}", + "", + "interface Root {", + " readonly first: {", + " readonly w: number;", + " readonly item: Item;", + " };", + " readonly second: {", + " readonly z: number;", + " readonly item: Item;", + " };", + " readonly third: {", + " readonly m: number;", + " readonly item: Item2;", + " };", + " readonly fourth: {", + " readonly n: number;", + " readonly item: Item2;", + " };", + "}", + ].join("\n"), + ); + }); + }); + + describe("descriptions as comments", () => { + it("comments a top-level type alias", () => { + expect(Schema.toTypeScript({ type: "string", description: "the id" }, "Id")).toBe( + ["// the id", "type Id = string;"].join("\n"), + ); + }); + + it("comments an interface and its properties", () => { + const schema = { + type: "object", + description: "A 2D point", + properties: { + x: { type: "number", description: "the x coordinate" }, + y: { type: "number" }, + }, + required: ["x", "y"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Point")).toBe( + [ + "// A 2D point", + "interface Point {", + " // the x coordinate", + " readonly x: number;", + " readonly y: number;", + "}", + ].join("\n"), + ); + }); + + it("comments a function-typed property", () => { + const schema = { + type: "object", + properties: { + run: { + type: "function", + description: "runs the job", + signature: { returns: { type: "promise", value: { type: "null" } } }, + }, + }, + required: ["run"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Svc")).toBe( + [ + "interface Svc {", + " // runs the job", + " readonly run: () => Promise;", + "}", + ].join("\n"), + ); + }); + + it("suppresses a property comment that repeats the referenced type's own comment", () => { + const point = { + description: "A point", + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + } as const; + const schema = { + type: "object", + properties: { from: point, to: point }, + required: ["from", "to"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Seg")).toBe( + [ + "// A point", + "interface From {", + " readonly x: number;", + "}", + "", + "interface Seg {", + " readonly from: From;", + " readonly to: From;", + "}", + ].join("\n"), + ); + }); + + it("keeps a property comment that differs from the referenced type's comment", () => { + const schema = { + type: "object", + properties: { + from: { description: "A point", type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + to: { description: "the origin", type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + }, + required: ["from", "to"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Seg")).toBe( + [ + "// A point", + "interface From {", + " readonly x: number;", + "}", + "", + "interface Seg {", + " readonly from: From;", + " // the origin", + " readonly to: From;", + "}", + ].join("\n"), + ); + }); + + it("renders a multi-line description as multiple comment lines", () => { + const schema = { + type: "object", + properties: { x: { type: "number", description: "line one\nline two" } }, + required: ["x"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "M")).toBe( + [ + "interface M {", + " // line one", + " // line two", + " readonly x: number;", + "}", + ].join("\n"), + ); + }); + }); + + describe("named parameters", () => { + it("uses parameter names in a single-line signature", () => { + const schema = { + type: "function", + signature: { + parameters: [ + { name: "x", schema: { type: "number" } }, + { name: "y", schema: { type: "number" } }, + ], + returns: { type: "null" }, + }, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "F")).toBe("type F = (x: number, y: number) => null;"); + }); + + it("breaks parameters onto their own lines with independent descriptions", () => { + const vec2 = { + title: "Vec2", + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + } as const; + const schema = { + type: "object", + properties: { + move: { + type: "function", + signature: { + parameters: [ + { name: "position", description: "in meters", schema: vec2 }, + { name: "velocity", description: "in meters/second", schema: vec2 }, + ], + }, + }, + }, + required: ["move"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "Body")).toBe( + [ + "interface Vec2 {", + " readonly x: number;", + " readonly y: number;", + "}", + "", + "interface Body {", + " readonly move: (", + " // in meters", + " position: Vec2,", + " // in meters/second", + " velocity: Vec2,", + " ) => void;", + "}", + ].join("\n"), + ); + }); + }); + + describe("propertyMeta descriptions", () => { + it("documents properties independently of a shared property type", () => { + const vec2 = { + title: "Vec2", + type: "object", + properties: { x: { type: "number" }, y: { type: "number" } }, + required: ["x", "y"], + } as const; + const schema = { + type: "object", + properties: { position: vec2, velocity: vec2 }, + propertyMeta: { + position: { description: "in meters" }, + velocity: { description: "in meters/second" }, + }, + required: ["position", "velocity"], + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "State")).toBe( + [ + "interface Vec2 {", + " readonly x: number;", + " readonly y: number;", + "}", + "", + "interface State {", + " // in meters", + " readonly position: Vec2;", + " // in meters/second", + " readonly velocity: Vec2;", + "}", + ].join("\n"), + ); + }); + + it("wins over the property type's own description and is never suppressed", () => { + const vec2 = { + title: "Vec2", + description: "A vector", + type: "object", + properties: { x: { type: "number" } }, + required: ["x"], + } as const; + const schema = { + type: "object", + properties: { a: vec2, b: vec2 }, + propertyMeta: { a: { description: "left" } }, + required: ["a", "b"], + } as const satisfies Schema; + // Vec2's own doc lands on the interface; `a` overrides with its field doc; `b` + // falls back to the type doc, which is suppressed because it repeats the interface. + expect(Schema.toTypeScript(schema, "Pair")).toBe( + [ + "// A vector", + "interface Vec2 {", + " readonly x: number;", + "}", + "", + "interface Pair {", + " // left", + " readonly a: Vec2;", + " readonly b: Vec2;", + "}", + ].join("\n"), + ); + }); + + it("does not affect structural shape identity", () => { + const schema = { + type: "object", + properties: { + from: { type: "object", properties: { x: { type: "number" } }, required: ["x"], propertyMeta: { x: { description: "start" } } }, + to: { type: "object", properties: { x: { type: "number" } }, required: ["x"] }, + }, + required: ["from", "to"], + } as const satisfies Schema; + // Both `from` and `to` are the same shape despite `from`'s propertyMeta, so they + // share one hoisted interface (named from the first key, "From"). + expect(Schema.toTypeScript(schema, "Seg")).toBe( + [ + "interface From {", + " // start", + " readonly x: number;", + "}", + "", + "interface Seg {", + " readonly from: From;", + " readonly to: From;", + "}", + ].join("\n"), + ); + }); + }); +}); diff --git a/packages/data/src/schema/to-typescript.ts b/packages/data/src/schema/to-typescript.ts new file mode 100644 index 000000000..bf43a1c26 --- /dev/null +++ b/packages/data/src/schema/to-typescript.ts @@ -0,0 +1,461 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Schema } from "./schema.js"; + +const indent = " "; + +/** + * Converts a Schema to one or more TypeScript type declarations. + * + * The generated type is the source-text equivalent of `Schema.ToType`: + * it mirrors that mapping (including its deep-readonly default — a schema node opts + * out with `mutable: true`) so the two stay interchangeable. The generator: + * + * - **Recurses** the whole schema tree (properties, array items, `oneOf`/`allOf`/ + * `anyOf`, function signatures, and observe/promise/generator value types). + * - **Deduplicates repeated object shapes.** Any object shape that would appear more + * than once in the output (the same structure used in ≥2 places, or the element of + * a fixed-length tuple of length ≥2) is pre-declared once at the top as a named + * `interface` and referenced by name everywhere it occurs. Shapes used exactly once + * stay inlined. The name is the shape's `title` (PascalCased) when present, else the + * referencing property key, else a generated `Shape`. + * - **Emits `description`s as `//` comments** directly above the declaration, property, + * or function they annotate (one comment line per line of the description). + * + * A schema that resolves to an object is emitted as an `interface`; anything else as a + * `type` alias. The result references the ambient names `Blob`, `Promise`, + * `AsyncGenerator`, `TypedBuffer` (`@adobe/data`) and `Observe` (`@adobe/data/observe`) + * for the corresponding schema constructors; the caller supplies those imports. + * + * @example + * ```typescript + * toTypeScript( + * { + * type: "object", + * description: "A line segment", + * properties: { + * from: { title: "Point", type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] }, + * to: { title: "Point", type: "object", properties: { x: { type: "number" }, y: { type: "number" } }, required: ["x", "y"] }, + * }, + * required: ["from", "to"], + * }, + * "Line", + * ); + * // interface Point { + * // readonly x: number; + * // readonly y: number; + * // } + * // + * // // A line segment + * // interface Line { + * // readonly from: Point; + * // readonly to: Point; + * // } + * ``` + */ +export function toTypeScript(schema: Schema, name: string): string { + const context = createContext(); + walk(schema, undefined, 1, context); + const hoisted = assignNames(schema, name, context); + + const declarations = hoisted.map((entry) => renderDeclaration(entry.name, entry.schema, context)); + declarations.push(renderDeclaration(name, schema, context)); + return declarations.join("\n\n"); +} + +interface Context { + // Structural-shape key → number of times that shape appears in the output. + readonly counts: Map; + // Shape keys in first-seen order, so declarations emit deterministically. + readonly discovery: string[]; + // First-seen schema for each shape — used to render its shared declaration. + readonly firstSchema: Map; + // Name candidates: the shape's own `title`, and the property key referencing it. + readonly titleHint: Map; + readonly keyHint: Map; + // Shape key → assigned interface name, for the shapes that are hoisted + shared. + readonly names: Map; +} + +function createContext(): Context { + return { + counts: new Map(), + discovery: [], + firstSchema: new Map(), + titleHint: new Map(), + keyHint: new Map(), + names: new Map(), + }; +} + +// --- Pass 1: count how many times each object shape appears in the output -------- + +// `multiplier` is the number of copies of the current subtree the output will contain +// (a fixed tuple of length n multiplies its element's copies by n), so an object that +// only appears once in the schema but is a repeated tuple element still deduplicates. +function walk(schema: Schema, keyHint: string | undefined, multiplier: number, context: Context): void { + if (resolvesToObject(schema)) { + register(schema, keyHint, multiplier, context); + } + + if (schema.const !== undefined || schema.enum) return; + if (schema.oneOf) return walkEach(schema.oneOf, multiplier, context); + if (schema.anyOf) return walkEach(schema.anyOf, multiplier, context); + if (schema.allOf) return walkEach(schema.allOf, multiplier, context); + + switch (schema.type) { + case "typed-buffer": + if (schema.items) walk(schema.items, undefined, multiplier, context); + return; + case "observe": + case "promise": + case "generator": + if (schema.value) walk(schema.value, undefined, multiplier, context); + return; + case "function": + if (schema.signature) { + for (const parameter of schema.signature.parameters ?? []) { + walk(parameter.schema, undefined, multiplier, context); + } + if (schema.signature.returns) walk(schema.signature.returns, undefined, multiplier, context); + } + return; + } + + if (schema.type === "array" || schema.items !== undefined) { + if (schema.items) { + const length = tupleLength(schema); + walk(schema.items, keyHint, length !== undefined ? multiplier * length : multiplier, context); + } + return; + } + if (schema.type === undefined && schema.default !== undefined) return; + if (schema.type === "object" || schema.properties !== undefined) { + for (const [key, child] of Object.entries(schema.properties ?? {})) { + walk(child, key, multiplier, context); + } + const additional = schema.additionalProperties; + if (additional !== undefined && typeof additional === "object") { + walk(additional, undefined, multiplier, context); + } + } +} + +function walkEach(schemas: readonly Schema[], multiplier: number, context: Context): void { + for (const schema of schemas) walk(schema, undefined, multiplier, context); +} + +function register(schema: Schema, keyHint: string | undefined, multiplier: number, context: Context): void { + const key = canonical(schema); + if (!context.counts.has(key)) { + context.counts.set(key, 0); + context.discovery.push(key); + context.firstSchema.set(key, schema); + } + context.counts.set(key, (context.counts.get(key) ?? 0) + multiplier); + if (schema.title && !context.titleHint.has(key)) context.titleHint.set(key, schema.title); + if (keyHint && !context.keyHint.has(key)) context.keyHint.set(key, keyHint); +} + +// --- Between passes: decide which shapes are hoisted and name them --------------- + +interface HoistedDeclaration { + readonly name: string; + readonly schema: Schema; +} + +function assignNames(root: Schema, name: string, context: Context): HoistedDeclaration[] { + const used = new Set(); + const declarations: HoistedDeclaration[] = []; + + let rootKey: string | undefined; + if (resolvesToObject(root)) { + rootKey = canonical(root); + context.names.set(rootKey, name); + used.add(name); + } + + let generated = 0; + for (const key of context.discovery) { + if (key === rootKey) continue; + if ((context.counts.get(key) ?? 0) < 2) continue; + const schema = context.firstSchema.get(key); + if (!schema) continue; + const base = deriveName(key, context) ?? `Shape${++generated}`; + const unique = uniqueName(base, used); + context.names.set(key, unique); + used.add(unique); + declarations.push({ name: unique, schema }); + } + + return declarations; +} + +function deriveName(key: string, context: Context): string | undefined { + const title = context.titleHint.get(key); + if (title) { + const cased = pascalCase(title); + if (cased) return cased; + } + const propertyKey = context.keyHint.get(key); + if (propertyKey) { + const cased = pascalCase(propertyKey); + if (cased) return cased; + } + return undefined; +} + +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) return base; + let index = 2; + while (used.has(`${base}${index}`)) index++; + return `${base}${index}`; +} + +// --- Pass 2: render ------------------------------------------------------------ + +function renderDeclaration(name: string, schema: Schema, context: Context): string { + const comment = commentBlock(schema.description, ""); + return resolvesToObject(schema) + ? `${comment}interface ${name} ${objectBody(schema, 0, context)}` + : `${comment}type ${name} = ${typeExpression(schema, 0, context)};`; +} + +// Branches follow `ToType`'s resolution order so the produced type matches it. +function typeExpression(schema: Schema, depth: number, context: Context): string { + const readonly = schema.mutable !== true; + + if (schema.const !== undefined) return literal(schema.const, depth, readonly); + if (schema.enum) return union(schema.enum.map((value) => literal(value, depth, readonly))); + if (schema.oneOf) return union(schema.oneOf.map((sub) => typeExpression(sub, depth, context))); + if (schema.anyOf) return union(schema.anyOf.map((sub) => typeExpression(sub, depth, context))); + if (schema.allOf) return intersection(schema.allOf.map((sub) => typeExpression(sub, depth, context))); + + switch (schema.type) { + case "number": + case "integer": + return "number"; + case "string": + return "string"; + case "boolean": + return "boolean"; + case "null": + return "null"; + case "blob": + return "Blob"; + case "typed-buffer": + return `TypedBuffer<${schema.items ? typeExpression(schema.items, depth, context) : "unknown"}>`; + } + + if (schema.type === "array" || schema.items !== undefined) { + return arrayExpression(schema, depth, context); + } + if (schema.type === undefined && schema.default !== undefined) { + return literal(schema.default, depth, readonly); + } + if (schema.type === "object" || schema.properties !== undefined) { + // A hoisted shape is referenced by its name; a one-off shape is inlined. + return context.names.get(canonical(schema)) ?? objectBody(schema, depth, context); + } + + switch (schema.type) { + case "observe": + return `Observe<${valueExpression(schema, depth, context)}>`; + case "promise": + return `Promise<${valueExpression(schema, depth, context)}>`; + case "generator": + return `AsyncGenerator<${valueExpression(schema, depth, context)}>`; + case "function": + return functionExpression(schema, depth, context); + } + + return "any"; +} + +function arrayExpression(schema: Schema, depth: number, context: Context): string { + const readonly = schema.mutable !== true ? "readonly " : ""; + if (schema.items === undefined) return `${readonly}any[]`; + + const element = typeExpression(schema.items, depth, context); + const length = tupleLength(schema); + if (length !== undefined) { + return `${readonly}[${Array.from({ length }, () => element).join(", ")}]`; + } + + const item = isCompound(schema.items) ? `(${element})` : element; + return `${readonly}${item}[]`; +} + +function objectBody(schema: Schema, depth: number, context: Context): string { + const readonly = schema.mutable !== true ? "readonly " : ""; + const pad = indent.repeat(depth + 1); + const required = new Set(schema.required ?? []); + const members: string[] = []; + + for (const [key, propSchema] of Object.entries(schema.properties ?? {})) { + const optional = required.has(key) ? "" : "?"; + // A `propertyMeta` description is the author's explicit field doc — it wins and is + // never suppressed; otherwise fall back to the property type's own description. + const description = schema.propertyMeta?.[key]?.description ?? propertyDescription(propSchema, context); + const comment = commentBlock(description, pad); + members.push( + `${comment}${pad}${readonly}${propertyKey(key)}${optional}: ${typeExpression(propSchema, depth + 1, context)};`, + ); + } + + const additional = schema.additionalProperties; + if (additional === true) { + members.push(`${pad}${readonly}[key: string]: any;`); + } else if (additional !== undefined && additional !== false) { + members.push(`${pad}${readonly}[key: string]: ${typeExpression(additional, depth + 1, context)};`); + } + + if (members.length === 0) return "{}"; + return `{\n${members.join("\n")}\n${indent.repeat(depth)}}`; +} + +// A property's comment, suppressed when it merely repeats the description already +// carried by the hoisted interface it references (so the type's own doc wins and the +// property comment survives only when it says something independent of the type). +function propertyDescription(propSchema: Schema, context: Context): string | undefined { + if (propSchema.description === undefined) return undefined; + if (!resolvesToObject(propSchema)) return propSchema.description; + const key = canonical(propSchema); + if (!context.names.has(key)) return propSchema.description; + return propSchema.description === context.firstSchema.get(key)?.description + ? undefined + : propSchema.description; +} + +// The wrapped value schema for observe/promise/generator; absent ⇒ any. +function valueExpression(schema: Schema, depth: number, context: Context): string { + return schema.value ? typeExpression(schema.value, depth, context) : "any"; +} + +function functionExpression(schema: Schema, depth: number, context: Context): string { + const { signature } = schema; + if (!signature) return "() => void"; + const parameters = signature.parameters ?? []; + const returns = signature.returns ? typeExpression(signature.returns, depth, context) : "void"; + + // With no argument docs, keep the compact single-line form; otherwise break each + // parameter onto its own line so its description can sit directly above it. + if (!parameters.some((parameter) => parameter.description)) { + const params = parameters + .map((parameter) => `${parameter.name}: ${typeExpression(parameter.schema, depth, context)}`) + .join(", "); + return `(${params}) => ${returns}`; + } + + const pad = indent.repeat(depth + 1); + const lines = parameters.map((parameter) => { + const comment = commentBlock(parameter.description, pad); + return `${comment}${pad}${parameter.name}: ${typeExpression(parameter.schema, depth + 1, context)},`; + }); + return `(\n${lines.join("\n")}\n${indent.repeat(depth)}) => ${returns}`; +} + +// --- Shared helpers ------------------------------------------------------------ + +// An object schema is only rendered as an interface/object when nothing with higher +// precedence in `ToType` (const/enum/oneOf/allOf/anyOf) overrides the object type. +function resolvesToObject(schema: Schema): boolean { + if (schema.const !== undefined || schema.enum || schema.oneOf || schema.allOf || schema.anyOf) { + return false; + } + return schema.type === "object" || schema.properties !== undefined; +} + +function tupleLength(schema: Schema): number | undefined { + return schema.minItems !== undefined && schema.minItems === schema.maxItems ? schema.minItems : undefined; +} + +// Structural identity of a shape, ignoring documentation metadata (`title`, +// `description`) so shapes that differ only in docs still deduplicate. +function canonical(schema: Schema): string { + return JSON.stringify(normalizeShape(schema)); +} + +function normalizeShape(value: unknown): unknown { + if (Array.isArray(value)) return value.map(normalizeShape); + if (value !== null && typeof value === "object") { + const normalized: Record = {}; + for (const [key, child] of Object.entries(value).sort(([a], [b]) => a.localeCompare(b))) { + // Documentation metadata never affects the structural shape identity, so shapes + // that differ only in docs still deduplicate. + if (key === "title" || key === "description" || key === "propertyMeta") continue; + normalized[key] = normalizeShape(child); + } + return normalized; + } + return value; +} + +// Renders a JSON value as a TypeScript literal type, matching the deep-readonly +// treatment `ToType` gives `const`/`enum`/`default` values. +function literal(value: unknown, depth: number, readonly: boolean): string { + if (value === null) return "null"; + switch (typeof value) { + case "string": + return JSON.stringify(value); + case "number": + case "boolean": + return String(value); + } + const modifier = readonly ? "readonly " : ""; + if (Array.isArray(value)) { + return `${modifier}[${value.map((entry) => literal(entry, depth, readonly)).join(", ")}]`; + } + if (typeof value === "object") { + const entries = Object.entries(value); + if (entries.length === 0) return "{}"; + const pad = indent.repeat(depth + 1); + const body = entries + .map(([key, entry]) => `${pad}${modifier}${propertyKey(key)}: ${literal(entry, depth + 1, readonly)};`) + .join("\n"); + return `{\n${body}\n${indent.repeat(depth)}}`; + } + return "unknown"; +} + +// A `description` rendered as `//` comment line(s) with a trailing newline, so it +// prefixes the declaration/property/function line it annotates. Empty when absent. +function commentBlock(description: string | undefined, pad: string): string { + if (!description) return ""; + return description.split("\n").map((line) => `${pad}// ${line}\n`).join(""); +} + +// A union/intersection/function element must be parenthesized before an array `[]` suffix. +function isCompound(schema: Schema): boolean { + return ( + (schema.enum !== undefined && schema.enum.length > 1) || + (schema.oneOf !== undefined && schema.oneOf.length > 1) || + (schema.anyOf !== undefined && schema.anyOf.length > 1) || + (schema.allOf !== undefined && schema.allOf.length > 1) || + schema.type === "function" + ); +} + +function union(parts: readonly string[]): string { + return parts.length === 0 ? "never" : parts.join(" | "); +} + +function intersection(parts: readonly string[]): string { + return parts.length === 0 ? "{}" : parts.join(" & "); +} + +const identifier = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +function propertyKey(key: string): string { + return identifier.test(key) ? key : JSON.stringify(key); +} + +function pascalCase(input: string): string { + const cased = input + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(""); + if (!cased) return ""; + return /^[A-Za-z_$]/.test(cased) ? cased : `_${cased}`; +} diff --git a/packages/data/src/service/async-data-service/create-lazy.test.ts b/packages/data/src/service/async-data-service/create-lazy.test.ts index 0a0877fc2..498488c77 100644 --- a/packages/data/src/service/async-data-service/create-lazy.test.ts +++ b/packages/data/src/service/async-data-service/create-lazy.test.ts @@ -128,7 +128,7 @@ const validAuth = createLazy({ showSignInDialog: { type: "function", signature: { parameters: [] } }, hideSignInDialog: { type: "function", signature: { parameters: [] } }, refreshToken: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, - signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + signIn: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], @@ -143,7 +143,7 @@ const validObserveFn = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } }, fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["allUsers", "selectUser", "fetchData"], @@ -182,7 +182,7 @@ const validWithArgs = createLazy({ type: "object", properties: { config: { type: "observe", value: {} }, - fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } + fetch: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["config", "fetch"], additionalProperties: false @@ -196,7 +196,7 @@ const validWithPreload = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } }, fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["allUsers", "selectUser", "fetchData"], @@ -220,7 +220,7 @@ const validNonConformant = createLazy({ schema: { type: "object", properties: { - fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + fetch: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, count: { type: "observe", value: {} } }, required: ["fetch", "count"], @@ -245,7 +245,7 @@ const errorMissing = createLazy({ showSignInDialog: { type: "function", signature: { parameters: [] } }, hideSignInDialog: { type: "function", signature: { parameters: [] } }, // Missing: refreshToken - signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + signIn: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "signIn", "signOut"], @@ -266,7 +266,7 @@ const errorWrongType1 = createLazy({ showSignInDialog: { type: "function", signature: { parameters: [] } }, hideSignInDialog: { type: "function", signature: { parameters: [] } }, refreshToken: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, - signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + signIn: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], @@ -287,7 +287,7 @@ const errorWrongType2 = createLazy({ showSignInDialog: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, // WRONG: should be void hideSignInDialog: { type: "function", signature: { parameters: [] } }, refreshToken: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, - signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + signIn: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], @@ -303,7 +303,7 @@ const errorExtra = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } }, fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, unknownProperty: { type: "observe", value: {} } // EXTRA: doesn't exist in service }, @@ -373,7 +373,7 @@ const errorMalformedReturns = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } }, // @ts-expect-error - `returns` has no recognized type-constructor fetchData: { type: "function", signature: { parameters: [], returns: { value: {} } } }, }, @@ -565,7 +565,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -608,7 +608,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -653,7 +653,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -698,7 +698,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -729,7 +729,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -778,7 +778,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { track: { type: "function", signature: { parameters: [{}] } } }, + properties: { track: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }] } } }, required: ["track"], additionalProperties: false } @@ -819,7 +819,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -861,7 +861,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -895,7 +895,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -937,7 +937,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -1126,7 +1126,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { streamData: { type: "function", signature: { parameters: [{}], returns: { type: "generator", value: {} } } } }, + properties: { streamData: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -1175,7 +1175,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { track: { type: "function", signature: { parameters: [{}] } } }, + properties: { track: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }] } } }, required: ["track"], additionalProperties: false } @@ -1226,7 +1226,7 @@ describe('createLazy preload option', () => { const trackSchema = { type: "object", - properties: { track: { type: "function", signature: { parameters: [{}] } } }, + properties: { track: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }] } } }, required: ["track"], additionalProperties: false } as const; diff --git a/packages/data/src/service/async-data-service/example.ts b/packages/data/src/service/async-data-service/example.ts index 9875c8b94..1c61ab5d9 100644 --- a/packages/data/src/service/async-data-service/example.ts +++ b/packages/data/src/service/async-data-service/example.ts @@ -53,9 +53,9 @@ namespace UserService { properties: { currentUser: { type: "observe", value: {} }, allUsers: { type: "observe", value: {} }, - selectUserById: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, - fetchUser: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, - updateUser: { type: "function", signature: { parameters: [{}, {}], returns: { type: "promise" } } }, + selectUserById: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "observe", value: {} } } }, + fetchUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, + updateUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }, { name: "arg1", schema: {} }], returns: { type: "promise" } } }, clearCache: { type: "function" }, }, required: ["currentUser", "allUsers", "selectUserById", "fetchUser", "updateUser", "clearCache"], @@ -106,7 +106,7 @@ namespace ConfigurableUserService { properties: { config: { type: "observe", value: {} }, currentUser: { type: "observe", value: {} }, - fetchUser: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + fetchUser: { type: "function", signature: { parameters: [{ name: "arg0", schema: {} }], returns: { type: "promise", value: {} } } }, }, required: ["config", "currentUser", "fetchUser"], additionalProperties: false, diff --git a/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts index 6a44e3143..30059531a 100644 --- a/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts +++ b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts @@ -34,7 +34,7 @@ const _accountSchema = { deposit: { type: "function", signature: { - parameters: [{ type: "number" }], + parameters: [{ name: "arg0", schema: { type: "number" } }], returns: { type: "promise", value: { type: "number" } }, }, description: "add funds; resolves to the new balance", @@ -74,7 +74,7 @@ const _assetSchema = { download: { type: "function", signature: { - parameters: [{ type: "string" }], + parameters: [{ name: "arg0", schema: { type: "string" } }], returns: { type: "promise", value: { type: "blob" } }, }, description: "download an asset by id",