From c87e707c6015ff22e92d01b9d22ee9f27b0e1605 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 7 Oct 2026 13:03:26 -0700 Subject: [PATCH 1/5] feat(schema,service): schema-level external policy, toTypeScript audience filter, AsyncDataService.toState Co-Authored-By: Claude Opus 5.5 --- .../resolve-external-invocation.test.ts | 20 ++- .../src/schema/resolve-external-invocation.ts | 6 +- packages/data/src/schema/schema.ts | 47 +++--- .../data/src/schema/to-typescript.test.ts | 55 +++++++ packages/data/src/schema/to-typescript.ts | 22 ++- .../src/service/async-data-service/README.md | 10 ++ .../src/service/async-data-service/public.ts | 2 + .../src/service/async-data-service/state.ts | 24 +++ .../async-data-service/to-state.test.ts | 150 ++++++++++++++++++ .../service/async-data-service/to-state.ts | 63 ++++++++ 10 files changed, 368 insertions(+), 31 deletions(-) create mode 100644 packages/data/src/service/async-data-service/state.ts create mode 100644 packages/data/src/service/async-data-service/to-state.test.ts create mode 100644 packages/data/src/service/async-data-service/to-state.ts diff --git a/packages/data/src/schema/resolve-external-invocation.test.ts b/packages/data/src/schema/resolve-external-invocation.test.ts index bd82c3ffb..a49f711ef 100644 --- a/packages/data/src/schema/resolve-external-invocation.test.ts +++ b/packages/data/src/schema/resolve-external-invocation.test.ts @@ -2,9 +2,7 @@ import { describe, it, expect } from "vitest"; import { resolveExternalInvocation } from "./resolve-external-invocation.js"; -import type { Schema } from "./schema.js"; - -type External = NonNullable["external"]; +import type { External, Schema } from "./schema.js"; const fn = (external?: External): Schema => ({ type: "function", @@ -33,4 +31,20 @@ describe("resolveExternalInvocation", () => { it("channels resolve independently", () => { expect(resolveExternalInvocation(fn({ link: true, agent: false }))).toEqual({ link: true, agent: false }); }); + + it("reads the schema-level policy on any member, not just functions", () => { + const hiddenState: Schema = { type: "observe", value: { type: "number" }, external: { agent: false } }; + expect(resolveExternalInvocation(hiddenState)).toEqual({ link: false, agent: false }); + const linkedGroup: Schema = { type: "object", properties: {}, external: { link: true } }; + expect(resolveExternalInvocation(linkedGroup)).toEqual({ link: true, agent: true }); + }); + + it("schema-level policy takes precedence over the deprecated signature.external", () => { + const schema: Schema = { + type: "function", + signature: { parameters: [], external: { agent: false } }, + external: { link: true }, + }; + expect(resolveExternalInvocation(schema)).toEqual({ link: true, agent: true }); + }); }); diff --git a/packages/data/src/schema/resolve-external-invocation.ts b/packages/data/src/schema/resolve-external-invocation.ts index 771aaf671..fd2b0444a 100644 --- a/packages/data/src/schema/resolve-external-invocation.ts +++ b/packages/data/src/schema/resolve-external-invocation.ts @@ -3,8 +3,8 @@ import type { Schema } from "./schema.js"; /** - * Resolves a `function` schema's untrusted-channel invocation policy - * (`schema.external`) into plain booleans, applying the two channels' opposite + * Resolves a schema's untrusted-channel exposure policy (`schema.external`, falling + * back to the deprecated `schema.signature.external`) into plain booleans, applying the two channels' opposite * default polarity in ONE place so call sites never re-derive it (getting the * `=== true` vs `!== false` polarity wrong on the link channel would be a * security hole): @@ -19,7 +19,7 @@ import type { Schema } from "./schema.js"; * (link denied, agent allowed). */ export function resolveExternalInvocation(schema: Schema): { readonly link: boolean; readonly agent: boolean } { - const external = schema.signature?.external; + const external = schema.external ?? schema.signature?.external; return { link: external?.link === true, agent: external?.agent !== false, diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index f82887476..7cafcd987 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -110,28 +110,18 @@ export interface Schema { signature?: { readonly parameters?: readonly (Parameter | Schema)[]; readonly returns?: Schema; - /** - * Invocation policy — who may call this function from an **untrusted - * channel**. Read at runtime by the executor that performs the invocation; - * pure metadata that does NOT affect the type produced by `Schema.ToType` - * (a function differing only in `external` derives the same signature), nor - * service-schema validation or lazy wrapping. - * - * The two channels have **deliberately opposite default polarity**, matching - * their trust level. Resolve them with `resolveExternalInvocation(schema)` - * (see `resolve-external-invocation.ts`) — the single source of truth — - * rather than re-deriving per call site, because getting the `link` default - * wrong is a security hole. - * - * - `link` — a deeplink / URL: the least-trusted channel (anyone can craft a - * URL and get a victim to open it in their authenticated session). - * **Default-deny whitelist**: link-invocable only when `link === true`; - * absent or `false` ⇒ not link-invocable. - * - `agent` — an agent acting on the user's behalf: more trusted. - * **Default-allow blacklist**: agent-invocable unless `agent === false`. - */ - readonly external?: { readonly agent?: boolean; readonly link?: boolean }; + /** @deprecated Use the schema-level `external` (on the function schema itself). Read only as a fallback. */ + readonly external?: External; }; + /** + * Exposure policy for **untrusted channels**, valid on any schema (an action, a + * state, a nested group). For a function it governs invocation; for any other + * member it governs visibility (e.g. `toTypeScript` omits members hidden from the + * agent). Pure metadata: does NOT affect `ToType` or service-schema validation. + * Resolve it with `resolveExternalInvocation(schema)` — the single source of + * truth — rather than re-deriving per call site. See `External`. + */ + external?: External; 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 @@ -160,6 +150,21 @@ export interface Schema { }; } +/** + * Per-channel exposure policy (`Schema.external`). The two channels have + * **deliberately opposite default polarity**, matching their trust level: + * + * - `link` — a deeplink / URL: the least-trusted channel (anyone can craft a URL + * and get a victim to open it in their authenticated session). + * **Default-deny whitelist**: exposed only when `link === true`. + * - `agent` — an agent acting on the user's behalf: more trusted. + * **Default-allow blacklist**: exposed unless `agent === false`. + */ +export interface External { + readonly agent?: boolean; + readonly link?: boolean; +} + /** * A named function parameter: the argument's `name` and `description` are known * independently of its type (`schema`). `description` documents the argument; diff --git a/packages/data/src/schema/to-typescript.test.ts b/packages/data/src/schema/to-typescript.test.ts index 73b962f72..f21712360 100644 --- a/packages/data/src/schema/to-typescript.test.ts +++ b/packages/data/src/schema/to-typescript.test.ts @@ -821,4 +821,59 @@ describe("Schema.toTypeScript", () => { ); }); }); + + describe("external audience", () => { + const point = { type: "object", properties: { x: { type: "number" } }, required: ["x"] } as const satisfies Schema; + const service = { + type: "object", + properties: { + count: { type: "observe", value: { type: "number" } }, + secret: { type: "observe", value: point, external: { agent: false } }, + reset: { type: "function", external: { agent: false } }, + legacy: { type: "function", signature: { external: { agent: false } } }, + share: { type: "function", external: { link: true } }, + group: { + type: "object", + properties: { + at: point, + hidden: { type: "observe", value: { type: "string" }, external: { agent: false } }, + }, + required: ["at", "hidden"], + }, + }, + required: ["count", "secret", "reset", "legacy", "share", "group"], + } as const satisfies Schema; + + it("omits agent-hidden members at any depth by default", () => { + // `point` appears once once `secret` is dropped, so it is no longer hoisted. + expect(Schema.toTypeScript(service, "Svc")).toBe( + [ + "interface Svc {", + " readonly count: Observe;", + " readonly share: () => void;", + " readonly group: {", + " readonly at: {", + " readonly x: number;", + " };", + " };", + "}", + ].join("\n"), + ); + }); + + it("link audience keeps only link-enabled members", () => { + expect(Schema.toTypeScript(service, "Svc", { audience: "link" })).toBe( + ["interface Svc {", " readonly share: () => void;", "}"].join("\n"), + ); + }); + + it("all audience emits every member", () => { + const out = Schema.toTypeScript(service, "Svc", { audience: "all" }); + for (const key of ["count", "secret", "reset", "legacy", "share", "group", "hidden"]) { + expect(out).toContain(`readonly ${key}`); + } + // `point` now appears twice, so it is hoisted. + expect(out.startsWith("interface At {")).toBe(true); + }); + }); }); diff --git a/packages/data/src/schema/to-typescript.ts b/packages/data/src/schema/to-typescript.ts index 852c8d5d5..048a0777a 100644 --- a/packages/data/src/schema/to-typescript.ts +++ b/packages/data/src/schema/to-typescript.ts @@ -2,6 +2,7 @@ import { Schema } from "./schema.js"; import { isNamed, schemaOf } from "./parameter.js"; +import { resolveExternalInvocation } from "./resolve-external-invocation.js"; const indent = " "; @@ -22,6 +23,11 @@ const indent = " "; * referencing property key or parameter name, 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). + * - **Omits members hidden from the `audience`** (default `"agent"`): an object property + * whose schema `external` policy denies that channel (see `resolveExternalInvocation`) + * is left out entirely, at any depth. So by default a member marked + * `external: { agent: false }` is never shown to an agent. `"link"` keeps only + * `link: true` members (default-deny); `"all"` emits every member. * * 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`, @@ -55,8 +61,12 @@ const indent = " "; * // } * ``` */ -export function toTypeScript(schema: Schema, name: string): string { - const context = createContext(); +export function toTypeScript( + schema: Schema, + name: string, + options: { readonly audience?: "agent" | "link" | "all" } = {}, +): string { + const context = createContext(options.audience ?? "agent"); walk(schema, undefined, 1, context); const hoisted = assignNames(schema, name, context); @@ -77,10 +87,13 @@ interface Context { readonly keyHint: Map; // Shape key → assigned interface name, for the shapes that are hoisted + shared. readonly names: Map; + // Whether an object property is shown to the target audience. + readonly visible: (schema: Schema) => boolean; } -function createContext(): Context { +function createContext(audience: "agent" | "link" | "all"): Context { return { + visible: audience === "all" ? () => true : (schema) => resolveExternalInvocation(schema)[audience], counts: new Map(), discovery: [], firstSchema: new Map(), @@ -134,7 +147,7 @@ function walk(schema: Schema, keyHint: string | undefined, multiplier: number, c 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); + if (context.visible(child)) walk(child, key, multiplier, context); } const additional = schema.additionalProperties; if (additional !== undefined && typeof additional === "object") { @@ -296,6 +309,7 @@ function objectBody(schema: Schema, depth: number, context: Context): string { const members: string[] = []; for (const [key, propSchema] of Object.entries(schema.properties ?? {})) { + if (!context.visible(propSchema)) continue; 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. diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index 6aa3d33dd..e7d25220f 100644 --- a/packages/data/src/service/async-data-service/README.md +++ b/packages/data/src/service/async-data-service/README.md @@ -99,3 +99,13 @@ See [create-lazy.md](./create-lazy.md) for complete documentation. - **create-lazy.md** - Complete documentation and examples - **public.ts** - Public API exports - **index.ts** - Namespace export + +### `AsyncDataService.toState(service)` + +Observes every `Observe` member a service's schema describes — recursing through nested organizational groups — in parallel, and emits one combined object with the service's nested shape, typed as `AsyncDataService.State` (observe members become their values; actions and action-only groups are omitted). Nothing is emitted until every member has a value; the member set is re-derived when `service.schema` changes. The service must expose its `schema`. + +```typescript +const unobserve = AsyncDataService.toState(service)((state) => { + state.foo.state.b; // the current value of service.foo.state.b +}); +``` diff --git a/packages/data/src/service/async-data-service/public.ts b/packages/data/src/service/async-data-service/public.ts index 18a0449c5..66027105e 100644 --- a/packages/data/src/service/async-data-service/public.ts +++ b/packages/data/src/service/async-data-service/public.ts @@ -5,3 +5,5 @@ export * from "./is-valid-with-partial-schema.js"; export * from "./is-valid-with-complete-schema.js"; export * from "./member-kind.js"; export * from "./create-lazy.js"; +export * from "./state.js"; +export * from "./to-state.js"; diff --git a/packages/data/src/service/async-data-service/state.ts b/packages/data/src/service/async-data-service/state.ts new file mode 100644 index 000000000..ab2b27304 --- /dev/null +++ b/packages/data/src/service/async-data-service/state.ts @@ -0,0 +1,24 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/index.js"; +import { Service } from "../service.js"; + +// A member's state value: an `Observe` becomes `T`, a nested organizational group +// becomes its own `State` (dropped when it holds no observe members), and anything +// else (an action) is `never`, which removes the key. +type StateValue

= + P extends Observe ? T + : P extends (...args: any[]) => any ? never + : P extends object ? (keyof State

extends never ? never : State

) + : never; + +/** + * The current-value snapshot of a service: the same nested shape as `T`, keeping only + * its `Observe` members (converted to `X`) and the groups that contain them. Actions + * and base-`Service` metadata are omitted. Produced at runtime by `toState`. + */ +export type State = { + readonly [K in keyof T as K extends keyof Service ? never + : [StateValue>] extends [never] ? never + : K]: StateValue>; +}; diff --git a/packages/data/src/service/async-data-service/to-state.test.ts b/packages/data/src/service/async-data-service/to-state.test.ts new file mode 100644 index 000000000..6bb4bfd6d --- /dev/null +++ b/packages/data/src/service/async-data-service/to-state.test.ts @@ -0,0 +1,150 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, expect, it } from "vitest"; +import { Observe } from "../../observe/index.js"; +import { Schema } from "../../schema/index.js"; +import { Service } from "../service.js"; +import { Assert } from "../../types/assert.js"; +import { EquivalentTypes } from "../../types/types.js"; +import { AsyncDataService } from "./async-data-service.js"; + +const schema = { + type: "object", + properties: { + count: { type: "observe", value: { type: "number" } }, + reset: { type: "function" }, + foo: { + type: "object", + properties: { + state: { + type: "object", + properties: { + a: { type: "observe", value: { type: "string" } }, + b: { type: "observe", value: { type: "boolean" } }, + }, + required: ["a", "b"], + }, + save: { type: "function", signature: { returns: { type: "promise", value: { type: "null" } } } }, + }, + required: ["state", "save"], + }, + actionsOnly: { + type: "object", + properties: { go: { type: "function" } }, + required: ["go"], + }, + "dotted.key": { type: "observe", value: { type: "number" } }, + }, + required: ["count", "reset", "foo", "actionsOnly", "dotted.key"], +} as const satisfies Schema; + +interface TestService extends Service { + readonly schema: Observe; + readonly count: Observe; + readonly reset: () => void; + readonly foo: { + readonly state: { readonly a: Observe; readonly b: Observe }; + readonly save: () => Promise; + }; + readonly actionsOnly: { readonly go: () => void }; + readonly "dotted.key": Observe; + readonly optional?: Observe; +} + +// Observe members become their values, actions and action-only groups are omitted, +// optional members stay optional. +type _CheckState = Assert, { + readonly count: number; + readonly foo: { readonly state: { readonly a: string; readonly b: boolean } }; + readonly "dotted.key": number; + readonly optional?: string; +}>>; + +function createTestService() { + const [count, setCount] = Observe.createState(1); + const [a, setA] = Observe.createState("x"); + const [b] = Observe.createState(true); + const [dotted] = Observe.createState(7); + const [schemaState, setSchema] = Observe.createState(schema); + const service: TestService = { + serviceName: "test", + schema: schemaState, + count, + reset: () => setCount(0), + foo: { state: { a, b }, save: async () => null }, + actionsOnly: { go: () => {} }, + "dotted.key": dotted, + }; + return { service, setCount, setA, setSchema }; +} + +describe("AsyncDataService.toState", () => { + it("combines every nested observe member into the service's shape", () => { + const { service } = createTestService(); + const values: unknown[] = []; + const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + expect(values).toEqual([{ count: 1, foo: { state: { a: "x", b: true } }, "dotted.key": 7 }]); + unobserve(); + }); + + it("re-emits when any member changes and stops after unobserve", () => { + const { service, setCount, setA } = createTestService(); + const values: AsyncDataService.State[] = []; + const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + setA("y"); + setCount(2); + expect(values.map((value) => [value.count, value.foo.state.a])).toEqual([[1, "x"], [1, "y"], [2, "y"]]); + unobserve(); + setCount(3); + expect(values).toHaveLength(3); + }); + + it("waits until every member has a value", () => { + const [late, setLate] = Observe.createState(); + const service = { + schema: Observe.fromConstant({ + type: "object", + properties: { now: { type: "observe" }, late: { type: "observe" } }, + }), + now: Observe.fromConstant(1), + late, + }; + const values: unknown[] = []; + AsyncDataService.toState(service)((value) => values.push(value)); + expect(values).toEqual([]); + setLate(2); + expect(values).toEqual([{ now: 1, late: 2 }]); + }); + + it("skips members the schema describes but the instance lacks", () => { + const service = { + schema: Observe.fromConstant({ + type: "object", + properties: { present: { type: "observe" }, missing: { type: "observe" } }, + }), + present: Observe.fromConstant("here"), + }; + const values: unknown[] = []; + AsyncDataService.toState(service)((value) => values.push(value)); + expect(values).toEqual([{ present: "here" }]); + }); + + it("emits an empty object for a service with no observe members", () => { + const service = { schema: Observe.fromConstant({ type: "object", properties: { go: { type: "function" } } }), go: () => {} }; + const values: unknown[] = []; + AsyncDataService.toState(service)((value) => values.push(value)); + expect(values).toEqual([{}]); + }); + + it("re-derives the member set when the schema changes", () => { + const { service, setSchema, setA } = createTestService(); + const values: unknown[] = []; + const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + setSchema({ type: "object", properties: { count: { type: "observe" } } }); + expect(values.at(-1)).toEqual({ count: 1 }); + // The previous member set's subscriptions were released. + setA("z"); + expect(values).toHaveLength(2); + unobserve(); + }); +}); diff --git a/packages/data/src/service/async-data-service/to-state.ts b/packages/data/src/service/async-data-service/to-state.ts new file mode 100644 index 000000000..c8df09352 --- /dev/null +++ b/packages/data/src/service/async-data-service/to-state.ts @@ -0,0 +1,63 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/index.js"; +import { Schema } from "../../schema/index.js"; +import { Service } from "../service.js"; +import { State } from "./state.js"; + +/** + * Observes every `Observe` member of a service — found by recursively walking its + * schema, through nested organizational groups — in parallel, and emits them combined + * into one object with the service's nested shape (`foo.state.b` is read as + * `state.foo.state.b`). Like `Observe.fromProperties`, nothing is emitted until every + * member has a value, then once per change. When the service's schema changes, the + * member set is re-derived. + * + * Every observe member the schema describes is included, regardless of its `external` + * policy; members the schema describes but the instance lacks (optional) are skipped. + */ +export function toState }>(service: T): Observe> { + return Observe.withUnwrap(Observe.withMap(service.schema, (schema) => observeState(service, schema))); +} + +function observeState(service: T, schema: Schema): Observe> { + const paths: (readonly string[])[] = []; + const members: Record> = {}; + collect(service, schema, [], paths, members); + return Observe.withMap(Observe.fromProperties(members), (values) => { + const state: Record = {}; + paths.forEach((path, index) => { + let target = state; + for (const key of path.slice(0, -1)) { + // Intermediate path entries are only ever the group objects created here. + target = (target[key] ??= {}) as Record; + } + target[path[path.length - 1]] = values[index]; + }); + // The state is built from exactly the observe members `T`'s schema describes. + return state as State; + }); +} + +// Members are keyed by index (not a joined "a.b" path) so a key containing "." cannot collide. +function collect( + target: unknown, + schema: Schema, + path: readonly string[], + paths: (readonly string[])[], + members: Record>, +): void { + for (const [key, member] of Object.entries(schema.properties ?? {})) { + if (key === "serviceName" || key === "schema") continue; + // A service member, or a group of them, is a property of a plain object. + const value = (target as Record | undefined)?.[key]; + if (value === undefined) continue; + if (member.type === "observe") { + // The schema describes this member as an observe. + members[paths.length] = value as Observe; + paths.push([...path, key]); + } else if (member.type === "object" || member.properties !== undefined) { + collect(value, member, [...path, key], paths, members); + } + } +} From 281b1c2165510991452ac2407292479d96e388b9 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 7 Oct 2026 13:08:17 -0700 Subject: [PATCH 2/5] fix(types): Exact passes any through so defaultFactory stores stay assignable to Store Relating Archetype.insert's generic Exact signature to an any-row archetype infers U = any, and Exact collapsed to never. A defaultFactory store was therefore not assignable to Store, so a single such transaction member (e.g. applyOperations) erased every transaction on its plugin and Store.Resources/Archetypes of that store evaluated to never. Co-Authored-By: Claude Opus 5.5 --- .../src/ecs/database/default-factory.test.ts | 31 +++++++++++++++++++ packages/data/src/types/types.ts | 6 +++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/data/src/ecs/database/default-factory.test.ts b/packages/data/src/ecs/database/default-factory.test.ts index d4019ce69..598d1c8ff 100644 --- a/packages/data/src/ecs/database/default-factory.test.ts +++ b/packages/data/src/ecs/database/default-factory.test.ts @@ -10,6 +10,10 @@ import { describe, it, expect } from "vitest"; import { Database } from "./database.js"; import type { Schema } from "../../schema/index.js"; +import type { Store } from "../store/index.js"; +import { applyOperations } from "./transactional-store/apply-operations.js"; +import type { Assert } from "../../types/assert.js"; +import type { Equal } from "../../types/equal.js"; // `guid` stands in for a cross-runtime identity: a value the archetype must have // at creation but that callers usually don't supply. `defaultFactory: "guid"` @@ -84,3 +88,30 @@ describe("insert-time default-factory (full plugin chain)", () => { t.archetypes.Node.insert({ guid: 2 }); }; } + +// A default-factory store must stay assignable to the any-row `Store` +// that generic helpers take. Otherwise a single such transaction member (e.g. the +// library's own `applyOperations`) is rejected by the plugin's transaction constraint +// and every transaction on that plugin silently vanishes from `db.transactions`. +{ + const anyStorePlugin = Database.Plugin.create({ + components: { guid, value }, + archetypes: { Node: ["guid", "value"] } as const, + transactions: { + applyOperations, + addNode(t, args: { value: number }) { + return t.archetypes.Node.insert(args); + }, + }, + }); + const _transactionsKept = (db: Database.FromPlugin) => { + db.transactions.addNode({ value: 1 }); + db.transactions.applyOperations([]); + }; + + type NodeStore = Database.Plugin.ToStore; + const _assignable = (t: NodeStore): Store => t; + // Store's inference helpers match a default-factory store instead of yielding `never`. + type _CheckArchetypes = Assert, { readonly Node: readonly ["guid", "value"] }>>; + type _CheckResourcesNotNever = Assert] extends [never] ? true : false, false>>; +} diff --git a/packages/data/src/types/types.ts b/packages/data/src/types/types.ts index 1ebc1b97a..c45095b80 100644 --- a/packages/data/src/types/types.ts +++ b/packages/data/src/types/types.ts @@ -171,7 +171,11 @@ export type NoUnion = [Key] extends [UnionToIntersection] * Extra properties will cause a type error. */ export type Exact = - U extends T + // `any` carries no keys to check, so it passes through rather than collapsing to + // `never`. Without this, relating a generic `(row: Exact)` signature to an + // any-row one (e.g. a `Store` parameter) infers `U = any` and fails. + 0 extends (1 & U) ? U + : U extends T ? Exclude extends never ? U : never From eb0232f824ecdbb1a90b9edbc952325eaf7c9ad8 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 7 Oct 2026 13:22:36 -0700 Subject: [PATCH 3/5] chore: bump version to 0.10.26 Co-Authored-By: Claude Opus 5.5 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-rpc/package.json | 2 +- packages/data-solid/package.json | 2 +- packages/data-sync/package.json | 2 +- packages/data-testing/package.json | 2 +- packages/data/package.json | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index f80a0e664..4d79c4fa6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.25", + "version": "0.10.26", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index c2c5ea3be..17fe2e154 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.25", + "version": "0.10.26", "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/package.json b/packages/data-ai/package.json index 154a4b709..d60c419fa 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.25", + "version": "0.10.26", "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-gpu/package.json b/packages/data-gpu/package.json index 4372d580f..469e933e4 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.25", + "version": "0.10.26", "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 f7f1fc99c..22f632402 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.25", + "version": "0.10.26", "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 65cd9d7cc..6078b1925 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.25", + "version": "0.10.26", "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 8ad1e6cf9..143a422e9 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.25", + "version": "0.10.26", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 07c7fba0d..8fd0c443c 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.25", + "version": "0.10.26", "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-solid/package.json b/packages/data-solid/package.json index 26c1eb922..6b3a88abd 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.25", + "version": "0.10.26", "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 02b643c5b..36b74a87c 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.25", + "version": "0.10.26", "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 43ab52c85..373c5b911 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.25", + "version": "0.10.26", "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 3ce90d9fa..65b01fa81 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.25", + "version": "0.10.26", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, From f54f3943f40956e94fbf4b7665a0c609e219c06f Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 7 Oct 2026 22:32:25 -0700 Subject: [PATCH 4/5] refactor(service): rename AsyncDataService.toState to toObserveState Co-Authored-By: Claude Opus 5.5 --- .../data/src/service/async-data-service/README.md | 4 ++-- .../data/src/service/async-data-service/public.ts | 2 +- .../data/src/service/async-data-service/state.ts | 2 +- .../{to-state.test.ts => to-observe-state.test.ts} | 14 +++++++------- .../{to-state.ts => to-observe-state.ts} | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) rename packages/data/src/service/async-data-service/{to-state.test.ts => to-observe-state.test.ts} (89%) rename packages/data/src/service/async-data-service/{to-state.ts => to-observe-state.ts} (95%) diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index e7d25220f..836250b18 100644 --- a/packages/data/src/service/async-data-service/README.md +++ b/packages/data/src/service/async-data-service/README.md @@ -100,12 +100,12 @@ See [create-lazy.md](./create-lazy.md) for complete documentation. - **public.ts** - Public API exports - **index.ts** - Namespace export -### `AsyncDataService.toState(service)` +### `AsyncDataService.toObserveState(service)` Observes every `Observe` member a service's schema describes — recursing through nested organizational groups — in parallel, and emits one combined object with the service's nested shape, typed as `AsyncDataService.State` (observe members become their values; actions and action-only groups are omitted). Nothing is emitted until every member has a value; the member set is re-derived when `service.schema` changes. The service must expose its `schema`. ```typescript -const unobserve = AsyncDataService.toState(service)((state) => { +const unobserve = AsyncDataService.toObserveState(service)((state) => { state.foo.state.b; // the current value of service.foo.state.b }); ``` diff --git a/packages/data/src/service/async-data-service/public.ts b/packages/data/src/service/async-data-service/public.ts index 66027105e..67c252dad 100644 --- a/packages/data/src/service/async-data-service/public.ts +++ b/packages/data/src/service/async-data-service/public.ts @@ -6,4 +6,4 @@ export * from "./is-valid-with-complete-schema.js"; export * from "./member-kind.js"; export * from "./create-lazy.js"; export * from "./state.js"; -export * from "./to-state.js"; +export * from "./to-observe-state.js"; diff --git a/packages/data/src/service/async-data-service/state.ts b/packages/data/src/service/async-data-service/state.ts index ab2b27304..ef7ea9b19 100644 --- a/packages/data/src/service/async-data-service/state.ts +++ b/packages/data/src/service/async-data-service/state.ts @@ -15,7 +15,7 @@ type StateValue

= /** * The current-value snapshot of a service: the same nested shape as `T`, keeping only * its `Observe` members (converted to `X`) and the groups that contain them. Actions - * and base-`Service` metadata are omitted. Produced at runtime by `toState`. + * and base-`Service` metadata are omitted. Produced at runtime by `toObserveState`. */ export type State = { readonly [K in keyof T as K extends keyof Service ? never diff --git a/packages/data/src/service/async-data-service/to-state.test.ts b/packages/data/src/service/async-data-service/to-observe-state.test.ts similarity index 89% rename from packages/data/src/service/async-data-service/to-state.test.ts rename to packages/data/src/service/async-data-service/to-observe-state.test.ts index 6bb4bfd6d..31d01ea5e 100644 --- a/packages/data/src/service/async-data-service/to-state.test.ts +++ b/packages/data/src/service/async-data-service/to-observe-state.test.ts @@ -78,11 +78,11 @@ function createTestService() { return { service, setCount, setA, setSchema }; } -describe("AsyncDataService.toState", () => { +describe("AsyncDataService.toObserveState", () => { it("combines every nested observe member into the service's shape", () => { const { service } = createTestService(); const values: unknown[] = []; - const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + const unobserve = AsyncDataService.toObserveState(service)((value) => values.push(value)); expect(values).toEqual([{ count: 1, foo: { state: { a: "x", b: true } }, "dotted.key": 7 }]); unobserve(); }); @@ -90,7 +90,7 @@ describe("AsyncDataService.toState", () => { it("re-emits when any member changes and stops after unobserve", () => { const { service, setCount, setA } = createTestService(); const values: AsyncDataService.State[] = []; - const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + const unobserve = AsyncDataService.toObserveState(service)((value) => values.push(value)); setA("y"); setCount(2); expect(values.map((value) => [value.count, value.foo.state.a])).toEqual([[1, "x"], [1, "y"], [2, "y"]]); @@ -110,7 +110,7 @@ describe("AsyncDataService.toState", () => { late, }; const values: unknown[] = []; - AsyncDataService.toState(service)((value) => values.push(value)); + AsyncDataService.toObserveState(service)((value) => values.push(value)); expect(values).toEqual([]); setLate(2); expect(values).toEqual([{ now: 1, late: 2 }]); @@ -125,21 +125,21 @@ describe("AsyncDataService.toState", () => { present: Observe.fromConstant("here"), }; const values: unknown[] = []; - AsyncDataService.toState(service)((value) => values.push(value)); + AsyncDataService.toObserveState(service)((value) => values.push(value)); expect(values).toEqual([{ present: "here" }]); }); it("emits an empty object for a service with no observe members", () => { const service = { schema: Observe.fromConstant({ type: "object", properties: { go: { type: "function" } } }), go: () => {} }; const values: unknown[] = []; - AsyncDataService.toState(service)((value) => values.push(value)); + AsyncDataService.toObserveState(service)((value) => values.push(value)); expect(values).toEqual([{}]); }); it("re-derives the member set when the schema changes", () => { const { service, setSchema, setA } = createTestService(); const values: unknown[] = []; - const unobserve = AsyncDataService.toState(service)((value) => values.push(value)); + const unobserve = AsyncDataService.toObserveState(service)((value) => values.push(value)); setSchema({ type: "object", properties: { count: { type: "observe" } } }); expect(values.at(-1)).toEqual({ count: 1 }); // The previous member set's subscriptions were released. diff --git a/packages/data/src/service/async-data-service/to-state.ts b/packages/data/src/service/async-data-service/to-observe-state.ts similarity index 95% rename from packages/data/src/service/async-data-service/to-state.ts rename to packages/data/src/service/async-data-service/to-observe-state.ts index c8df09352..4992b50f5 100644 --- a/packages/data/src/service/async-data-service/to-state.ts +++ b/packages/data/src/service/async-data-service/to-observe-state.ts @@ -16,7 +16,7 @@ import { State } from "./state.js"; * Every observe member the schema describes is included, regardless of its `external` * policy; members the schema describes but the instance lacks (optional) are skipped. */ -export function toState }>(service: T): Observe> { +export function toObserveState }>(service: T): Observe> { return Observe.withUnwrap(Observe.withMap(service.schema, (schema) => observeState(service, schema))); } From 8ef07a50b6ddab6a208bd003aef711b0e5e852f4 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 7 Oct 2026 22:43:01 -0700 Subject: [PATCH 5/5] refactor(schema): drop signature.external; external lives only on the schema Removes the deprecated signature.external fallback and inlines the policy shape on Schema.external instead of exporting a separate External type. Co-Authored-By: Claude Opus 5.5 --- .../resolve-external-invocation.test.ts | 16 ++++------ .../src/schema/resolve-external-invocation.ts | 9 +++--- packages/data/src/schema/schema.ts | 29 +++++++------------ packages/data/src/schema/to-type.ts | 6 ++-- .../data/src/schema/to-typescript.test.ts | 5 ++-- .../service/async-data-service/create-lazy.md | 2 +- 6 files changed, 25 insertions(+), 42 deletions(-) diff --git a/packages/data/src/schema/resolve-external-invocation.test.ts b/packages/data/src/schema/resolve-external-invocation.test.ts index a49f711ef..2d7a9f89a 100644 --- a/packages/data/src/schema/resolve-external-invocation.test.ts +++ b/packages/data/src/schema/resolve-external-invocation.test.ts @@ -2,11 +2,14 @@ import { describe, it, expect } from "vitest"; import { resolveExternalInvocation } from "./resolve-external-invocation.js"; -import type { External, Schema } from "./schema.js"; +import type { Schema } from "./schema.js"; + +type External = Schema["external"]; const fn = (external?: External): Schema => ({ type: "function", - signature: { parameters: [], ...(external ? { external } : {}) }, + signature: { parameters: [] }, + ...(external ? { external } : {}), }); describe("resolveExternalInvocation", () => { @@ -38,13 +41,4 @@ describe("resolveExternalInvocation", () => { const linkedGroup: Schema = { type: "object", properties: {}, external: { link: true } }; expect(resolveExternalInvocation(linkedGroup)).toEqual({ link: true, agent: true }); }); - - it("schema-level policy takes precedence over the deprecated signature.external", () => { - const schema: Schema = { - type: "function", - signature: { parameters: [], external: { agent: false } }, - external: { link: true }, - }; - expect(resolveExternalInvocation(schema)).toEqual({ link: true, agent: true }); - }); }); diff --git a/packages/data/src/schema/resolve-external-invocation.ts b/packages/data/src/schema/resolve-external-invocation.ts index fd2b0444a..82fc4a4e2 100644 --- a/packages/data/src/schema/resolve-external-invocation.ts +++ b/packages/data/src/schema/resolve-external-invocation.ts @@ -3,8 +3,8 @@ import type { Schema } from "./schema.js"; /** - * Resolves a schema's untrusted-channel exposure policy (`schema.external`, falling - * back to the deprecated `schema.signature.external`) into plain booleans, applying the two channels' opposite + * Resolves a schema's untrusted-channel exposure policy (`schema.external`) into + * plain booleans, applying the two channels' opposite * default polarity in ONE place so call sites never re-derive it (getting the * `=== true` vs `!== false` polarity wrong on the link channel would be a * security hole): @@ -19,9 +19,8 @@ import type { Schema } from "./schema.js"; * (link denied, agent allowed). */ export function resolveExternalInvocation(schema: Schema): { readonly link: boolean; readonly agent: boolean } { - const external = schema.external ?? schema.signature?.external; return { - link: external?.link === true, - agent: external?.agent !== false, + link: schema.external?.link === true, + agent: schema.external?.agent !== false, }; } diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index 7cafcd987..5e3bb1db3 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -110,8 +110,6 @@ export interface Schema { signature?: { readonly parameters?: readonly (Parameter | Schema)[]; readonly returns?: Schema; - /** @deprecated Use the schema-level `external` (on the function schema itself). Read only as a fallback. */ - readonly external?: External; }; /** * Exposure policy for **untrusted channels**, valid on any schema (an action, a @@ -119,9 +117,17 @@ export interface Schema { * member it governs visibility (e.g. `toTypeScript` omits members hidden from the * agent). Pure metadata: does NOT affect `ToType` or service-schema validation. * Resolve it with `resolveExternalInvocation(schema)` — the single source of - * truth — rather than re-deriving per call site. See `External`. + * truth — rather than re-deriving per call site. + * + * The two channels have **deliberately opposite default polarity**, matching + * their trust level: + * - `link` — a deeplink / URL: the least-trusted channel (anyone can craft a URL + * and get a victim to open it in their authenticated session). + * **Default-deny whitelist**: exposed only when `link === true`. + * - `agent` — an agent acting on the user's behalf: more trusted. + * **Default-allow blacklist**: exposed unless `agent === false`. */ - external?: External; + 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 @@ -150,21 +156,6 @@ export interface Schema { }; } -/** - * Per-channel exposure policy (`Schema.external`). The two channels have - * **deliberately opposite default polarity**, matching their trust level: - * - * - `link` — a deeplink / URL: the least-trusted channel (anyone can craft a URL - * and get a victim to open it in their authenticated session). - * **Default-deny whitelist**: exposed only when `link === true`. - * - `agent` — an agent acting on the user's behalf: more trusted. - * **Default-allow blacklist**: exposed unless `agent === false`. - */ -export interface External { - readonly agent?: boolean; - readonly link?: boolean; -} - /** * A named function parameter: the argument's `name` and `description` are known * independently of its type (`schema`). `description` documents the argument; diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts index a4159545e..320a286d0 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -373,9 +373,9 @@ type CheckFunctionNoParams = True vo // `external` invocation-policy metadata never affects the derived function type. type TestFunctionExternalIgnored = ToType<{ type: 'function', signature: { - parameters: [{ name: 'a', schema: { type: 'number' } }], returns: { type: 'promise', value: { type: 'number' } }, - external: { link: true, agent: false } - } + parameters: [{ name: 'a', schema: { type: 'number' } }], returns: { type: 'promise', value: { type: 'number' } } + }, + external: { link: true, agent: false } }>; // (a: number) => Promise type CheckFunctionExternalIgnored = True Promise>>; diff --git a/packages/data/src/schema/to-typescript.test.ts b/packages/data/src/schema/to-typescript.test.ts index f21712360..a737702e7 100644 --- a/packages/data/src/schema/to-typescript.test.ts +++ b/packages/data/src/schema/to-typescript.test.ts @@ -830,7 +830,6 @@ describe("Schema.toTypeScript", () => { count: { type: "observe", value: { type: "number" } }, secret: { type: "observe", value: point, external: { agent: false } }, reset: { type: "function", external: { agent: false } }, - legacy: { type: "function", signature: { external: { agent: false } } }, share: { type: "function", external: { link: true } }, group: { type: "object", @@ -841,7 +840,7 @@ describe("Schema.toTypeScript", () => { required: ["at", "hidden"], }, }, - required: ["count", "secret", "reset", "legacy", "share", "group"], + required: ["count", "secret", "reset", "share", "group"], } as const satisfies Schema; it("omits agent-hidden members at any depth by default", () => { @@ -869,7 +868,7 @@ describe("Schema.toTypeScript", () => { it("all audience emits every member", () => { const out = Schema.toTypeScript(service, "Svc", { audience: "all" }); - for (const key of ["count", "secret", "reset", "legacy", "share", "group", "hidden"]) { + for (const key of ["count", "secret", "reset", "share", "group", "hidden"]) { expect(out).toContain(`readonly ${key}`); } // `point` now appears twice, so it is hoisted. diff --git a/packages/data/src/service/async-data-service/create-lazy.md b/packages/data/src/service/async-data-service/create-lazy.md index bb5700733..065c65af7 100644 --- a/packages/data/src/service/async-data-service/create-lazy.md +++ b/packages/data/src/service/async-data-service/create-lazy.md @@ -45,7 +45,7 @@ AsyncDataService.createLazy({ - `{ type: "function", signature: { returns: { type: "promise", … } } }` → a function returning `Promise` - `{ type: "function" }` (no `signature`) → a function returning `void` -The function constructor groups its `parameters`/`returns` (and invocation-policy `external`) under a nested `signature`, so those members live only on function schemas. Use `value: {}` as a "don't-care" (resolves to `any`) when a member's precise value type doesn't matter for wrapping; fill in real value schemas when the schema is also a published contract. Function `signature.parameters` list only the **required** parameters. Publish the schema beside the service with the namespace pattern and validate it with `IsValidWithCompleteSchema`. +The function constructor groups its `parameters`/`returns` under a nested `signature`, so those members live only on function schemas. The invocation policy `external` sits on the member schema itself (valid on any member). Use `value: {}` as a "don't-care" (resolves to `any`) when a member's precise value type doesn't matter for wrapping; fill in real value schemas when the schema is also a published contract. Function `signature.parameters` list only the **required** parameters. Publish the schema beside the service with the namespace pattern and validate it with `IsValidWithCompleteSchema`. ## Type Safety Guarantees