diff --git a/package.json b/package.json index b2610eec..e4b0a670 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.6", + "version": "0.10.7", "private": true, "engines": { "node": ">=24" @@ -17,7 +17,7 @@ "dev-gpu": "pnpm --parallel --filter @adobe/data --filter @adobe/data-gpu --filter data-gpu-samples run dev", "link": "pnpm -r --filter @adobe/data* run link", "publish": "sh -c 'for x in \"$@\"; do OTP=\"$x\"; done; export NPM_CONFIG_OTP=\"$OTP\"; pnpm -r run build && pnpm -r publish --no-git-checks --access public' sh", - "bump": "pnpm version patch --no-git-tag-version && V=$(node -p \"require('$PWD/package.json').version\") && pnpm -r exec pnpm version $V --no-git-tag-version --allow-same-version && node -e \"const fs=require('fs');const p='packages/data-ai/.claude-plugin/plugin.json';const j=JSON.parse(fs.readFileSync(p,'utf8'));j.version=process.argv[1];fs.writeFileSync(p,JSON.stringify(j,null,2)+'\\n')\" $V", + "bump": "node scripts/bump.mjs", "release": "pnpm bump && pnpm publish", "bp": "pnpm bump && pnpm run publish" }, diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 8a894735..ec7ea367 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.6", + "version": "0.10.7", "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 e1faaafe..47e5d1cc 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.6", + "version": "0.10.7", "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 d3785e65..f7aec11c 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.6", + "version": "0.10.7", "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 12b36aad..b60ec0e4 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.6", + "version": "0.10.7", "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 7f9eb900..0292cad5 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.6", + "version": "0.10.7", "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 360327f4..413e3122 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.6", + "version": "0.10.7", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index fd1ce9e5..4af8c86c 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.6", + "version": "0.10.7", "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 c6ff5437..1ce510d7 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.6", + "version": "0.10.7", "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 bd98044e..4e71176c 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.6", + "version": "0.10.7", "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 9e2f5cbd..20db7c62 100644 --- a/packages/data/package.json +++ b/packages/data/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data", - "version": "0.10.6", + "version": "0.10.7", "description": "Adobe data oriented programming library", "type": "module", "sideEffects": false, diff --git a/packages/data/src/schema/public.ts b/packages/data/src/schema/public.ts index 7a80bf6a..3edfe078 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 "./resolve-external-invocation.js"; export * from "./from-object-properties.js"; export * from "./from-archetype.js"; export * from "./from-struct-properties.js"; diff --git a/packages/data/src/schema/resolve-external-invocation.test.ts b/packages/data/src/schema/resolve-external-invocation.test.ts new file mode 100644 index 00000000..bd82c3ff --- /dev/null +++ b/packages/data/src/schema/resolve-external-invocation.test.ts @@ -0,0 +1,36 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, it, expect } from "vitest"; +import { resolveExternalInvocation } from "./resolve-external-invocation.js"; +import type { Schema } from "./schema.js"; + +type External = NonNullable["external"]; + +const fn = (external?: External): Schema => ({ + type: "function", + signature: { parameters: [], ...(external ? { external } : {}) }, +}); + +describe("resolveExternalInvocation", () => { + it("defaults: link denied, agent allowed (no external)", () => { + expect(resolveExternalInvocation(fn())).toEqual({ link: false, agent: true }); + }); + + it("link is a default-deny whitelist — only link:true permits", () => { + expect(resolveExternalInvocation(fn({ link: true })).link).toBe(true); + expect(resolveExternalInvocation(fn({ link: false })).link).toBe(false); + // absent link (even with agent set) ⇒ denied + expect(resolveExternalInvocation(fn({ agent: true })).link).toBe(false); + }); + + it("agent is a default-allow blacklist — only agent:false denies", () => { + expect(resolveExternalInvocation(fn({ agent: false })).agent).toBe(false); + expect(resolveExternalInvocation(fn({ agent: true })).agent).toBe(true); + // absent agent (even with link set) ⇒ allowed + expect(resolveExternalInvocation(fn({ link: true })).agent).toBe(true); + }); + + it("channels resolve independently", () => { + expect(resolveExternalInvocation(fn({ link: true, agent: false }))).toEqual({ link: true, agent: false }); + }); +}); diff --git a/packages/data/src/schema/resolve-external-invocation.ts b/packages/data/src/schema/resolve-external-invocation.ts new file mode 100644 index 00000000..771aaf67 --- /dev/null +++ b/packages/data/src/schema/resolve-external-invocation.ts @@ -0,0 +1,27 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +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 + * 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): + * + * - `link` — the least-trusted channel (a deeplink / URL anyone can craft and get + * a victim to open in their authenticated session). Default-DENY whitelist: + * invocable only when `external.link === true`. + * - `agent` — acting on the user's behalf, more trusted. Default-ALLOW blacklist: + * invocable unless `external.agent === false`. + * + * Safe to call on any schema; a schema without a policy resolves to the defaults + * (link denied, agent allowed). + */ +export function resolveExternalInvocation(schema: Schema): { readonly link: boolean; readonly agent: boolean } { + const 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 aa52d019..0c9240f9 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -18,7 +18,13 @@ export type Conditional = { value: JSONMergePatch; } -const schemaTypes = { number: true, integer: true, string: true, boolean: true, null: true, array: true, object: true, 'typed-buffer': true, blob: true } as const; +// Data types describe serializable/storable values. The type-constructor types +// (observe/promise/generator/function) describe data-adjacent *types* — reactive +// values, async values, streams, and callables — so a Schema can describe a +// service surface, not just data. Like `blob`/`typed-buffer`, these are confined +// by usage: they belong in service/interface schemas, never in ECS component, +// resource, or typed-buffer schemas (which handle-or-throw at runtime, as today). +const schemaTypes = { number: true, integer: true, string: true, boolean: true, null: true, array: true, object: true, 'typed-buffer': true, blob: true, observe: true, promise: true, generator: true, function: true } as const; export interface Schema { type?: keyof typeof schemaTypes; @@ -60,6 +66,41 @@ export interface Schema { minItems?: number; maxItems?: number; items?: Schema; + // The wrapped value type for the `observe`/`promise`/`generator` constructors: + // `{ type: "observe", value: S }` → `Observe>`, etc. Absent ⇒ any. + value?: Schema; + /** + * The signature of the `function` constructor, grouped so these members live + * only on function schemas rather than on every `Schema`: + * `{ type: "function", signature: { parameters, returns } }` → + * `(...args) => ToType`. Absent `parameters` ⇒ no args; absent + * `returns` ⇒ void; absent `signature` entirely ⇒ `() => void`. + */ + signature?: { + readonly parameters?: readonly 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 }; + }; properties?: { readonly [key: string]: Schema }; required?: readonly string[]; additionalProperties?: boolean | Schema; diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts index 3de138fc..66a3cbc6 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -1,7 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { TypedBuffer } from "../typed-buffer/typed-buffer.js"; -import { DeepReadonly, EquivalentTypes, True } from "../types/types.js"; +import { DeepReadonly, EquivalentTypes, False, True } from "../types/types.js"; +import type { Observe } from "../observe/index.js"; import { Schema } from "./schema.js"; export type ToType = @@ -26,6 +27,16 @@ type FromSchemaInternal = T extends { const: infer ? boolean : T extends { type: 'null' } ? null + : T extends { type: 'blob' } + ? Blob + : T extends { type: 'observe' } + ? Observe, Decrement>> + : T extends { type: 'promise' } + ? Promise, Decrement>> + : T extends { type: 'generator' } + ? AsyncGenerator, Decrement>> + : T extends { type: 'function' } + ? FromSchemaFunction> : T extends { type: 'typed-buffer', items: infer Items } ? TypedBuffer> : T extends { type: 'typed-buffer' } @@ -45,6 +56,28 @@ type Decrement = ((...x: any[]) => void) extends ( ? R['length'] : never; +// The wrapped value schema for observe/promise/generator; absent ⇒ any. +type ValueSchema = T extends { value: infer V } ? V : {}; + +// The function-constructor mapping: `signature.parameters` → positional args, +// `signature.returns` → result. Absent `signature` ⇒ `() => void`. +type FromSchemaFunction = + T extends { signature: infer Sig } + ? (...args: FromSchemaArgs, Depth>) => FromSchemaReturns + : () => void; + +// The signature's parameters tuple; absent ⇒ no args. +type SignatureParams = + Sig extends { parameters: infer P } ? P extends readonly Schema[] ? P : readonly [] : readonly []; + +type FromSchemaArgs

= { + -readonly [K in keyof P]: ToType; +}; + +// Absent `returns` ⇒ void (a function that returns nothing meaningful). +type FromSchemaReturns = + Sig extends { returns: infer R } ? R extends Schema ? ToType : void : void; + type FromSchemaArray = T extends { items: infer Items; } @@ -264,3 +297,77 @@ type TestAllOfSingle = ToType<{ }>; // { name?: string } type CheckAllOfSingle = True>; +// ============================================================================ +// TYPE-CONSTRUCTOR SCHEMAS (data-adjacent types) +// ============================================================================ + +// observe +type TestObserve = ToType<{ type: 'observe', value: { type: 'number' } }>; // Observe +type CheckObserve = True>>; + +type TestObserveNoValue = ToType<{ type: 'observe' }>; // Observe +type CheckObserveNoValue = True>>; + +type TestObserveObject = ToType<{ + type: 'observe', + value: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false } +}>; // Observe<{ readonly x: number }> +type CheckObserveObject = True>>; + +// promise +type TestPromise = ToType<{ type: 'promise', value: { type: 'string' } }>; // Promise +type CheckPromise = True>>; + +// generator +type TestGenerator = ToType<{ type: 'generator', value: { type: 'boolean' } }>; // AsyncGenerator +type CheckGenerator = True>>; + +// function +type TestFunction = ToType<{ + type: 'function', signature: { parameters: [{ type: 'number' }, { type: 'string' }], returns: { type: 'boolean' } } +}>; // (a: number, b: string) => boolean +type CheckFunction = True boolean>>; + +type TestFunctionVoid = ToType<{ type: 'function', signature: { parameters: [] } }>; // () => void +type CheckFunctionVoid = True void>>; + +type TestFunctionNoParams = ToType<{ type: 'function' }>; // () => void (no signature) +type CheckFunctionNoParams = True void>>; + +// `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' } }, + external: { link: true, agent: false } + } +}>; // (a: number) => Promise +type CheckFunctionExternalIgnored = True Promise>>; + +// Driver case: an AsyncGenerator parameter nested inside an object argument, +// with a Promise return — the shape plain-Data parameters could not express. +type TestStreamingAction = ToType<{ + type: 'function', + signature: { + parameters: [{ + type: 'object', + properties: { chunks: { type: 'generator', value: { type: 'number' } } }, + required: ['chunks'], + additionalProperties: false + }], + returns: { type: 'promise', value: { type: 'null' } } + } +}>; // (arg: { readonly chunks: AsyncGenerator }) => Promise +type CheckStreamingAction = True }) => Promise +>>; + +// Negatives — inner types, constructor kinds, and function signatures are honored. +type CheckObserveNotString = False>>; +type CheckObserveNotPromise = False>>; +type CheckPromiseNotObserve = False>>; +type CheckGeneratorNotPromise = False>>; +type CheckFunctionWrongArg = False boolean>>; +type CheckFunctionWrongReturn = False number>>; +type CheckFunctionWrongArity = False boolean>>; + diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index 3ace0e0f..6aa3d33d 100644 --- a/packages/data/src/service/async-data-service/README.md +++ b/packages/data/src/service/async-data-service/README.md @@ -39,18 +39,28 @@ interface MyService extends Service { type Check = Assert>; ``` -### `AsyncDataService.createLazy({ load, properties, preload? })` +### `AsyncDataService.createLazy({ load, schema, preload? })` -Creates a lazy-loading wrapper factory for a service. Returns a factory function that creates service instances. The real service is only loaded when first accessed (or, with `preload: true`, at browser idle). TypeScript automatically infers service and argument types. +Creates a lazy-loading wrapper factory for a service. Returns a factory function that creates service instances. The real service is only loaded when first accessed (or, with `preload: true`, at browser idle). The service's sideloaded `Schema` (published beside it — see [is-valid-with-complete-schema.ts](./is-valid-with-complete-schema.ts)) drives how each member is wrapped, and TypeScript enforces that the schema completely describes the loaded service. ```typescript +// The service publishes its schema on the side +namespace MyService { + export const schema = { + type: "object", + properties: { + data: { type: "observe", value: {} }, + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, + }, + required: ["data", "fetchData"], + additionalProperties: false, + } as const satisfies Schema; +} + // Define the factory const createLazyService = AsyncDataService.createLazy({ load: () => import('./my-service').then(m => m.createService()), - properties: { - data: 'observe', - fetchData: 'fn:promise' - } + schema: MyService.schema, }); // Create instances @@ -62,7 +72,7 @@ const service = createLazyService(); ```typescript const createLazyService = AsyncDataService.createLazy({ load: (config: Config) => import('./my-service').then(m => m.createService(config)), - properties: { data: 'observe', fetch: 'fn:promise' } + schema: MyService.schema, }); const service = createLazyService({ apiUrl: '...' }); @@ -71,6 +81,7 @@ const service = createLazyService({ apiUrl: '...' }); **Features:** - ✅ Full type inference (no generic type parameters needed) +- ✅ Compile-time check that the schema completely describes the loaded service - ✅ Lazy loading on first property access - ✅ Call queuing for functions (all calls execute in order after load) - ✅ Proper cleanup for Observe subscriptions @@ -81,19 +92,10 @@ See [create-lazy.md](./create-lazy.md) for complete documentation. ## Files - **is-valid.ts** - Type utility for validating AsyncDataService conformance +- **is-valid-with-partial-schema.ts** - Valid service whose members include everything a schema describes (subset) +- **is-valid-with-complete-schema.ts** - Valid service whose members are exactly what a schema describes - **create-lazy.ts** - Function signature for creating lazy service wrappers - **create-lazy.test.ts** - Type safety tests - **create-lazy.md** - Complete documentation and examples - **public.ts** - Public API exports - **index.ts** - Namespace export - -## Backwards Compatibility - -For backwards compatibility, `IsDataService` is still exported from `@adobe/data/service`: - -```typescript -import { IsDataService } from "@adobe/data/service"; - -// Equivalent to AsyncDataService.IsValid -type Check = Assert>; -``` 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 821e79bd..35f7091c 100644 --- a/packages/data/src/service/async-data-service/create-lazy.md +++ b/packages/data/src/service/async-data-service/create-lazy.md @@ -2,12 +2,13 @@ ## Overview -`AsyncDataService.createLazy` provides a type-safe way to create lazy-loading wrapper factories for AsyncDataServices. The real service is only loaded when the first property is accessed. +`AsyncDataService.createLazy` provides a type-safe way to create lazy-loading wrapper factories for AsyncDataServices. The real service is only loaded when the first property is accessed. Wrapping is driven by the service's **sideloaded schema** — a `Schema` published beside the service (e.g. `MyService.schema`) rather than attached to instances. ## Import ```typescript import { AsyncDataService } from "@adobe/data/service"; +import { Schema } from "@adobe/data/schema"; ``` ## API Surface @@ -15,12 +16,12 @@ import { AsyncDataService } from "@adobe/data/service"; ```typescript AsyncDataService.createLazy({ load: (...args: any[]) => Promise, - properties: { [key: string]: PropertyDescriptor }, + schema: Schema, // the service's object schema (e.g. MyService.schema) preload?: boolean }): (...args: Args) => Service ``` -Returns a **factory function** that creates lazy service instances. TypeScript automatically infers both the service type and argument types from the `load` function. +Returns a **factory function** that creates lazy service instances. TypeScript infers the service type and argument types from the `load` function, and enforces that `schema` completely and correctly describes the loaded service (see [Type Safety Guarantees](#type-safety-guarantees)). ### Preloading @@ -29,45 +30,31 @@ By default a lazy service loads on first property access. Set `preload: true` to ```typescript AsyncDataService.createLazy({ load: () => import('./analytics').then(m => m.create()), - properties: { send: 'fn:void', pageload: 'fn:void' }, + schema: AnalyticsService.schema, preload: true // warm at browser idle }); ``` -### Descriptor Format +### The schema -```typescript -type LazyServiceDescriptor = { - // Loader function - may accept optional constructor args - load: Args extends void - ? () => Promise - : (args: Args) => Promise; - - // Must describe every property (excluding base Service properties) - properties: { - [K in Exclude]: PropertyDescriptor; - }; -}; -``` +`schema` is an object `Schema` whose `properties` describe each service member using the schema type-constructors. `createLazy` derives the runtime wrapper strategy from each member's schema: -### Property Descriptors +- `{ type: "observe", value: S }` → an `Observe` property +- `{ type: "function", signature: { returns: { type: "observe", … } } }` → a function returning `Observe` +- `{ type: "function", signature: { returns: { type: "generator", … } } }` → a function returning `AsyncGenerator` +- `{ type: "function", signature: { returns: { type: "promise", … } } }` → a function returning `Promise` +- `{ type: "function" }` (no `signature`) → a function returning `void` -Each property must be described with a string that matches its type: - -- `'observe'` - For `Observe` properties -- `'fn:observe'` - For functions returning `Observe` -- `'fn:generator'` - For functions returning `AsyncGenerator` -- `'fn:promise'` - For functions returning `Promise` -- `'fn:void'` - For functions 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`. ## Type Safety Guarantees -TypeScript will enforce: +TypeScript enforces, via `IsValidWithCompleteSchema`: -1. ✅ **Completeness** - All service properties must be declared -2. ✅ **Type Matching** - Each descriptor must match the actual property type -3. ✅ **No Extra Properties** - Cannot add properties that don't exist in service -4. ✅ **Clear Errors** - Missing or wrong types produce clear compile errors +1. ✅ **Completeness** — every service member must be described by the schema +2. ✅ **Type Matching** — each member's schema must match the actual member type +3. ✅ **No Extra Members** — the schema cannot describe members that don't exist on the service +4. ✅ **Clear Errors** — a mismatch reports a `SchemaMismatch` on the `schema` argument ## Usage Examples @@ -75,6 +62,7 @@ TypeScript will enforce: ```typescript import { AsyncDataService } from "@adobe/data/service"; +import { Schema } from "@adobe/data/schema"; interface AuthService extends Service { isSignedIn: Observe; @@ -83,15 +71,24 @@ interface AuthService extends Service { signOut: () => void; } +namespace AuthService { + export const schema = { + type: "object", + properties: { + isSignedIn: { type: "observe", value: {} }, + accessToken: { type: "observe", value: {} }, + signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise" } } }, + signOut: { type: "function" }, + }, + required: ["isSignedIn", "accessToken", "signIn", "signOut"], + additionalProperties: false, + } as const satisfies Schema; +} + // Define the lazy factory const createLazyAuthService = AsyncDataService.createLazy({ load: () => import('./auth-service').then(m => m.createAuthService()), - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - signIn: 'fn:promise', - signOut: 'fn:void' - } + schema: AuthService.schema, }); // Create an instance @@ -101,25 +98,28 @@ const authService = createLazyAuthService(); ### Service With Constructor Args ```typescript -import { AsyncDataService } from "@adobe/data/service"; - interface ConfigService extends Service { config: Observe; fetch: (endpoint: string) => Promise; } -type ServiceConfig = { - apiUrl: string; - timeout?: number; -}; +namespace ConfigService { + export const schema = { + type: "object", + properties: { + config: { type: "observe", value: {} }, + fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + }, + required: ["config", "fetch"], + additionalProperties: false, + } as const satisfies Schema; +} + +type ServiceConfig = { apiUrl: string; timeout?: number }; -// Define the lazy factory const createLazyConfigService = AsyncDataService.createLazy({ load: (config: ServiceConfig) => import('./config-service').then(m => m.create(config)), - properties: { - config: 'observe', - fetch: 'fn:promise' - } + schema: ConfigService.schema, }); // Create instances with different configs @@ -127,103 +127,72 @@ const prodService = createLazyConfigService({ apiUrl: 'https://api.prod.com' }); const testService = createLazyConfigService({ apiUrl: 'https://api.test.com' }); ``` -### All Property Types +### All Member Kinds ```typescript -import { AsyncDataService } from "@adobe/data/service"; - interface ComplexService extends Service { - // Observe property - status: Observe; - - // Function returning Observe - selectById: (id: string) => Observe; - - // Function returning AsyncGenerator - streamEvents: () => AsyncGenerator; - - // Function returning Promise - fetchData: () => Promise; - - // Function returning void - clearCache: () => void; + status: Observe; // observe + selectById: (id: string) => Observe; // function → observe + streamEvents: () => AsyncGenerator; // function → generator + fetchData: () => Promise; // function → promise + clearCache: () => void; // function → void +} + +namespace ComplexService { + export const schema = { + type: "object", + properties: { + status: { type: "observe", value: {} }, + selectById: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + streamEvents: { type: "function", signature: { returns: { type: "generator", value: {} } } }, + fetchData: { type: "function", signature: { returns: { type: "promise", value: {} } } }, + clearCache: { type: "function" }, + }, + required: ["status", "selectById", "streamEvents", "fetchData", "clearCache"], + additionalProperties: false, + } as const satisfies Schema; } const createLazyComplexService = AsyncDataService.createLazy({ load: () => import('./complex').then(m => m.createService()), - properties: { - status: 'observe', - selectById: 'fn:observe', - streamEvents: 'fn:generator', - fetchData: 'fn:promise', - clearCache: 'fn:void' - } + schema: ComplexService.schema, }); - -const service = createLazyComplexService(); ``` ## Compile-Time Error Examples -### Missing Property - -```typescript -// ❌ Error: Property 'signOut' is missing -const error = AsyncDataService.createLazy({ - load: () => import('./auth').then(m => m.create()), - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - signIn: 'fn:promise' - // Missing: signOut - TypeScript will error - } -}); -``` - -### Wrong Descriptor Type +A schema that does not completely and correctly describe the service reports a `SchemaMismatch` on the `schema` argument: ```typescript -// ❌ Error: Type '"fn:observe"' is not assignable to type '"observe"' -const error = AsyncDataService.createLazy({ +// ❌ Missing member 'signOut' — schema is not a complete description +AsyncDataService.createLazy({ load: () => import('./auth').then(m => m.create()), - properties: { - isSignedIn: 'fn:observe', // Wrong: should be 'observe' - accessToken: 'observe', - signIn: 'fn:promise', - signOut: 'fn:void' - } + schema: { + type: "object", + properties: { isSignedIn: { type: "observe", value: {} }, signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise" } } } }, + required: ["isSignedIn", "signIn"], + additionalProperties: false, + } as const satisfies Schema, // ← error: schema omits `signOut` (and `accessToken`) }); -``` - -### Extra Property -```typescript -// ❌ Error: 'unknownProp' does not exist in type -const error = AsyncDataService.createLazy({ - load: () => import('./auth').then(m => m.create()), - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - signIn: 'fn:promise', - signOut: 'fn:void', - unknownProp: 'observe' // Extra: doesn't exist in service - } -}); +// ❌ Wrong kind — `isSignedIn` is an observe property, not a function +// ❌ Extra member — a property not on the service +// both likewise report a SchemaMismatch on the schema argument ``` ## Behavior (Queue Strategy) All calls are queued and executed in order once the service loads: -- **`'observe'`** - Subscription is deferred until service loads -- **`'fn:observe'`** - Calls are queued, each returns Observe that subscribes when loaded -- **`'fn:generator'`** - Calls are queued, each returns AsyncGenerator that yields when loaded -- **`'fn:promise'`** - Calls are queued, each returns Promise that resolves when loaded -- **`'fn:void'`** - Calls are queued, all execute in order when loaded +- **observe property** — subscription is deferred until the service loads +- **function → observe** — calls queued; each returns an `Observe` that subscribes when loaded +- **function → generator** — calls queued; each returns an `AsyncGenerator` that yields when loaded +- **function → promise** — calls queued; each returns a `Promise` that resolves when loaded +- **function → void** — calls queued; all execute in order when loaded ## Validation -Use `AsyncDataService.IsValid` to validate that a service conforms to the AsyncDataService pattern: +Use `AsyncDataService.IsValid` to validate that a service conforms to the AsyncDataService pattern, and `IsValidWithCompleteSchema` to validate that a sideloaded schema matches it exactly: ```typescript import { AsyncDataService, Assert } from "@adobe/data/service"; @@ -233,14 +202,14 @@ interface MyService extends Service { fetchData: () => Promise; } -// This will compile successfully if MyService is a valid async data service -type CheckValidDataService = Assert>; +type CheckValid = Assert>; +type CheckSchema = Assert>; ``` ## Testing See `create-lazy.test.ts` for comprehensive type safety tests including: -- Valid usage with all property types -- Error cases for missing/wrong/extra properties +- Valid usage with all member kinds +- Error cases for missing / wrong / extra members - Services with and without constructor args 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 9ace68a9..d677bc0f 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 @@ -116,38 +116,53 @@ type _CheckRecognitionService = Assert>; // VALID USAGE TESTS // ============================================================================ -// ✅ Test 1: Complete descriptor for SimpleAuthService +// ✅ Test 1: Complete schema for SimpleAuthService const validAuth = createLazy({ load: () => Promise.resolve({} as SimpleAuthService), - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - userProfile: 'observe', - showSignInDialog: 'fn:void', - hideSignInDialog: 'fn:void', - refreshToken: 'fn:promise', - signIn: 'fn:promise', - signOut: 'fn:promise' + schema: { + type: "object", + properties: { + isSignedIn: { type: "observe", value: {} }, + accessToken: { type: "observe", value: {} }, + userProfile: { type: "observe", value: {} }, + 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: {} } } }, + signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], + additionalProperties: false } }); // ✅ Test 2: Service with function returning Observe const validObserveFn = createLazy({ load: () => Promise.resolve({} as ServiceWithObserveFn), - properties: { - allUsers: 'observe', - selectUser: 'fn:observe', - fetchData: 'fn:promise' + schema: { + type: "object", + properties: { + allUsers: { type: "observe", value: {} }, + selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false } }); // ✅ Test 3: Service with AsyncGenerator const validGenerator = createLazy({ load: () => Promise.resolve({} as ServiceWithGenerator), - properties: { - status: 'observe', - streamEvents: 'fn:generator', - cancel: 'fn:void' + schema: { + type: "object", + properties: { + status: { type: "observe", value: {} }, + streamEvents: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } }, + cancel: { type: "function", signature: { parameters: [] } } + }, + required: ["status", "streamEvents", "cancel"], + additionalProperties: false } }); @@ -163,19 +178,29 @@ const validWithArgs = createLazy({ console.log(args.apiUrl); return Promise.resolve({} as ConfigurableService); }, - properties: { - config: 'observe', - fetch: 'fn:promise' + schema: { + type: "object", + properties: { + config: { type: "observe", value: {} }, + fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } + }, + required: ["config", "fetch"], + additionalProperties: false } }); // ✅ Test 4b: preload flag is accepted const validWithPreload = createLazy({ load: () => Promise.resolve({} as ServiceWithObserveFn), - properties: { - allUsers: 'observe', - selectUser: 'fn:observe', - fetchData: 'fn:promise' + schema: { + type: "object", + properties: { + allUsers: { type: "observe", value: {} }, + selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false }, preload: true }); @@ -187,82 +212,150 @@ const validWithPreload = createLazy({ // ❌ Test 5: Missing property 'refreshToken' const errorMissing = createLazy({ load: () => Promise.resolve({} as SimpleAuthService), - // @ts-expect-error - Missing property 'refreshToken' in descriptor - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - userProfile: 'observe', - showSignInDialog: 'fn:void', - hideSignInDialog: 'fn:void', - // Missing: refreshToken - signIn: 'fn:promise', - signOut: 'fn:promise' + // @ts-expect-error - Missing property 'refreshToken' in schema + schema: { + type: "object", + properties: { + isSignedIn: { type: "observe", value: {} }, + accessToken: { type: "observe", value: {} }, + userProfile: { type: "observe", value: {} }, + showSignInDialog: { type: "function", signature: { parameters: [] } }, + hideSignInDialog: { type: "function", signature: { parameters: [] } }, + // Missing: refreshToken + signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "signIn", "signOut"], + additionalProperties: false } }); -// ❌ Test 6: Wrong descriptor type (observe property marked as fn:observe) +// ❌ Test 6: Wrong schema type (observe property described as an observe function) const errorWrongType1 = createLazy({ load: () => Promise.resolve({} as SimpleAuthService), - properties: { - // @ts-expect-error - Wrong descriptor type - isSignedIn: 'fn:observe', // WRONG: should be 'observe' - accessToken: 'observe', - userProfile: 'observe', - showSignInDialog: 'fn:void', - hideSignInDialog: 'fn:void', - refreshToken: 'fn:promise', - signIn: 'fn:promise', - signOut: 'fn:promise' + // @ts-expect-error - Wrong schema type for isSignedIn (function instead of observe) + schema: { + type: "object", + properties: { + isSignedIn: { type: "function", signature: { parameters: [], returns: { type: "observe", value: {} } } }, // WRONG: should be observe + accessToken: { type: "observe", value: {} }, + userProfile: { type: "observe", value: {} }, + 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: {} } } }, + signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], + additionalProperties: false } }); -// ❌ Test 7: Wrong descriptor type (void function marked as fn:promise) +// ❌ Test 7: Wrong schema type (void function described as promise function) const errorWrongType2 = createLazy({ load: () => Promise.resolve({} as SimpleAuthService), - properties: { - isSignedIn: 'observe', - accessToken: 'observe', - userProfile: 'observe', - // @ts-expect-error - Wrong descriptor type - showSignInDialog: 'fn:promise', // WRONG: should be 'fn:void' - hideSignInDialog: 'fn:void', - refreshToken: 'fn:promise', - signIn: 'fn:promise', - signOut: 'fn:promise' + // @ts-expect-error - Wrong schema type for showSignInDialog (promise instead of void) + schema: { + type: "object", + properties: { + isSignedIn: { type: "observe", value: {} }, + accessToken: { type: "observe", value: {} }, + userProfile: { type: "observe", value: {} }, + 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: {} } } }, + signOut: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["isSignedIn", "accessToken", "userProfile", "showSignInDialog", "hideSignInDialog", "refreshToken", "signIn", "signOut"], + additionalProperties: false } }); // ❌ Test 8: Extra property that doesn't exist in service const errorExtra = createLazy({ load: () => Promise.resolve({} as ServiceWithObserveFn), - properties: { - allUsers: 'observe', - selectUser: 'fn:observe', - fetchData: 'fn:promise', - // @ts-expect-error - Extra property - unknownProperty: 'observe' // EXTRA: doesn't exist in service + // @ts-expect-error - Extra property 'unknownProperty' not in service + schema: { + type: "object", + properties: { + allUsers: { type: "observe", value: {} }, + selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, + unknownProperty: { type: "observe", value: {} } // EXTRA: doesn't exist in service + }, + required: ["allUsers", "selectUser", "fetchData", "unknownProperty"], + additionalProperties: false } }); -// ❌ Test 9: Wrong descriptor for fn:observe (marked as observe) +// ❌ Test 9: Wrong schema for observe function (described as observe property) const errorObserveFn = createLazy({ load: () => Promise.resolve({} as ServiceWithObserveFn), - properties: { - allUsers: 'observe', - // @ts-expect-error - Wrong descriptor type - selectUser: 'observe', // WRONG: should be 'fn:observe' - fetchData: 'fn:promise' + // @ts-expect-error - Wrong schema type for selectUser (observe instead of observe function) + schema: { + type: "object", + properties: { + allUsers: { type: "observe", value: {} }, + selectUser: { type: "observe", value: {} }, // WRONG: should be a function returning observe + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false } }); -// ❌ Test 10: Wrong descriptor for generator +// ❌ Test 10: Wrong schema for generator (described as promise function) const errorGenerator = createLazy({ load: () => Promise.resolve({} as ServiceWithGenerator), - properties: { - status: 'observe', - // @ts-expect-error - Wrong descriptor type - streamEvents: 'fn:promise', // WRONG: should be 'fn:generator' - cancel: 'fn:void' + // @ts-expect-error - Wrong schema type for streamEvents (promise instead of generator) + schema: { + type: "object", + properties: { + status: { type: "observe", value: {} }, + streamEvents: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, // WRONG: should be generator + cancel: { type: "function", signature: { parameters: [] } } + }, + required: ["status", "streamEvents", "cancel"], + additionalProperties: false + } +}); + +// ❌ Test 11: Nested organizational object member is not a supported wrapper kind. +// createLazy only wraps observe values and functions, so a member of type "object" +// is rejected at compile time (rather than throwing at runtime). +interface ServiceWithNestedGroup extends Service { + group: { readonly ready: Observe }; +} +type _CheckNestedGroupIsValid = Assert>; +const errorNestedObject = createLazy({ + load: () => Promise.resolve({} as ServiceWithNestedGroup), + schema: { + type: "object", + properties: { + // @ts-expect-error - nested-object members are not a supported createLazy wrapper kind + group: { type: "object", properties: { ready: { type: "observe", value: {} } }, required: ["ready"], additionalProperties: false }, + }, + required: ["group"], + additionalProperties: false + } +}); + +// ❌ Test 12: A function member whose `returns` carries no recognized type would +// resolve to `any` (vacuously passing the gate) but has no runtime wrapper — so it +// is rejected at compile time by the member-kind constraint. +const errorMalformedReturns = createLazy({ + load: () => Promise.resolve({} as ServiceWithObserveFn), + schema: { + type: "object", + properties: { + allUsers: { type: "observe", value: {} }, + selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, + // @ts-expect-error - `returns` has no recognized type-constructor + fetchData: { type: "function", signature: { parameters: [], returns: { value: {} } } }, + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false } }); @@ -271,6 +364,37 @@ const errorGenerator = createLazy({ // ============================================================================ describe('createLazy', () => { + test('lazy instance exposes its schema before load', async () => { + interface TestService extends Service { + value: Observe; + } + + let loaded = false; + const schema = { + type: "object", + properties: { value: { type: "observe", value: {} } }, + required: ["value"], + additionalProperties: false + } as const; + + const factory = createLazy({ + load: (): Promise => { + loaded = true; + return Promise.resolve({ serviceName: 'test-service', value: (n) => { n('x'); return () => {}; } }); + }, + schema + }); + + const service = factory(); + + assert({ + given: 'a lazy service is created', + should: 'expose the same schema without triggering a load', + actual: `${service.schema === schema},${loaded}`, + expected: 'true,false' + }); + }); + test('factory pattern', async () => { interface TestService extends Service { value: Observe; @@ -288,7 +412,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { value: 'observe' } + schema: { + type: "object", + properties: { value: { type: "observe", value: {} } }, + required: ["value"], + additionalProperties: false + } }); assert({ @@ -325,7 +454,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { value: 'observe' } + schema: { + type: "object", + properties: { value: { type: "observe", value: {} } }, + required: ["value"], + additionalProperties: false + } }); const service = factory(); @@ -365,7 +499,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { value: 'observe' } + schema: { + type: "object", + properties: { value: { type: "observe", value: {} } }, + required: ["value"], + additionalProperties: false + } }); const service = factory(); @@ -397,7 +536,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -435,7 +579,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -475,7 +624,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -515,7 +669,12 @@ describe('createLazy', () => { serviceName: 'test-service', fetchData: async (id: string) => `result-${id}` }), - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -541,7 +700,12 @@ describe('createLazy', () => { serviceName: 'test-service', fetchData: async (id: string) => `result-${id}` }), - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -585,7 +749,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { track: 'fn:void' } + schema: { + type: "object", + properties: { track: { type: "function", signature: { parameters: [{}] } } }, + required: ["track"], + additionalProperties: false + } }); const service = factory(); @@ -621,7 +790,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -658,7 +832,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -687,7 +866,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -724,7 +908,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -769,7 +958,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -787,6 +981,75 @@ describe('createLazy', () => { }); }); + test('generator return() before next() latches done (no resurrection)', async () => { + interface TestService extends Service { + streamData: () => AsyncGenerator; + } + + let started = false; + const factory = createLazy({ + load: () => Promise.resolve({ + serviceName: 'test-service', + streamData: async function* () { + started = true; + yield 1; + yield 2; + } + }), + schema: { + type: "object", + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, + required: ["streamData"], + additionalProperties: false + } + }); + + const gen = factory().streamData(); + const returned = await gen.return(undefined); + const afterReturn = await gen.next(); + + assert({ + given: 'return() is called before the first next()', + should: 'report done and never start the real generator', + actual: `${returned.done},${afterReturn.done},${started}`, + expected: 'true,true,false' + }); + }); + + test('generator supports async dispose', async () => { + interface TestService extends Service { + streamData: () => AsyncGenerator; + } + + const factory = createLazy({ + load: () => Promise.resolve({ + serviceName: 'test-service', + streamData: async function* () { + yield 1; + yield 2; + } + }), + schema: { + type: "object", + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, + required: ["streamData"], + additionalProperties: false + } + }); + + const gen = factory().streamData(); + const hasDispose = typeof (gen as unknown as { [Symbol.asyncDispose]?: () => Promise })[Symbol.asyncDispose]; + await (gen as unknown as { [Symbol.asyncDispose](): Promise })[Symbol.asyncDispose](); + const afterDispose = await gen.next(); + + assert({ + given: 'a lazy generator is async-disposed', + should: 'expose Symbol.asyncDispose and be done afterward', + actual: `${hasDispose},${afterDispose.done}`, + expected: 'function,true' + }); + }); + test('generator function returns same instance', async () => { interface TestService extends Service { streamData: () => AsyncGenerator; @@ -800,7 +1063,12 @@ describe('createLazy', () => { yield 2; } }), - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -829,7 +1097,12 @@ describe('createLazy', () => { yield `${prefix}-2`; } }), - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", signature: { parameters: [{}], returns: { type: "generator", value: {} } } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -873,7 +1146,12 @@ describe('createLazy', () => { serviceName: 'test-service', track: (event: string) => {} }), - properties: { track: 'fn:void' } + schema: { + type: "object", + properties: { track: { type: "function", signature: { parameters: [{}] } } }, + required: ["track"], + additionalProperties: false + } }); const service = factory(); @@ -919,9 +1197,16 @@ describe('createLazy preload option', () => { } }; + const trackSchema = { + type: "object", + properties: { track: { type: "function", signature: { parameters: [{}] } } }, + required: ["track"], + additionalProperties: false + } as const; + test('does not load when preload is not set', async () => { const loadCount = { value: 0 }; - const factory = createLazy({ load: makeLoader(loadCount), properties: { track: 'fn:void' } }); + const factory = createLazy({ load: makeLoader(loadCount), schema: trackSchema }); factory(); await new Promise(resolve => setTimeout(resolve, 10)); @@ -937,7 +1222,7 @@ describe('createLazy preload option', () => { test('preload: true warms the service before any property touch', async () => { await withIdleCallback(async () => { const loadCount = { value: 0 }; - const factory = createLazy({ load: makeLoader(loadCount), properties: { track: 'fn:void' }, preload: true }); + const factory = createLazy({ load: makeLoader(loadCount), schema: trackSchema, preload: true }); factory(); await new Promise(resolve => setTimeout(resolve, 10)); @@ -954,7 +1239,7 @@ describe('createLazy preload option', () => { test('preload: true dedupes with the first real property access', async () => { await withIdleCallback(async () => { const loadCount = { value: 0 }; - const factory = createLazy({ load: makeLoader(loadCount), properties: { track: 'fn:void' }, preload: true }); + const factory = createLazy({ load: makeLoader(loadCount), schema: trackSchema, preload: true }); const service = factory(); service.track('event-1'); diff --git a/packages/data/src/service/async-data-service/create-lazy.ts b/packages/data/src/service/async-data-service/create-lazy.ts index 24cba04c..d029b413 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -1,42 +1,74 @@ // © 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"; - -// ============================================================================ -// PROPERTY DESCRIPTORS -// ============================================================================ - -type PropertyDescriptor

= - P extends Observe - ? 'observe' - : P extends (...args: any[]) => Observe - ? 'fn:observe' - : P extends (...args: any[]) => AsyncGenerator - ? 'fn:generator' - : P extends (...args: any[]) => Promise - ? 'fn:promise' - : P extends (...args: any[]) => void - ? 'fn:void' - : never; +import { IsValidWithCompleteSchema } from "./is-valid-with-complete-schema.js"; // ============================================================================ // TYPE INFERENCE HELPERS // ============================================================================ // Extract Service type from load function -type InferService = - F extends (...args: any[]) => Promise - ? S extends Service ? S : never - : never; +type InferService = + F extends (...args: any[]) => Promise + ? S extends Service ? S : never + : never; // Extract Args type from load function type InferArgs = F extends () => Promise - ? void - : F extends (args: infer A) => Promise - ? A - : never; + ? void + : F extends (args: infer A) => Promise + ? A + : never; + +// createLazy only wraps observe values and functions (classified by what they +// return). Constrain each member schema to those shapes so an unsupported member +// — a nested organizational object, a data property, or a function whose +// `returns` carries no recognized type — is a compile error at the call site +// rather than a runtime throw. The generic complete-schema gate would otherwise +// accept such members (e.g. a `returns` that resolves to `any`), leaving the +// runtime `memberKind` dispatch to disagree with the type-level check. +type LazyMemberSchema = + | { readonly type: "observe" } + | { readonly type: "function"; readonly signature?: { readonly returns?: { readonly type: "observe" | "promise" | "generator" } } }; + +type LazyServiceSchema = Schema & { + readonly properties?: { readonly [name: string]: LazyMemberSchema }; +}; + +// ============================================================================ +// RUNTIME WRAPPER KIND +// ============================================================================ + +type WrapKind = "observe" | "fn:observe" | "fn:promise" | "fn:generator" | "fn:void"; + +// The runtime wrapper strategy for a service member, derived from its schema: +// an `observe` value, or a `function` classified by what it returns. Well-typed +// callers can never reach a throw (the LazyServiceSchema constraint rejects +// unsupported members at compile time); the throws defend untyped/`any` callers. +function memberKind(member: Schema): WrapKind { + if (member.type === "observe") return "observe"; + if (member.type === "function") { + const returns = member.signature?.returns; + if (returns === undefined) return "fn:void"; // absent returns ⇒ void + switch (returns.type) { + case "observe": return "fn:observe"; + case "promise": return "fn:promise"; + case "generator": return "fn:generator"; + default: + // A present `returns` with an unrecognized type must not silently become + // void (that would drop the result); fail loudly instead. + throw new Error( + `createLazy: unsupported function returns schema type "${returns.type}" — must be observe, promise, generator, or omitted (void)`, + ); + } + } + throw new Error( + `createLazy: unsupported member schema type "${member.type}" — service members must be observe or function schemas`, + ); +} // ============================================================================ // MAIN FUNCTION SIGNATURE @@ -47,59 +79,63 @@ type InferArgs = * By default the real service is only loaded when the first property is accessed. * All calls are queued and executed in order once the service loads. * - * @param params - `load` returns the real service (may accept args); `properties` describes how to - * wrap each service property; `preload` (default false) warms the service at browser idle instead - * of waiting for the first property access. + * @param params - `load` returns the real service (may accept args); `schema` is the + * service's sideloaded object schema (e.g. `typeof MyService.schema`), which drives + * how each member is wrapped; `preload` (default false) warms the service at browser + * idle instead of waiting for the first property access. * @returns A factory function that creates lazy service instances * - * TypeScript will enforce: - * - All service properties must be declared in properties object - * - Each descriptor must match the actual property type - * - Clear errors indicate what is missing or wrong + * TypeScript enforces that `schema` describes every member of the loaded service + * with the correct wrapper kind; otherwise the `schema` argument fails to type-check + * with a `__createLazyError` marker. Note: member value/parameter schemas authored as `{}` + * resolve to `any`, so presence and wrapper kind are checked but inner payload + * types are only verified where a precise `value`/parameter schema is supplied. * * @example * ```typescript * // Service with no args * const createLazySimple = createLazy({ * load: () => import('./simple').then(m => m.create()), - * properties: { data: 'observe', fetch: 'fn:promise' } + * schema: SimpleService.schema, * }); * const service = createLazySimple(); * * // Service with args, warmed at browser idle * const createLazyConfig = createLazy({ * load: (config: Config) => import('./service').then(m => m.create(config)), - * properties: { data: 'observe', fetch: 'fn:promise' }, - * preload: true + * schema: ConfigService.schema, + * preload: true, * }); * const service = createLazyConfig({ apiUrl: '...' }); * ``` */ export function createLazy< - LoadFn extends (...args: any[]) => Promise + LoadFn extends (...args: any[]) => Promise, + const S extends LazyServiceSchema >( params: { load: LoadFn, - properties: { - [K in Exclude, keyof Service>]: - PropertyDescriptor[K]> - }, + schema: S, preload?: boolean - } + } & (IsValidWithCompleteSchema, S> extends true + ? unknown + // Inline (not a named type) so the mismatch marker isn't a documented symbol. + : { schema: { readonly __createLazyError: "createLazy: schema must completely and correctly describe the loaded service" } }) ): InferArgs extends void ? () => InferService : (args: InferArgs) => InferService { - const { load, properties, preload } = params; + const { load, schema, preload } = params; + const properties = schema.properties ?? {}; // Return factory function that creates lazy service instances return ((...factoryArgs: any[]) => { type ServiceType = InferService; - + // Shared loading state for this instance let loadPromise: Promise | null = null; let loadedService: ServiceType | null = null; - + const ensureLoading = (): Promise => { if (loadedService) { return Promise.resolve(loadedService); @@ -107,12 +143,12 @@ export function createLazy< if (loadPromise) { return loadPromise; } - + loadPromise = (load as any)(...factoryArgs).then((service: ServiceType) => { loadedService = service; return service; }); - + return loadPromise!; }; @@ -123,21 +159,24 @@ export function createLazy< if (typeof idle === 'function') idle(() => { void ensureLoading(); }); } - // Build lazy service object + // Build lazy service object. Expose the schema up front (before load) so the + // lazy instance is introspectable without triggering a load. const lazyService: any = { serviceName: 'lazy-service', + schema, }; - - // Wrap each property based on its descriptor - for (const [key, descriptor] of Object.entries(properties)) { - if (descriptor === 'observe') { + + // Wrap each member based on the strategy derived from its schema + for (const [key, member] of Object.entries(properties)) { + const kind = memberKind(member); + if (kind === 'observe') { // Observe property - defer subscription until service loads lazyService[key] = (notify: any) => { let unobserveReal: (() => void) | null = null; let isCancelled = false; - + ensureLoading().then((service: any) => { - if (!isCancelled && service.serviceName !== 'lazy-service') { + if (!isCancelled && service.serviceName !== undefined && service.serviceName !== 'lazy-service') { // Update lazy service name once real service loads lazyService.serviceName = `lazy-${service.serviceName}`; } @@ -145,13 +184,13 @@ export function createLazy< unobserveReal = service[key](notify); } }); - + return () => { isCancelled = true; unobserveReal?.(); }; }; - } else if (descriptor === 'fn:promise') { + } else if (kind === 'fn:promise') { // Promise function - queue calls and execute after load type QueuedCall = { args: any[]; @@ -189,7 +228,7 @@ export function createLazy< isProcessing = true; ensureLoading() .then((service: any) => { - if (service.serviceName !== 'lazy-service') { + if (service.serviceName !== undefined && service.serviceName !== 'lazy-service') { lazyService.serviceName = `lazy-${service.serviceName}`; } runDrain(service); @@ -203,7 +242,7 @@ export function createLazy< } }); }; - } else if (descriptor === 'fn:void') { + } else if (kind === 'fn:void') { // Void function - queue calls and execute after load const queue: any[][] = []; let isProcessing = false; @@ -226,22 +265,22 @@ export function createLazy< if (!isProcessing) { isProcessing = true; ensureLoading().then((service: any) => { - if (service.serviceName !== 'lazy-service') { + if (service.serviceName !== undefined && service.serviceName !== 'lazy-service') { lazyService.serviceName = `lazy-${service.serviceName}`; } runDrain(service); }); } }; - } else if (descriptor === 'fn:observe') { + } else if (kind === 'fn:observe') { // Observe function - returns Observe that waits for service lazyService[key] = (...args: any[]): Observe => { return (notify: any) => { let unobserveReal: (() => void) | null = null; let isCancelled = false; - + ensureLoading().then((service: any) => { - if (service.serviceName !== 'lazy-service') { + if (service.serviceName !== undefined && service.serviceName !== 'lazy-service') { lazyService.serviceName = `lazy-${service.serviceName}`; } if (!isCancelled) { @@ -249,52 +288,68 @@ export function createLazy< unobserveReal = realObserve(notify); } }); - + return () => { isCancelled = true; unobserveReal?.(); }; }; }; - } else if (descriptor === 'fn:generator') { + } else if (kind === 'fn:generator') { // AsyncGenerator function - returns generator that waits for service lazyService[key] = (...args: any[]): AsyncGenerator => { let realGenerator: AsyncGenerator | null = null; - - return { + let done = false; + + const gen = { async next(): Promise> { + // Once terminated, never resurrect and start the real generator. + if (done) return { done: true, value: undefined }; if (!realGenerator) { const service = await ensureLoading(); - if (service.serviceName !== 'lazy-service') { + if (service.serviceName !== undefined && service.serviceName !== 'lazy-service') { lazyService.serviceName = `lazy-${service.serviceName}`; } realGenerator = (service as any)[key](...args); } - return realGenerator!.next(); + const result = await realGenerator!.next(); + if (result.done) done = true; + return result; }, - + + // Latch `done` so a subsequent next() cannot start the real generator; + // delegate to it only when iteration has already begun. async return(value?: any): Promise> { + done = true; if (realGenerator) { return realGenerator.return(value); } return { done: true, value: value as any }; }, - + async throw(e: any): Promise> { + done = true; if (realGenerator) { return realGenerator.throw(e); } throw e; }, - + [Symbol.asyncIterator]() { return this; - } + }, + + // Explicit resource management: `await using` disposes by terminating. + async [Symbol.asyncDispose](): Promise { + await gen.return(undefined); + }, } as AsyncGenerator; + + return gen; }; } } - + return lazyService as ServiceType; }) as any; } diff --git a/packages/data/src/service/async-data-service/example.ts b/packages/data/src/service/async-data-service/example.ts index b10d9a26..9875c8b9 100644 --- a/packages/data/src/service/async-data-service/example.ts +++ b/packages/data/src/service/async-data-service/example.ts @@ -2,17 +2,20 @@ /** * Example usage of AsyncDataService utilities - * + * * This file demonstrates: * 1. Validating a service with AsyncDataService.IsValid - * 2. Creating a lazy wrapper with AsyncDataService.createLazy - * + * 2. Publishing a service's schema on the side via the namespace pattern + * (`MyService.schema`) and validating it with IsValidWithCompleteSchema + * 3. Creating a lazy wrapper with AsyncDataService.createLazy, driven by that schema + * * NOTE: This is a documentation example. The imported services don't exist. */ /* eslint-disable @typescript-eslint/no-unused-vars */ import { Observe } from "../../observe/index.js"; +import { Schema } from "../../schema/index.js"; import { Assert } from "../../types/assert.js"; import { Service } from "../service.js"; import { AsyncDataService } from "./async-data-service.js"; @@ -37,6 +40,29 @@ interface UserService extends Service { clearCache: () => void; } +// ============================================================================ +// SIDELOADED SCHEMA (published beside the service via the namespace pattern) +// ============================================================================ + +namespace UserService { + // `value: {}` is a "don't-care" schema (resolves to `any`) — enough to drive + // lazy wrapping; fill in precise value schemas when the schema is also a + // published contract. Function `parameters` list only the REQUIRED params. + export const schema = { + type: "object", + 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" } } }, + clearCache: { type: "function" }, + }, + required: ["currentUser", "allUsers", "selectUserById", "fetchUser", "updateUser", "clearCache"], + additionalProperties: false, + } as const satisfies Schema; +} + // ============================================================================ // VALIDATION // ============================================================================ @@ -44,13 +70,16 @@ interface UserService extends Service { // Compile-time validation that UserService conforms to AsyncDataService pattern type _ValidateUserService = Assert>; +// Compile-time validation that the sideloaded schema matches the service exactly +type _ValidateUserSchema = Assert>; + // ============================================================================ // LAZY WRAPPER // ============================================================================ /** - * Create a lazy-loading wrapper for UserService - * The real service is only loaded when first accessed + * Create a lazy-loading wrapper for UserService. + * The real service is only loaded when first accessed; the schema drives wrapping. */ export const createLazyUserService = AsyncDataService.createLazy({ load: async (): Promise => { @@ -58,14 +87,7 @@ export const createLazyUserService = AsyncDataService.createLazy({ // e.g., return import('./user-service-impl.js').then(m => m.createUserService()) throw new Error('Example only - service implementation not provided'); }, - properties: { - currentUser: 'observe', - allUsers: 'observe', - selectUserById: 'fn:observe', - fetchUser: 'fn:promise', - updateUser: 'fn:promise', - clearCache: 'fn:void' - } + schema: UserService.schema, }); // ============================================================================ @@ -78,12 +100,28 @@ interface ConfigurableUserService extends Service { fetchUser: (id: string) => Promise<{ readonly id: string; readonly name: string }>; } +namespace ConfigurableUserService { + export const schema = { + type: "object", + properties: { + config: { type: "observe", value: {} }, + currentUser: { type: "observe", value: {} }, + fetchUser: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, + }, + required: ["config", "currentUser", "fetchUser"], + additionalProperties: false, + } as const satisfies Schema; +} + type UserServiceConfig = { apiUrl: string; timeout?: number; }; type _ValidateConfigurableUserService = Assert>; +type _ValidateConfigurableUserSchema = Assert< + AsyncDataService.IsValidWithCompleteSchema +>; /** * Create a lazy-loading wrapper with constructor arguments @@ -95,9 +133,5 @@ export const createLazyConfigurableUserService = AsyncDataService.createLazy({ console.log('Service config:', args); throw new Error('Example only - service implementation not provided'); }, - properties: { - config: "observe", - currentUser: "observe", - fetchUser: "fn:promise" - } + schema: ConfigurableUserService.schema, }); 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 new file mode 100644 index 00000000..6a44e314 --- /dev/null +++ b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts @@ -0,0 +1,96 @@ +// © 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 { Assert } from "../../types/assert.js"; +import { EquivalentTypes, False, True } from "../../types/types.js"; +import { IsValid } from "./is-valid.js"; + +/** + * A valid async data service whose members are EXACTLY what the object schema `S` + * describes: no described member missing, and no undescribed member present. + * + * `S` is sideloaded — supplied as a second type argument from `typeof MyService.schema` + * (a `... as const satisfies Schema`), never read off the service instance. For an + * exact match, `S` needs `required: [...all members]` and `additionalProperties: false`. + */ +export type IsValidWithCompleteSchema = + IsValid extends true + ? EquivalentTypes, Omit> + : false; + +// ---- Tests ----------------------------------------------------------------- + +interface _AccountService extends Service { + balance: Observe; + deposit: (amount: number) => Promise; +} + +const _accountSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "number" }, description: "current balance" }, + deposit: { + type: "function", + signature: { + parameters: [{ type: "number" }], + returns: { type: "promise", value: { type: "number" } }, + }, + description: "add funds; resolves to the new balance", + }, + }, + required: ["balance", "deposit"], + additionalProperties: false, +} as const satisfies Schema; + +const _incompleteSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "number" }, description: "current balance" }, + }, + required: ["balance"], + additionalProperties: false, +} as const satisfies Schema; + +// Positive: the schema matches the service exactly. +type _CheckComplete = Assert>; + +// Negative: an incomplete schema is not a complete description (missing `deposit`). +// @ts-expect-error — schema omits the `deposit` member +type _CheckIncompleteFails = Assert>; + +// ---- Blob: describable as both an observed property and a return value ----- + +interface _AssetService extends Service { + thumbnail: Observe; + download: (id: string) => Promise; +} + +const _assetSchema = { + type: "object", + properties: { + thumbnail: { type: "observe", value: { type: "blob" }, description: "current thumbnail" }, + download: { + type: "function", + signature: { + parameters: [{ type: "string" }], + returns: { type: "promise", value: { type: "blob" } }, + }, + description: "download an asset by id", + }, + }, + required: ["thumbnail", "download"], + additionalProperties: false, +} as const satisfies Schema; + +type _CheckBlobComplete = Assert>; + +type _AssetShape = Schema.ToType; + +// Positive: the blob schema resolves to `Blob` in both positions. +type _CheckBlobState = True>>; +type _CheckBlobReturn = True Promise>>; + +// Regression guard: only holds if `{ type: "blob" }` resolves to `Blob`, not `any`. +type _CheckBlobIsNotString = False>>; diff --git a/packages/data/src/service/async-data-service/is-valid-with-partial-schema.ts b/packages/data/src/service/async-data-service/is-valid-with-partial-schema.ts new file mode 100644 index 00000000..def8c5c0 --- /dev/null +++ b/packages/data/src/service/async-data-service/is-valid-with-partial-schema.ts @@ -0,0 +1,67 @@ +// © 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 { Assert } from "../../types/assert.js"; +import { IsValid } from "./is-valid.js"; + +/** + * A valid async data service whose members INCLUDE everything the object schema + * `S` describes: a partial (subset) schema. Every described member must exist on + * the service with a compatible type, but the service MAY expose additional, + * undescribed members. + * + * `S` is sideloaded — supplied as a second type argument from `typeof MyService.schema` + * (a `... as const satisfies Schema`), never read off the service instance. + */ +export type IsValidWithPartialSchema = + IsValid extends true + ? [Omit] extends [Schema.ToType] ? true : false + : false; + +// ---- Tests ----------------------------------------------------------------- + +interface _AccountService extends Service { + balance: Observe; + deposit: (amount: number) => Promise; +} + +const _partialSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "number" }, description: "current balance" }, + }, + required: ["balance"], + additionalProperties: false, +} as const satisfies Schema; + +const _wrongTypeSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "string" }, description: "should be a number" }, + }, + required: ["balance"], + additionalProperties: false, +} as const satisfies Schema; + +const _extraMemberSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "number" }, description: "current balance" }, + nonexistent: { type: "observe", value: { type: "number" }, description: "not on the service" }, + }, + required: ["balance", "nonexistent"], + additionalProperties: false, +} as const satisfies Schema; + +// Positive: a partial schema is a valid subset (service may have more members). +type _CheckPartialMatches = Assert>; + +// Negative: a described member with the wrong type fails. +// @ts-expect-error — balance is a number on the service, but string in the schema +type _CheckWrongTypeFails = Assert>; + +// Negative: describing a member the service lacks fails. +// @ts-expect-error — `nonexistent` is not a member of _AccountService +type _CheckExtraDescribedFails = Assert>; diff --git a/packages/data/src/service/async-data-service/is-valid.ts b/packages/data/src/service/async-data-service/is-valid.ts index ab76e552..f1704324 100644 --- a/packages/data/src/service/async-data-service/is-valid.ts +++ b/packages/data/src/service/async-data-service/is-valid.ts @@ -89,7 +89,10 @@ type AllArgsAreData = : false; // Helper: Check if a single property is valid -// Allows: Observe, valid functions, and readonly objects whose properties are all valid (for organization) +// Allows: Observe, valid functions, and readonly objects whose properties are all valid (for organization). +// Nested objects exclude base-Service metadata keys (`serviceName`/`schema`) — those are metadata, not data +// members (the top-level check excludes them too), and it keeps validation from recursing into the deeply +// self-referential `Schema` type carried by the optional `schema` slot. type IsValidProperty

= P extends Observe ? T extends Data ? true : false @@ -98,9 +101,9 @@ type IsValidProperty

= ? ValidReturnType : false : P extends object - ? keyof P extends never + ? Exclude extends never ? false - : { [K in keyof P]: IsValidProperty } extends Record + : { [K in Exclude]: IsValidProperty } extends Record, true> ? true : false : false; diff --git a/packages/data/src/service/async-data-service/public.ts b/packages/data/src/service/async-data-service/public.ts index 05f53ba6..4bf7dbbb 100644 --- a/packages/data/src/service/async-data-service/public.ts +++ b/packages/data/src/service/async-data-service/public.ts @@ -1,4 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./is-valid.js"; +export * from "./is-valid-with-partial-schema.js"; +export * from "./is-valid-with-complete-schema.js"; export * from "./create-lazy.js"; diff --git a/packages/data/src/service/service.ts b/packages/data/src/service/service.ts index 0e03f90c..0ef35e5d 100644 --- a/packages/data/src/service/service.ts +++ b/packages/data/src/service/service.ts @@ -1,5 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../schema/index.js"; + /** * A service is an object that provides functionality to an application. * Services are never dependent upon user interface components. @@ -10,7 +12,17 @@ * - Backend Services * - Usually consumed by other services. * - May also contain Promise or AsyncGenerator functions. + * + * A service's shape can be described by a `Schema` (an object schema whose + * property schemas describe each member). The schema is authored beside the + * service (e.g. `MyService.schema`) and validated with `IsValidWithCompleteSchema`; + * a factory may also attach it to the instance via the optional `schema` slot for + * runtime introspection. Both `serviceName` and `schema` are base-`Service` + * metadata, so they are excluded from `AsyncDataService.IsValid` and reserved as + * member names. See `async-data-service/is-valid-with-*-schema.ts`. */ export interface Service { readonly serviceName?: string; + /** Optional runtime copy of the service's schema (its authored contract). */ + readonly schema?: Schema; } diff --git a/packages/data/src/service/ui-service/is-valid.ts b/packages/data/src/service/ui-service/is-valid.ts index 93e8f226..07f37435 100644 --- a/packages/data/src/service/ui-service/is-valid.ts +++ b/packages/data/src/service/ui-service/is-valid.ts @@ -88,9 +88,12 @@ type IsValidProperty

= : P extends (...args: any[]) => infer R ? ValidReturnType : P extends object - ? keyof P extends never + // Exclude base-Service metadata keys (`serviceName`/`schema`) from nested + // objects — they are metadata, not data members, and it keeps validation from + // recursing into the deeply self-referential `Schema` on the `schema` slot. + ? Exclude extends never ? false - : { [K in keyof P]: IsValidProperty } extends Record + : { [K in Exclude]: IsValidProperty } extends Record, true> ? true : false : false; diff --git a/packages/data/src/types/types.ts b/packages/data/src/types/types.ts index cfe4314b..1ebc1b97 100644 --- a/packages/data/src/types/types.ts +++ b/packages/data/src/types/types.ts @@ -103,7 +103,7 @@ export type IsUnknown = unknown extends T : false; -export type DeepReadonly = T extends Function | Branded | Element | Blob +export type DeepReadonly = T extends Function | Branded | Element | Blob | Promise | AsyncGenerator ? T : T extends number | string | boolean | symbol | bigint ? T diff --git a/scripts/bump.mjs b/scripts/bump.mjs new file mode 100644 index 00000000..55b1ca67 --- /dev/null +++ b/scripts/bump.mjs @@ -0,0 +1,65 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// +// Bumps the monorepo patch version. The root package.json is the version anchor; +// every PUBLISHABLE package (private !== true) is set to the same version, so the +// published surface stays in lockstep — `@adobe/data@x.y.z` and +// `@adobe/data-gpu@x.y.z` are always the same release. +// +// PRIVATE packages (samples / demo apps, private: true) are intentionally NOT +// bumped: an unpublished package's version is meaningless and only adds diff +// noise. Publishability is keyed off the same `private` field that governs +// `pnpm -r publish`, so there is no package list to maintain here. +// +// Only the `"version"` line of each file is rewritten, so diffs stay one line +// per package instead of a full reformat. + +import { readFileSync, writeFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +const root = process.cwd(); + +/** Read + parse a package.json, or return null if it isn't one. */ +const tryReadPkg = (path) => { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } +}; + +/** Rewrite only the top-level "version" line, preserving all other formatting. */ +const setVersion = (path, version) => { + const text = readFileSync(path, "utf8"); + writeFileSync(path, text.replace(/("version"\s*:\s*)"[^"]*"/, `$1"${version}"`)); +}; + +// 1. Compute the next patch version from the root anchor and write it. +const rootPath = join(root, "package.json"); +const rootPkg = tryReadPkg(rootPath); +const [major, minor, patch] = rootPkg.version.split(".").map(Number); +const version = `${major}.${minor}.${patch + 1}`; +setVersion(rootPath, version); + +// 2. Set every publishable package to the anchor version; skip private ones. +const packagesDir = join(root, "packages"); +const bumped = []; +const skipped = []; +for (const name of readdirSync(packagesDir)) { + const pkgPath = join(packagesDir, name, "package.json"); + const pkg = tryReadPkg(pkgPath); + if (!pkg) continue; + if (pkg.private === true) { + skipped.push(pkg.name ?? name); + continue; + } + setVersion(pkgPath, version); + bumped.push(pkg.name ?? name); +} + +// 3. Keep the data-ai plugin manifest (ships with a publishable package) in lockstep. +const pluginPath = join(root, "packages/data-ai/.claude-plugin/plugin.json"); +if (tryReadPkg(pluginPath)) setVersion(pluginPath, version); + +console.log(`v${version}`); +console.log(` bumped ${bumped.length}: ${bumped.sort().join(", ")}`); +console.log(` skipped ${skipped.length} private: ${skipped.sort().join(", ")}`);