From 6234262085a7fc7072214f2e6226e36f3e7e9040 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 1 Sep 2026 21:30:33 -0700 Subject: [PATCH 01/15] feat(data): add serializable Service.Descriptor + compile-time descriptor validation Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- packages/data-ai/.claude-plugin/plugin.json | 2 +- packages/data-ai/package.json | 2 +- packages/data-gpu-hopper/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- packages/data-gpu/package.json | 2 +- .../data-lit-space-rock-game/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-lit/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-persistence/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-react/package.json | 2 +- packages/data-solid-dashboard/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 +- .../service/async-data-service/is-valid.ts | 100 +++++++++++ .../data/src/service/descriptor-to-service.ts | 157 ++++++++++++++++++ packages/data/src/service/service.ts | 51 ++++++ 23 files changed, 328 insertions(+), 20 deletions(-) create mode 100644 packages/data/src/service/descriptor-to-service.ts diff --git a/package.json b/package.json index b2610eec..c646821e 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" 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-hopper/package.json b/packages/data-gpu-hopper/package.json index 9a9c236c..ae35a27b 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-hopper", - "version": "0.10.6", + "version": "0.10.7", "description": "Hopper sample - real-time ECS game rendered as colored cubes via @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 25a90d2b..130cc5fc 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.10.6", + "version": "0.10.7", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, 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-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index f29222a7..074d1c7a 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-space-rock-game", - "version": "0.10.6", + "version": "0.10.7", "description": "Space Rock Game sample - real-time ECS game with Lit and @adobe/data", "type": "module", "private": true, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 5b037705..879dee47 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.10.6", + "version": "0.10.7", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index 0d97b727..d1dc6fac 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.10.6", + "version": "0.10.7", "description": "Todo application - Lit web components with @adobe/data ECS", "type": "module", "private": true, 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-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index 1fd8b616..ce1d8a60 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.10.6", + "version": "0.10.7", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, 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-hello/package.json b/packages/data-react-hello/package.json index 205b62f9..9c52a67b 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.10.6", + "version": "0.10.7", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 6bb9d707..4f9643c2 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.10.6", + "version": "0.10.7", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, 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-dashboard/package.json b/packages/data-solid-dashboard/package.json index 3e183e3a..de62ede4 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.10.6", + "version": "0.10.7", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, 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/service/async-data-service/is-valid.ts b/packages/data/src/service/async-data-service/is-valid.ts index ab76e552..a8972b05 100644 --- a/packages/data/src/service/async-data-service/is-valid.ts +++ b/packages/data/src/service/async-data-service/is-valid.ts @@ -4,6 +4,7 @@ import { Data } from "../../data.js"; import { Observe } from "../../observe/index.js"; import { Service } from "../service.js"; import { Assert } from "../../types/assert.js"; +import { EquivalentTypes } from "../../types/types.js"; /** * Checks if a service is a valid async data service. @@ -114,3 +115,102 @@ type AllPropertiesValid = : false; export type IsValid = AllPropertiesValid; + +// ============================================================================ +// DESCRIPTOR-AWARE VALIDATION +// ============================================================================ +// +// The base `IsValid` is intentionally descriptor-free. These two variants layer +// a compile-time check that a strongly-typed descriptor (passed as the second +// argument, sourced from `typeof theDescriptorConst`) matches the service. The +// descriptor is transformed to a service shape via `Service.Descriptor.ToService` +// and compared against the service's own members. + +/** A service's own members — everything except the base `Service` metadata slots. */ +type ServiceMembers = Omit; + +/** + * A valid async data service whose members INCLUDE everything `D` describes: a + * partial (subset) descriptor. Every described state/action/child service must + * exist on the service with a compatible type, but the service MAY expose + * additional, undescribed members. + */ +export type IsValidWithPartialDescriptor = + AllPropertiesValid extends true + ? [ServiceMembers] extends [Service.Descriptor.ToService] ? true : false + : false; + +/** + * A valid async data service whose members are EXACTLY what `D` describes: no + * described member missing, and no undescribed member present. + */ +export type IsValidWithCompleteDescriptor = + AllPropertiesValid extends true + ? EquivalentTypes, ServiceMembers> + : false; + +// ---- Descriptor validation tests ------------------------------------------- + +interface _AccountService extends Service { + balance: Observe; + deposit: (amount: number) => Promise; +} + +const _completeAccountDescriptor = { + description: "An account.", + states: { balance: { schema: { type: "number" }, description: "current balance" } }, + actions: { + deposit: { + parameters: [{ type: "number" }], + result: "promise", + returns: { type: "number" }, + description: "add funds; resolves to the new balance", + }, + }, + services: {}, +} as const satisfies Service.Descriptor; + +const _partialAccountDescriptor = { + description: "An account (balance only).", + states: { balance: { schema: { type: "number" }, description: "current balance" } }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +const _wrongTypeDescriptor = { + description: "An account with a wrongly-typed balance.", + states: { balance: { schema: { type: "string" }, description: "should be a number" } }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +const _extraMemberDescriptor = { + description: "Describes a member the service does not have.", + states: { + balance: { schema: { type: "number" }, description: "current balance" }, + nonexistent: { schema: { type: "number" }, description: "not on the service" }, + }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +// Positive: the complete descriptor matches the service exactly. +type _CheckCompleteMatches = Assert>; + +// Positive: the partial descriptor is a valid subset. +type _CheckPartialMatches = Assert>; + +// Positive: a complete descriptor also satisfies the partial (subset) check. +type _CheckCompleteIsAlsoPartial = Assert>; + +// Negative: the partial descriptor is NOT a complete description (missing `deposit`). +// @ts-expect-error — partial descriptor omits the `deposit` action +type _CheckPartialIsNotComplete = Assert>; + +// Negative: a described member with the wrong type fails the partial (subset) check. +// @ts-expect-error — balance is a number on the service, but string in the descriptor +type _CheckWrongTypeFailsPartial = Assert>; + +// Negative: describing a member the service lacks fails even the partial check. +// @ts-expect-error — `nonexistent` is not a member of _AccountService +type _CheckExtraDescribedFailsPartial = Assert>; diff --git a/packages/data/src/service/descriptor-to-service.ts b/packages/data/src/service/descriptor-to-service.ts new file mode 100644 index 00000000..ad51d9fa --- /dev/null +++ b/packages/data/src/service/descriptor-to-service.ts @@ -0,0 +1,157 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import type { Observe } from "../observe/index.js"; +import type { Schema } from "../schema/index.js"; +import type { Service } from "./service.js"; +import type { EquivalentTypes, False, True } from "../types/types.js"; + +type StateDescriptor = Service.Descriptor.StateDescriptor; +type ActionDescriptor = Service.Descriptor.ActionDescriptor; + +/** Map a tuple of Schemas to the (mutable) tuple of their runtime types, positionally. */ +type ArgsFromSchemas

= { + -readonly [K in keyof P]: Schema.ToType; +}; + +/** + * `Observe` for a direct state, or a factory `(...args) => Observe` + * when the state declares `parameters`. + */ +type StateToMember = + S extends { parameters: infer P } + ? P extends readonly Schema[] + ? (...args: ArgsFromSchemas

) => Observe> + : never + : Observe>; + +/** The resolved / yielded value of an action; `void` when no `returns` schema is given. */ +type ActionValue = + A extends { returns: infer R } ? R extends Schema ? Schema.ToType : void : void; + +/** A callable whose return shape is determined by the action's `result` kind. */ +type ActionToMember = + A extends { parameters: infer P } + ? P extends readonly Schema[] + ? (...args: ArgsFromSchemas

) => + A["result"] extends "promise" ? Promise> + : A["result"] extends "generator" ? AsyncGenerator> + : void + : never + : never; + +type Simplify = { [K in keyof T]: T[K] }; + +/** + * Converts a statically-typed {@link Service.Descriptor} into the `Service` + * type it describes: `states` become `Observe` values (or observe factories), + * `actions` become callables, and `services` recurse. Used to prove — at + * compile time — that a descriptor matches its associated service. + */ +export type DescriptorToService = Simplify< + & { -readonly [K in keyof D["states"]]: StateToMember } + & { -readonly [K in keyof D["actions"]]: ActionToMember } + & { + -readonly [K in keyof D["services"]]: D["services"][K] extends Service.Descriptor + ? DescriptorToService + : never + } +>; + +// ============================================================================ +// COMPILE-TIME TESTS +// ============================================================================ + +const userDescriptor = { + description: "Reads and mutates users.", + states: { + // direct Observe property (no parameters) + currentUser: { schema: { type: "string" }, description: "id of the current user" }, + // observe factory (parameters → callable returning Observe) + userName: { + schema: { type: "string" }, + parameters: [{ type: "string" }], + description: "observe a user's name by id", + }, + }, + actions: { + save: { + parameters: [{ type: "string" }], + result: "promise", + returns: { type: "boolean" }, + description: "persist a user; resolves to whether anything changed", + }, + refresh: { + parameters: [], + result: "promise", + description: "reload from the server; resolves to void", + }, + poke: { + parameters: [{ type: "string" }], + result: "void", + description: "fire-and-forget nudge", + }, + watchCount: { + parameters: [{ type: "integer" }], + result: "generator", + returns: { type: "integer" }, + description: "stream a running count", + }, + }, + services: { + audit: { + description: "Read-only audit trail.", + states: { + entries: { schema: { type: "string" }, description: "latest audit entry" }, + }, + actions: {}, + services: {}, + }, + }, +} as const satisfies Service.Descriptor; + +type UserService = DescriptorToService; + +type ExpectedUserService = { + currentUser: Observe; + userName: (id: string) => Observe; + save: (id: string) => Promise; + refresh: () => Promise; + poke: (id: string) => void; + watchCount: (start: number) => AsyncGenerator; + audit: { + entries: Observe; + }; +}; + +// Positive: the transform reproduces the expected service exactly. +type _CheckUserService = True>; + +// Positive: each member shape in isolation. +type _CheckDirectState = True>>; +type _CheckObserveFactory = True Observe>>; +type _CheckPromiseAction = True Promise>>; +type _CheckVoidPromiseAction = True Promise>>; +type _CheckVoidAction = True void>>; +type _CheckGeneratorAction = True AsyncGenerator>>; +type _CheckNestedService = True }>>; + +// Negative: a direct state must NOT be produced as a zero-arg observe factory. +type _CheckDirectStateNotFactory = False Observe>>; + +// Negative: an observe factory must NOT collapse to a direct Observe value. +type _CheckFactoryNotDirect = False>>; + +// Negative: the promise action's resolved value must reflect its `returns` schema. +type _CheckPromiseValueTyped = False Promise>>; + +// Negative: result kinds are distinct — a "void" action is not a promise. +type _CheckVoidNotPromise = False Promise>>; + +// Negative: a generator action is not a promise action. +type _CheckGeneratorNotPromise = False Promise>>; + +// Negative: argument schemas are honored — a string arg is not a number arg. +type _CheckArgTyped = False Promise>>; + +// Negative: the whole service is not equivalent to one missing a member. +type _CheckMissingMember = False>>; diff --git a/packages/data/src/service/service.ts b/packages/data/src/service/service.ts index 0e03f90c..6c8e472d 100644 --- a/packages/data/src/service/service.ts +++ b/packages/data/src/service/service.ts @@ -1,5 +1,8 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Schema } from "../schema/index.js"; +import type { DescriptorToService } from "./descriptor-to-service.js"; + /** * A service is an object that provides functionality to an application. * Services are never dependent upon user interface components. @@ -13,4 +16,52 @@ */ export interface Service { readonly serviceName?: string; + /** + * Optional, purely declarative description of this service's surface. + * Lives in the base `Service` so it is excluded from `AsyncDataService` + * validation — a plain-data constant that can be serialized and published. + */ + readonly descriptor?: Service.Descriptor; +} + +export namespace Service { + /** + * A JSON-serializable description of a service: its observable states, its + * actions, and any child services it exposes. Mirrors the shapes an + * `AsyncDataService` is allowed to contain. + */ + export interface Descriptor { + readonly description: string; + readonly states: { readonly [name: string]: Descriptor.StateDescriptor }; + readonly actions: { readonly [name: string]: Descriptor.ActionDescriptor }; + readonly services: { readonly [name: string]: Descriptor }; + } + + export namespace Descriptor { + /** + * Converts a statically-typed `Descriptor` into the `Service` type it + * describes — the compile-time bridge used to verify a descriptor matches + * its associated service. + */ + export type ToService = DescriptorToService; + + /** + * A readable observable value. Direct (`foo: Observe`) when `parameters` + * is absent; an observe factory (`bar(args): Observe`) when present. + */ + export interface StateDescriptor { + readonly schema: Schema; + readonly parameters?: readonly Schema[]; + readonly description: string; + } + + /** A callable invoked for its effect or async result. */ + export interface ActionDescriptor { + readonly parameters: readonly Schema[]; + readonly result: "promise" | "generator" | "void"; + /** Schema of the resolved/yielded value; absent when result is "void". */ + readonly returns?: Schema; + readonly description: string; + } + } } From 31a70f2207077a33a31e977f7d10f704bcdaa5c2 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 1 Sep 2026 22:00:38 -0700 Subject: [PATCH 02/15] =?UTF-8?q?refactor(data):=20split=20descriptor=20va?= =?UTF-8?q?lidators=20into=20own=20files;=20map=20Schema.ToType=20blob=20?= =?UTF-8?q?=E2=86=92=20Blob?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move IsValidWith{Partial,Complete}Descriptor out of is-valid.ts so the base validator stays descriptor-agnostic. Fix Schema.ToType to resolve { type: "blob" } to Blob instead of falling through to any; add a Blob-as-state-and-return test. Co-Authored-By: Claude Opus 4.8 --- packages/data-gpu-hopper/package.json | 2 +- packages/data-gpu-samples/package.json | 2 +- .../data-lit-space-rock-game/package.json | 2 +- packages/data-lit-tictactoe/package.json | 2 +- packages/data-lit-todo/package.json | 2 +- packages/data-p2p-tictactoe/package.json | 2 +- packages/data-react-hello/package.json | 2 +- packages/data-react-pixie/package.json | 2 +- packages/data-solid-dashboard/package.json | 2 +- packages/data/src/schema/to-type.ts | 2 + .../is-valid-with-complete-descriptor.ts | 89 ++++++++++++++++ .../is-valid-with-partial-descriptor.ts | 62 +++++++++++ .../service/async-data-service/is-valid.ts | 100 ------------------ .../src/service/async-data-service/public.ts | 2 + 14 files changed, 164 insertions(+), 109 deletions(-) create mode 100644 packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts create mode 100644 packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts diff --git a/packages/data-gpu-hopper/package.json b/packages/data-gpu-hopper/package.json index ae35a27b..9a9c236c 100644 --- a/packages/data-gpu-hopper/package.json +++ b/packages/data-gpu-hopper/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-hopper", - "version": "0.10.7", + "version": "0.10.6", "description": "Hopper sample - real-time ECS game rendered as colored cubes via @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-gpu-samples/package.json b/packages/data-gpu-samples/package.json index 130cc5fc..25a90d2b 100644 --- a/packages/data-gpu-samples/package.json +++ b/packages/data-gpu-samples/package.json @@ -1,6 +1,6 @@ { "name": "data-gpu-samples", - "version": "0.10.7", + "version": "0.10.6", "description": "WebGPU samples built on @adobe/data-gpu", "type": "module", "private": true, diff --git a/packages/data-lit-space-rock-game/package.json b/packages/data-lit-space-rock-game/package.json index 074d1c7a..f29222a7 100644 --- a/packages/data-lit-space-rock-game/package.json +++ b/packages/data-lit-space-rock-game/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-space-rock-game", - "version": "0.10.7", + "version": "0.10.6", "description": "Space Rock Game sample - real-time ECS game with Lit and @adobe/data", "type": "module", "private": true, diff --git a/packages/data-lit-tictactoe/package.json b/packages/data-lit-tictactoe/package.json index 879dee47..5b037705 100644 --- a/packages/data-lit-tictactoe/package.json +++ b/packages/data-lit-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-tictactoe", - "version": "0.10.7", + "version": "0.10.6", "description": "Tic-Tac-Toe sample - Lit web components with @adobe/data-lit and AgenticService", "type": "module", "private": true, diff --git a/packages/data-lit-todo/package.json b/packages/data-lit-todo/package.json index d1dc6fac..0d97b727 100644 --- a/packages/data-lit-todo/package.json +++ b/packages/data-lit-todo/package.json @@ -1,6 +1,6 @@ { "name": "data-lit-todo", - "version": "0.10.7", + "version": "0.10.6", "description": "Todo application - Lit web components with @adobe/data ECS", "type": "module", "private": true, diff --git a/packages/data-p2p-tictactoe/package.json b/packages/data-p2p-tictactoe/package.json index ce1d8a60..1fd8b616 100644 --- a/packages/data-p2p-tictactoe/package.json +++ b/packages/data-p2p-tictactoe/package.json @@ -1,6 +1,6 @@ { "name": "data-p2p-tictactoe", - "version": "0.10.7", + "version": "0.10.6", "description": "Serverless P2P tic-tac-toe — WebRTC DataChannel + @adobe/data-sync", "type": "module", "private": true, diff --git a/packages/data-react-hello/package.json b/packages/data-react-hello/package.json index 9c52a67b..205b62f9 100644 --- a/packages/data-react-hello/package.json +++ b/packages/data-react-hello/package.json @@ -1,6 +1,6 @@ { "name": "data-react-hello", - "version": "0.10.7", + "version": "0.10.6", "description": "Hello World sample - click counter using @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-react-pixie/package.json b/packages/data-react-pixie/package.json index 4f9643c2..6bb9d707 100644 --- a/packages/data-react-pixie/package.json +++ b/packages/data-react-pixie/package.json @@ -1,6 +1,6 @@ { "name": "data-react-pixie", - "version": "0.10.7", + "version": "0.10.6", "description": "PixiJS React sample - ECS sprites (bunny, fox) with @adobe/data-react", "type": "module", "private": true, diff --git a/packages/data-solid-dashboard/package.json b/packages/data-solid-dashboard/package.json index de62ede4..3e183e3a 100644 --- a/packages/data-solid-dashboard/package.json +++ b/packages/data-solid-dashboard/package.json @@ -1,6 +1,6 @@ { "name": "data-solid-dashboard", - "version": "0.10.7", + "version": "0.10.6", "description": "Mini dashboard sample — multiple components sharing one @adobe/data ECS database with SolidJS", "type": "module", "private": true, diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts index 3de138fc..ac9a5bdc 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -26,6 +26,8 @@ type FromSchemaInternal = T extends { const: infer ? boolean : T extends { type: 'null' } ? null + : T extends { type: 'blob' } + ? Blob : T extends { type: 'typed-buffer', items: infer Items } ? TypedBuffer> : T extends { type: 'typed-buffer' } diff --git a/packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts b/packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts new file mode 100644 index 00000000..bfebb554 --- /dev/null +++ b/packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts @@ -0,0 +1,89 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/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 `D` describes: no + * described member missing, and no undescribed member present. + * + * `D` is passed as a second type argument, sourced from `typeof theDescriptorConst` + * (a `... as const satisfies Service.Descriptor`), so its literal shape is known. + */ +export type IsValidWithCompleteDescriptor = + IsValid extends true + ? EquivalentTypes, Omit> + : false; + +// ---- Tests ----------------------------------------------------------------- + +interface _AccountService extends Service { + balance: Observe; + deposit: (amount: number) => Promise; +} + +const _completeAccountDescriptor = { + description: "An account.", + states: { balance: { schema: { type: "number" }, description: "current balance" } }, + actions: { + deposit: { + parameters: [{ type: "number" }], + result: "promise", + returns: { type: "number" }, + description: "add funds; resolves to the new balance", + }, + }, + services: {}, +} as const satisfies Service.Descriptor; + +const _incompleteAccountDescriptor = { + description: "An account (balance only).", + states: { balance: { schema: { type: "number" }, description: "current balance" } }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +// Positive: the complete descriptor matches the service exactly. +type _CheckCompleteMatches = Assert>; + +// Negative: an incomplete descriptor is NOT a complete description (missing `deposit`). +// @ts-expect-error — descriptor omits the `deposit` action, so it is not complete +type _CheckIncompleteFails = Assert>; + +// ---- Blob: describable as both a property (state) and a return value ------- + +interface _AssetService extends Service { + thumbnail: Observe; // Blob as an observable property + download: (id: string) => Promise; // Blob as an action return value +} + +const _assetDescriptor = { + description: "Serves binary assets.", + states: { thumbnail: { schema: { type: "blob" }, description: "current thumbnail" } }, + actions: { + download: { + parameters: [{ type: "string" }], + result: "promise", + returns: { type: "blob" }, + description: "download an asset by id", + }, + }, + services: {}, +} as const satisfies Service.Descriptor; + +// Positive: a Blob state and a Blob return value are described and validated. +type _CheckBlobComplete = Assert>; + +type _AssetShape = Service.Descriptor.ToService; + +// Positive: the blob schema resolves to `Blob` in both positions. +type _CheckBlobState = True>>; +type _CheckBlobReturn = True Promise>>; + +// Negative — and a regression guard: this only holds if `{ type: "blob" }` +// resolves to `Blob` rather than `any` (an `any` member would be spuriously +// equivalent to `Observe`, failing this check). +type _CheckBlobIsNotString = False>>; diff --git a/packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts b/packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts new file mode 100644 index 00000000..4f8362a1 --- /dev/null +++ b/packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts @@ -0,0 +1,62 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { Observe } from "../../observe/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 `D` describes: a + * partial (subset) descriptor. Every described state / action / child service + * must exist on the service with a compatible type, but the service MAY expose + * additional, undescribed members. + * + * `D` is passed as a second type argument, sourced from `typeof theDescriptorConst` + * (a `... as const satisfies Service.Descriptor`), so its literal shape is known. + */ +export type IsValidWithPartialDescriptor = + IsValid extends true + ? [Omit] extends [Service.Descriptor.ToService] ? true : false + : false; + +// ---- Tests ----------------------------------------------------------------- + +interface _AccountService extends Service { + balance: Observe; + deposit: (amount: number) => Promise; +} + +const _partialAccountDescriptor = { + description: "An account (balance only).", + states: { balance: { schema: { type: "number" }, description: "current balance" } }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +const _wrongTypeDescriptor = { + description: "An account with a wrongly-typed balance.", + states: { balance: { schema: { type: "string" }, description: "should be a number" } }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +const _extraMemberDescriptor = { + description: "Describes a member the service does not have.", + states: { + balance: { schema: { type: "number" }, description: "current balance" }, + nonexistent: { schema: { type: "number" }, description: "not on the service" }, + }, + actions: {}, + services: {}, +} as const satisfies Service.Descriptor; + +// Positive: a partial descriptor 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 descriptor +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 a8972b05..ab76e552 100644 --- a/packages/data/src/service/async-data-service/is-valid.ts +++ b/packages/data/src/service/async-data-service/is-valid.ts @@ -4,7 +4,6 @@ import { Data } from "../../data.js"; import { Observe } from "../../observe/index.js"; import { Service } from "../service.js"; import { Assert } from "../../types/assert.js"; -import { EquivalentTypes } from "../../types/types.js"; /** * Checks if a service is a valid async data service. @@ -115,102 +114,3 @@ type AllPropertiesValid = : false; export type IsValid = AllPropertiesValid; - -// ============================================================================ -// DESCRIPTOR-AWARE VALIDATION -// ============================================================================ -// -// The base `IsValid` is intentionally descriptor-free. These two variants layer -// a compile-time check that a strongly-typed descriptor (passed as the second -// argument, sourced from `typeof theDescriptorConst`) matches the service. The -// descriptor is transformed to a service shape via `Service.Descriptor.ToService` -// and compared against the service's own members. - -/** A service's own members — everything except the base `Service` metadata slots. */ -type ServiceMembers = Omit; - -/** - * A valid async data service whose members INCLUDE everything `D` describes: a - * partial (subset) descriptor. Every described state/action/child service must - * exist on the service with a compatible type, but the service MAY expose - * additional, undescribed members. - */ -export type IsValidWithPartialDescriptor = - AllPropertiesValid extends true - ? [ServiceMembers] extends [Service.Descriptor.ToService] ? true : false - : false; - -/** - * A valid async data service whose members are EXACTLY what `D` describes: no - * described member missing, and no undescribed member present. - */ -export type IsValidWithCompleteDescriptor = - AllPropertiesValid extends true - ? EquivalentTypes, ServiceMembers> - : false; - -// ---- Descriptor validation tests ------------------------------------------- - -interface _AccountService extends Service { - balance: Observe; - deposit: (amount: number) => Promise; -} - -const _completeAccountDescriptor = { - description: "An account.", - states: { balance: { schema: { type: "number" }, description: "current balance" } }, - actions: { - deposit: { - parameters: [{ type: "number" }], - result: "promise", - returns: { type: "number" }, - description: "add funds; resolves to the new balance", - }, - }, - services: {}, -} as const satisfies Service.Descriptor; - -const _partialAccountDescriptor = { - description: "An account (balance only).", - states: { balance: { schema: { type: "number" }, description: "current balance" } }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -const _wrongTypeDescriptor = { - description: "An account with a wrongly-typed balance.", - states: { balance: { schema: { type: "string" }, description: "should be a number" } }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -const _extraMemberDescriptor = { - description: "Describes a member the service does not have.", - states: { - balance: { schema: { type: "number" }, description: "current balance" }, - nonexistent: { schema: { type: "number" }, description: "not on the service" }, - }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -// Positive: the complete descriptor matches the service exactly. -type _CheckCompleteMatches = Assert>; - -// Positive: the partial descriptor is a valid subset. -type _CheckPartialMatches = Assert>; - -// Positive: a complete descriptor also satisfies the partial (subset) check. -type _CheckCompleteIsAlsoPartial = Assert>; - -// Negative: the partial descriptor is NOT a complete description (missing `deposit`). -// @ts-expect-error — partial descriptor omits the `deposit` action -type _CheckPartialIsNotComplete = Assert>; - -// Negative: a described member with the wrong type fails the partial (subset) check. -// @ts-expect-error — balance is a number on the service, but string in the descriptor -type _CheckWrongTypeFailsPartial = Assert>; - -// Negative: describing a member the service lacks fails even the partial check. -// @ts-expect-error — `nonexistent` is not a member of _AccountService -type _CheckExtraDescribedFailsPartial = Assert>; diff --git a/packages/data/src/service/async-data-service/public.ts b/packages/data/src/service/async-data-service/public.ts index 05f53ba6..077ac816 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-descriptor.js"; +export * from "./is-valid-with-complete-descriptor.js"; export * from "./create-lazy.js"; From 4bc38107eca7fc9270163cd03f9830e1aeebdf13 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Tue, 1 Sep 2026 22:00:47 -0700 Subject: [PATCH 03/15] chore(data): bump only publishable packages, not private samples Replace the inline bump one-liner with scripts/bump.mjs, which sets the anchor version on the root and every publishable package (private !== true) and skips private sample/app packages. Keyed off the same `private` field that governs publishing, so there is no package list to maintain. Revert the private samples bumped in this PR back to 0.10.6. Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- scripts/bump.mjs | 65 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 scripts/bump.mjs diff --git a/package.json b/package.json index c646821e..e4b0a670 100644 --- a/package.json +++ b/package.json @@ -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/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(", ")}`); From 82bdcbb2bd6f382373ffed4d0ded9e09aa73eb30 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 20:19:47 -0700 Subject: [PATCH 04/15] feat(data): add observe/promise/generator/function schema type-constructors Extend Schema + Schema.ToType to express data-adjacent types so a schema can describe a service surface, not just data. Nestable (e.g. AsyncGenerator as a function parameter). Confined by usage, like blob/typed-buffer. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/schema/schema.ts | 16 +- packages/data/src/schema/to-type.ts | 93 ++++++++++- .../data/src/service/descriptor-to-service.ts | 157 ------------------ 3 files changed, 107 insertions(+), 159 deletions(-) delete mode 100644 packages/data/src/service/descriptor-to-service.ts diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index aa52d019..f2435e71 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,14 @@ 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; + // Signature of the `function` constructor: `{ type: "function", parameters, + // returns }` → `(...args) => ToType`. Absent `parameters` ⇒ no args; + // absent `returns` ⇒ void. + parameters?: readonly Schema[]; + returns?: Schema; 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 ac9a5bdc..0da0d01e 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 = @@ -28,6 +29,14 @@ type FromSchemaInternal = T extends { const: infer ? 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' } @@ -47,6 +56,25 @@ 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: parameters → positional args, returns → result. +type FromSchemaFunction = + T extends { parameters: infer P } + ? P extends readonly Schema[] + ? (...args: FromSchemaArgs) => FromSchemaReturns + : never + : (...args: []) => FromSchemaReturns; + +type FromSchemaArgs

= { + -readonly [K in keyof P]: ToType; +}; + +// Absent `returns` ⇒ void (a function that returns nothing meaningful). +type FromSchemaReturns = + T extends { returns: infer R } ? R extends Schema ? ToType : void : void; + type FromSchemaArray = T extends { items: infer Items; } @@ -266,3 +294,66 @@ 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', parameters: [{ type: 'number' }, { type: 'string' }], returns: { type: 'boolean' } +}>; // (a: number, b: string) => boolean +type CheckFunction = True boolean>>; + +type TestFunctionVoid = ToType<{ type: 'function', parameters: [] }>; // () => void +type CheckFunctionVoid = True void>>; + +type TestFunctionNoParams = ToType<{ type: 'function' }>; // () => void +type CheckFunctionNoParams = True void>>; + +// 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', + 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/descriptor-to-service.ts b/packages/data/src/service/descriptor-to-service.ts deleted file mode 100644 index ad51d9fa..00000000 --- a/packages/data/src/service/descriptor-to-service.ts +++ /dev/null @@ -1,157 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import type { Observe } from "../observe/index.js"; -import type { Schema } from "../schema/index.js"; -import type { Service } from "./service.js"; -import type { EquivalentTypes, False, True } from "../types/types.js"; - -type StateDescriptor = Service.Descriptor.StateDescriptor; -type ActionDescriptor = Service.Descriptor.ActionDescriptor; - -/** Map a tuple of Schemas to the (mutable) tuple of their runtime types, positionally. */ -type ArgsFromSchemas

= { - -readonly [K in keyof P]: Schema.ToType; -}; - -/** - * `Observe` for a direct state, or a factory `(...args) => Observe` - * when the state declares `parameters`. - */ -type StateToMember = - S extends { parameters: infer P } - ? P extends readonly Schema[] - ? (...args: ArgsFromSchemas

) => Observe> - : never - : Observe>; - -/** The resolved / yielded value of an action; `void` when no `returns` schema is given. */ -type ActionValue = - A extends { returns: infer R } ? R extends Schema ? Schema.ToType : void : void; - -/** A callable whose return shape is determined by the action's `result` kind. */ -type ActionToMember = - A extends { parameters: infer P } - ? P extends readonly Schema[] - ? (...args: ArgsFromSchemas

) => - A["result"] extends "promise" ? Promise> - : A["result"] extends "generator" ? AsyncGenerator> - : void - : never - : never; - -type Simplify = { [K in keyof T]: T[K] }; - -/** - * Converts a statically-typed {@link Service.Descriptor} into the `Service` - * type it describes: `states` become `Observe` values (or observe factories), - * `actions` become callables, and `services` recurse. Used to prove — at - * compile time — that a descriptor matches its associated service. - */ -export type DescriptorToService = Simplify< - & { -readonly [K in keyof D["states"]]: StateToMember } - & { -readonly [K in keyof D["actions"]]: ActionToMember } - & { - -readonly [K in keyof D["services"]]: D["services"][K] extends Service.Descriptor - ? DescriptorToService - : never - } ->; - -// ============================================================================ -// COMPILE-TIME TESTS -// ============================================================================ - -const userDescriptor = { - description: "Reads and mutates users.", - states: { - // direct Observe property (no parameters) - currentUser: { schema: { type: "string" }, description: "id of the current user" }, - // observe factory (parameters → callable returning Observe) - userName: { - schema: { type: "string" }, - parameters: [{ type: "string" }], - description: "observe a user's name by id", - }, - }, - actions: { - save: { - parameters: [{ type: "string" }], - result: "promise", - returns: { type: "boolean" }, - description: "persist a user; resolves to whether anything changed", - }, - refresh: { - parameters: [], - result: "promise", - description: "reload from the server; resolves to void", - }, - poke: { - parameters: [{ type: "string" }], - result: "void", - description: "fire-and-forget nudge", - }, - watchCount: { - parameters: [{ type: "integer" }], - result: "generator", - returns: { type: "integer" }, - description: "stream a running count", - }, - }, - services: { - audit: { - description: "Read-only audit trail.", - states: { - entries: { schema: { type: "string" }, description: "latest audit entry" }, - }, - actions: {}, - services: {}, - }, - }, -} as const satisfies Service.Descriptor; - -type UserService = DescriptorToService; - -type ExpectedUserService = { - currentUser: Observe; - userName: (id: string) => Observe; - save: (id: string) => Promise; - refresh: () => Promise; - poke: (id: string) => void; - watchCount: (start: number) => AsyncGenerator; - audit: { - entries: Observe; - }; -}; - -// Positive: the transform reproduces the expected service exactly. -type _CheckUserService = True>; - -// Positive: each member shape in isolation. -type _CheckDirectState = True>>; -type _CheckObserveFactory = True Observe>>; -type _CheckPromiseAction = True Promise>>; -type _CheckVoidPromiseAction = True Promise>>; -type _CheckVoidAction = True void>>; -type _CheckGeneratorAction = True AsyncGenerator>>; -type _CheckNestedService = True }>>; - -// Negative: a direct state must NOT be produced as a zero-arg observe factory. -type _CheckDirectStateNotFactory = False Observe>>; - -// Negative: an observe factory must NOT collapse to a direct Observe value. -type _CheckFactoryNotDirect = False>>; - -// Negative: the promise action's resolved value must reflect its `returns` schema. -type _CheckPromiseValueTyped = False Promise>>; - -// Negative: result kinds are distinct — a "void" action is not a promise. -type _CheckVoidNotPromise = False Promise>>; - -// Negative: a generator action is not a promise action. -type _CheckGeneratorNotPromise = False Promise>>; - -// Negative: argument schemas are honored — a string arg is not a number arg. -type _CheckArgTyped = False Promise>>; - -// Negative: the whole service is not equivalent to one missing a member. -type _CheckMissingMember = False>>; From fbe6793a8882e93f2e19580ab8ef3df826dc19e6 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 20:19:47 -0700 Subject: [PATCH 05/15] refactor(data): sideload service schemas; drive createLazy from a Schema Collapse the bespoke Service.Descriptor into a plain object Schema (ToService = Schema.ToType). Schemas are published beside the service (MyService.schema), not attached to instances, so the base Service stays minimal. Rename the descriptor validators to IsValidWith{Partial,Complete}Schema over Schema.ToType. Rework createLazy to take a sideloaded schema, deriving each member's wrapper from its schema type, gated by IsValidWithCompleteSchema. Co-Authored-By: Claude Opus 4.8 --- .../src/service/async-data-service/README.md | 27 +- .../service/async-data-service/create-lazy.md | 215 +++++------ .../async-data-service/create-lazy.test.ts | 337 +++++++++++++----- .../service/async-data-service/create-lazy.ts | 141 ++++---- .../src/service/async-data-service/example.ts | 70 +++- .../is-valid-with-complete-descriptor.ts | 89 ----- .../is-valid-with-complete-schema.ts | 92 +++++ .../is-valid-with-partial-descriptor.ts | 62 ---- .../is-valid-with-partial-schema.ts | 67 ++++ .../src/service/async-data-service/public.ts | 4 +- packages/data/src/service/service.ts | 57 +-- 11 files changed, 651 insertions(+), 510 deletions(-) delete mode 100644 packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts create mode 100644 packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts delete mode 100644 packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts create mode 100644 packages/data/src/service/async-data-service/is-valid-with-partial-schema.ts diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index 3ace0e0f..7330e551 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", 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,6 +92,8 @@ 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 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..c236b94f 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", returns: { type: "observe", … } }` → a function returning `Observe` +- `{ type: "function", returns: { type: "generator", … } }` → a function returning `AsyncGenerator` +- `{ type: "function", returns: { type: "promise", … } }` → a function returning `Promise` +- `{ type: "function" }` (no `returns`) → 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` +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 `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", 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", 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", parameters: [{}], returns: { type: "observe", value: {} } }, + streamEvents: { type: "function", returns: { type: "generator", value: {} } }, + fetchData: { type: "function", 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", 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..15bf4169 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", parameters: [] }, + hideSignInDialog: { type: "function", parameters: [] }, + refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, + signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + signOut: { type: "function", 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", parameters: [{}], returns: { type: "observe", value: {} } }, + fetchData: { type: "function", 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", parameters: [], returns: { type: "generator", value: {} } }, + cancel: { type: "function", 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", 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", parameters: [{}], returns: { type: "observe", value: {} } }, + fetchData: { type: "function", parameters: [], returns: { type: "promise", value: {} } } + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false }, preload: true }); @@ -187,82 +212,112 @@ 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", parameters: [] }, + hideSignInDialog: { type: "function", parameters: [] }, + // Missing: refreshToken + signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + signOut: { type: "function", 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", parameters: [], returns: { type: "observe", value: {} } }, // WRONG: should be observe + accessToken: { type: "observe", value: {} }, + userProfile: { type: "observe", value: {} }, + showSignInDialog: { type: "function", parameters: [] }, + hideSignInDialog: { type: "function", parameters: [] }, + refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, + signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + signOut: { type: "function", 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", parameters: [], returns: { type: "promise", value: {} } }, // WRONG: should be void + hideSignInDialog: { type: "function", parameters: [] }, + refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, + signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + signOut: { type: "function", 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", parameters: [{}], returns: { type: "observe", value: {} } }, + fetchData: { type: "function", 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", 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", parameters: [], returns: { type: "promise", value: {} } }, // WRONG: should be generator + cancel: { type: "function", parameters: [] } + }, + required: ["status", "streamEvents", "cancel"], + additionalProperties: false } }); @@ -288,7 +343,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 +385,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 +430,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 +467,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -435,7 +510,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -475,7 +555,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -515,7 +600,12 @@ describe('createLazy', () => { serviceName: 'test-service', fetchData: async (id: string) => `result-${id}` }), - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -541,7 +631,12 @@ describe('createLazy', () => { serviceName: 'test-service', fetchData: async (id: string) => `result-${id}` }), - properties: { fetchData: 'fn:promise' } + schema: { + type: "object", + properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + required: ["fetchData"], + additionalProperties: false + } }); const service = factory(); @@ -585,7 +680,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { track: 'fn:void' } + schema: { + type: "object", + properties: { track: { type: "function", parameters: [{}] } }, + required: ["track"], + additionalProperties: false + } }); const service = factory(); @@ -621,7 +721,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -658,7 +763,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -687,7 +797,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -724,7 +839,12 @@ describe('createLazy', () => { return () => {}; } }), - properties: { selectUser: 'fn:observe' } + schema: { + type: "object", + properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + required: ["selectUser"], + additionalProperties: false + } }); const service = factory(); @@ -769,7 +889,12 @@ describe('createLazy', () => { const factory = createLazy({ load: createTestService, - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -800,7 +925,12 @@ describe('createLazy', () => { yield 2; } }), - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -829,7 +959,12 @@ describe('createLazy', () => { yield `${prefix}-2`; } }), - properties: { streamData: 'fn:generator' } + schema: { + type: "object", + properties: { streamData: { type: "function", parameters: [{}], returns: { type: "generator", value: {} } } }, + required: ["streamData"], + additionalProperties: false + } }); const service = factory(); @@ -873,7 +1008,12 @@ describe('createLazy', () => { serviceName: 'test-service', track: (event: string) => {} }), - properties: { track: 'fn:void' } + schema: { + type: "object", + properties: { track: { type: "function", parameters: [{}] } }, + required: ["track"], + additionalProperties: false + } }); const service = factory(); @@ -919,9 +1059,16 @@ describe('createLazy preload option', () => { } }; + const trackSchema = { + type: "object", + properties: { track: { type: "function", 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 +1084,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 +1101,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..d32ace28 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,56 @@ // © 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; + +// Surfaced on the `schema` argument when it does not completely and correctly +// describe the loaded service, so the mismatch is caught at the call site. +type SchemaMismatch = { + readonly __createLazyError: "createLazy: schema must completely and correctly describe the loaded service"; +}; + +// ============================================================================ +// 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. +function memberKind(member: Schema): WrapKind { + if (member.type === "observe") return "observe"; + if (member.type === "function") { + switch (member.returns?.type) { + case "observe": return "fn:observe"; + case "promise": return "fn:promise"; + case "generator": return "fn:generator"; + default: return "fn:void"; // absent returns ⇒ void + } + } + throw new Error( + `createLazy: unsupported member schema type "${member.type}" — service members must be observe or function schemas`, + ); +} // ============================================================================ // MAIN FUNCTION SIGNATURE @@ -47,59 +61,59 @@ 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` completely and correctly describes the loaded + * service; otherwise the `schema` argument reports a {@link SchemaMismatch}. * * @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 Schema >( params: { load: LoadFn, - properties: { - [K in Exclude, keyof Service>]: - PropertyDescriptor[K]> - }, + schema: S, preload?: boolean - } + } & (IsValidWithCompleteSchema, S> extends true + ? unknown + : { schema: SchemaMismatch }) ): 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 +121,12 @@ export function createLazy< if (loadPromise) { return loadPromise; } - + loadPromise = (load as any)(...factoryArgs).then((service: ServiceType) => { loadedService = service; return service; }); - + return loadPromise!; }; @@ -127,15 +141,16 @@ export function createLazy< const lazyService: any = { serviceName: 'lazy-service', }; - - // 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') { // Update lazy service name once real service loads @@ -145,13 +160,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[]; @@ -203,7 +218,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; @@ -233,13 +248,13 @@ export function createLazy< }); } }; - } 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') { lazyService.serviceName = `lazy-${service.serviceName}`; @@ -249,18 +264,18 @@ 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 { async next(): Promise> { if (!realGenerator) { @@ -272,21 +287,21 @@ export function createLazy< } return realGenerator!.next(); }, - + async return(value?: any): Promise> { if (realGenerator) { return realGenerator.return(value); } return { done: true, value: value as any }; }, - + async throw(e: any): Promise> { if (realGenerator) { return realGenerator.throw(e); } throw e; }, - + [Symbol.asyncIterator]() { return this; } @@ -294,7 +309,7 @@ export function createLazy< }; } } - + 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..e2c21a7a 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", parameters: [{}], returns: { type: "observe", value: {} } }, + fetchUser: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + updateUser: { type: "function", 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", 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-descriptor.ts b/packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts deleted file mode 100644 index bfebb554..00000000 --- a/packages/data/src/service/async-data-service/is-valid-with-complete-descriptor.ts +++ /dev/null @@ -1,89 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Observe } from "../../observe/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 `D` describes: no - * described member missing, and no undescribed member present. - * - * `D` is passed as a second type argument, sourced from `typeof theDescriptorConst` - * (a `... as const satisfies Service.Descriptor`), so its literal shape is known. - */ -export type IsValidWithCompleteDescriptor = - IsValid extends true - ? EquivalentTypes, Omit> - : false; - -// ---- Tests ----------------------------------------------------------------- - -interface _AccountService extends Service { - balance: Observe; - deposit: (amount: number) => Promise; -} - -const _completeAccountDescriptor = { - description: "An account.", - states: { balance: { schema: { type: "number" }, description: "current balance" } }, - actions: { - deposit: { - parameters: [{ type: "number" }], - result: "promise", - returns: { type: "number" }, - description: "add funds; resolves to the new balance", - }, - }, - services: {}, -} as const satisfies Service.Descriptor; - -const _incompleteAccountDescriptor = { - description: "An account (balance only).", - states: { balance: { schema: { type: "number" }, description: "current balance" } }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -// Positive: the complete descriptor matches the service exactly. -type _CheckCompleteMatches = Assert>; - -// Negative: an incomplete descriptor is NOT a complete description (missing `deposit`). -// @ts-expect-error — descriptor omits the `deposit` action, so it is not complete -type _CheckIncompleteFails = Assert>; - -// ---- Blob: describable as both a property (state) and a return value ------- - -interface _AssetService extends Service { - thumbnail: Observe; // Blob as an observable property - download: (id: string) => Promise; // Blob as an action return value -} - -const _assetDescriptor = { - description: "Serves binary assets.", - states: { thumbnail: { schema: { type: "blob" }, description: "current thumbnail" } }, - actions: { - download: { - parameters: [{ type: "string" }], - result: "promise", - returns: { type: "blob" }, - description: "download an asset by id", - }, - }, - services: {}, -} as const satisfies Service.Descriptor; - -// Positive: a Blob state and a Blob return value are described and validated. -type _CheckBlobComplete = Assert>; - -type _AssetShape = Service.Descriptor.ToService; - -// Positive: the blob schema resolves to `Blob` in both positions. -type _CheckBlobState = True>>; -type _CheckBlobReturn = True Promise>>; - -// Negative — and a regression guard: this only holds if `{ type: "blob" }` -// resolves to `Blob` rather than `any` (an `any` member would be spuriously -// equivalent to `Observe`, failing this check). -type _CheckBlobIsNotString = False>>; diff --git a/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts new file mode 100644 index 00000000..fafdabe2 --- /dev/null +++ b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts @@ -0,0 +1,92 @@ +// © 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", + 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", + 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-descriptor.ts b/packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts deleted file mode 100644 index 4f8362a1..00000000 --- a/packages/data/src/service/async-data-service/is-valid-with-partial-descriptor.ts +++ /dev/null @@ -1,62 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. - -import { Observe } from "../../observe/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 `D` describes: a - * partial (subset) descriptor. Every described state / action / child service - * must exist on the service with a compatible type, but the service MAY expose - * additional, undescribed members. - * - * `D` is passed as a second type argument, sourced from `typeof theDescriptorConst` - * (a `... as const satisfies Service.Descriptor`), so its literal shape is known. - */ -export type IsValidWithPartialDescriptor = - IsValid extends true - ? [Omit] extends [Service.Descriptor.ToService] ? true : false - : false; - -// ---- Tests ----------------------------------------------------------------- - -interface _AccountService extends Service { - balance: Observe; - deposit: (amount: number) => Promise; -} - -const _partialAccountDescriptor = { - description: "An account (balance only).", - states: { balance: { schema: { type: "number" }, description: "current balance" } }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -const _wrongTypeDescriptor = { - description: "An account with a wrongly-typed balance.", - states: { balance: { schema: { type: "string" }, description: "should be a number" } }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -const _extraMemberDescriptor = { - description: "Describes a member the service does not have.", - states: { - balance: { schema: { type: "number" }, description: "current balance" }, - nonexistent: { schema: { type: "number" }, description: "not on the service" }, - }, - actions: {}, - services: {}, -} as const satisfies Service.Descriptor; - -// Positive: a partial descriptor 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 descriptor -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-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/public.ts b/packages/data/src/service/async-data-service/public.ts index 077ac816..4bf7dbbb 100644 --- a/packages/data/src/service/async-data-service/public.ts +++ b/packages/data/src/service/async-data-service/public.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. export * from "./is-valid.js"; -export * from "./is-valid-with-partial-descriptor.js"; -export * from "./is-valid-with-complete-descriptor.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 6c8e472d..5cd6c756 100644 --- a/packages/data/src/service/service.ts +++ b/packages/data/src/service/service.ts @@ -1,8 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema } from "../schema/index.js"; -import type { DescriptorToService } from "./descriptor-to-service.js"; - /** * A service is an object that provides functionality to an application. * Services are never dependent upon user interface components. @@ -13,55 +10,13 @@ import type { DescriptorToService } from "./descriptor-to-service.js"; * - 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). Schemas are sideloaded — published + * beside the service (e.g. `MyService.schema`) and provided explicitly where + * needed — never attached to the service instance. See + * `async-data-service/is-valid-with-*-schema.ts`. */ export interface Service { readonly serviceName?: string; - /** - * Optional, purely declarative description of this service's surface. - * Lives in the base `Service` so it is excluded from `AsyncDataService` - * validation — a plain-data constant that can be serialized and published. - */ - readonly descriptor?: Service.Descriptor; -} - -export namespace Service { - /** - * A JSON-serializable description of a service: its observable states, its - * actions, and any child services it exposes. Mirrors the shapes an - * `AsyncDataService` is allowed to contain. - */ - export interface Descriptor { - readonly description: string; - readonly states: { readonly [name: string]: Descriptor.StateDescriptor }; - readonly actions: { readonly [name: string]: Descriptor.ActionDescriptor }; - readonly services: { readonly [name: string]: Descriptor }; - } - - export namespace Descriptor { - /** - * Converts a statically-typed `Descriptor` into the `Service` type it - * describes — the compile-time bridge used to verify a descriptor matches - * its associated service. - */ - export type ToService = DescriptorToService; - - /** - * A readable observable value. Direct (`foo: Observe`) when `parameters` - * is absent; an observe factory (`bar(args): Observe`) when present. - */ - export interface StateDescriptor { - readonly schema: Schema; - readonly parameters?: readonly Schema[]; - readonly description: string; - } - - /** A callable invoked for its effect or async result. */ - export interface ActionDescriptor { - readonly parameters: readonly Schema[]; - readonly result: "promise" | "generator" | "void"; - /** Schema of the resolved/yielded value; absent when result is "void". */ - readonly returns?: Schema; - readonly description: string; - } - } } From af41d3e1e0525763a040dd261d08b817dd2d1af1 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 20:32:35 -0700 Subject: [PATCH 06/15] fix(data): address fable review of createLazy schema gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - memberKind: a function schema with a present-but-unrecognized `returns` now throws instead of silently defaulting to void (was dropping the result). - Constrain createLazy's schema to observe/function members (LazyMemberSchema), so nested-object or malformed-returns members are a compile error rather than a runtime throw — the gate and runtime dispatch can no longer disagree. - Guard the lazy serviceName rename against an undefined real serviceName. - Pass Promise/AsyncGenerator through DeepReadonly (avoid structural expansion). - Soften createLazy's "completely describes" wording re: {}-as-any members. - Delete the README's false IsDataService backwards-compat section. - Add compile-time tests for the nested-object and malformed-returns rejections. Co-Authored-By: Claude Opus 4.8 --- .../src/service/async-data-service/README.md | 11 ----- .../async-data-service/create-lazy.test.ts | 38 +++++++++++++++ .../service/async-data-service/create-lazy.ts | 48 ++++++++++++++----- packages/data/src/types/types.ts | 2 +- 4 files changed, 76 insertions(+), 23 deletions(-) diff --git a/packages/data/src/service/async-data-service/README.md b/packages/data/src/service/async-data-service/README.md index 7330e551..e7a1e177 100644 --- a/packages/data/src/service/async-data-service/README.md +++ b/packages/data/src/service/async-data-service/README.md @@ -99,14 +99,3 @@ 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 - -## 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.test.ts b/packages/data/src/service/async-data-service/create-lazy.test.ts index 15bf4169..bfc265c4 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 @@ -321,6 +321,44 @@ const errorGenerator = createLazy({ } }); +// ❌ 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", parameters: [{}], returns: { type: "observe", value: {} } }, + // @ts-expect-error - `returns` has no recognized type-constructor + fetchData: { type: "function", parameters: [], returns: { value: {} } }, + }, + required: ["allUsers", "selectUser", "fetchData"], + additionalProperties: false + } +}); + // ============================================================================ // RUNTIME TESTS // ============================================================================ 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 d32ace28..ee854cf6 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -29,6 +29,21 @@ type SchemaMismatch = { readonly __createLazyError: "createLazy: schema must completely and correctly describe the loaded service"; }; +// 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 returns?: { readonly type: "observe" | "promise" | "generator" } }; + +type LazyServiceSchema = Schema & { + readonly properties?: { readonly [name: string]: LazyMemberSchema }; +}; + // ============================================================================ // RUNTIME WRAPPER KIND // ============================================================================ @@ -36,15 +51,23 @@ type SchemaMismatch = { 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. +// 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") { - switch (member.returns?.type) { + if (member.returns === undefined) return "fn:void"; // absent returns ⇒ void + switch (member.returns.type) { case "observe": return "fn:observe"; case "promise": return "fn:promise"; case "generator": return "fn:generator"; - default: return "fn:void"; // absent returns ⇒ void + 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 "${member.returns.type}" — must be observe, promise, generator, or omitted (void)`, + ); } } throw new Error( @@ -67,8 +90,11 @@ function memberKind(member: Schema): WrapKind { * idle instead of waiting for the first property access. * @returns A factory function that creates lazy service instances * - * TypeScript enforces that `schema` completely and correctly describes the loaded - * service; otherwise the `schema` argument reports a {@link SchemaMismatch}. + * TypeScript enforces that `schema` describes every member of the loaded service + * with the correct wrapper kind; otherwise the `schema` argument reports a + * {@link SchemaMismatch}. 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 @@ -90,7 +116,7 @@ function memberKind(member: Schema): WrapKind { */ export function createLazy< LoadFn extends (...args: any[]) => Promise, - const S extends Schema + const S extends LazyServiceSchema >( params: { load: LoadFn, @@ -152,7 +178,7 @@ export function createLazy< 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}`; } @@ -204,7 +230,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); @@ -241,7 +267,7 @@ 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); @@ -256,7 +282,7 @@ export function createLazy< 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) { @@ -280,7 +306,7 @@ export function createLazy< async next(): Promise> { 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); 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 From 2102952d3c0e4ad6956c1f3cc71fd416a8a71dee Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 20:41:00 -0700 Subject: [PATCH 07/15] fix(data): latch lazy generator terminal state + support async dispose The lazy AsyncGenerator wrapper could resurrect after termination: return()/throw() called before the first next() didn't latch, so a later next() would start the real generator and yield. Latch a `done` flag on return/throw/natural-completion, and add [Symbol.asyncDispose] (terminates via return) for `await using`. Red/green tested. Co-Authored-By: Claude Opus 4.8 --- .../async-data-service/create-lazy.test.ts | 69 +++++++++++++++++++ .../service/async-data-service/create-lazy.ts | 22 +++++- 2 files changed, 88 insertions(+), 3 deletions(-) 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 bfc265c4..332e799f 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 @@ -950,6 +950,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", 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", 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; 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 ee854cf6..12df714f 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -301,9 +301,12 @@ export function createLazy< // AsyncGenerator function - returns generator that waits for service lazyService[key] = (...args: any[]): AsyncGenerator => { let realGenerator: AsyncGenerator | null = null; + let done = false; - return { + 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 !== undefined && service.serviceName !== 'lazy-service') { @@ -311,10 +314,15 @@ export function createLazy< } 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); } @@ -322,6 +330,7 @@ export function createLazy< }, async throw(e: any): Promise> { + done = true; if (realGenerator) { return realGenerator.throw(e); } @@ -330,8 +339,15 @@ export function createLazy< [Symbol.asyncIterator]() { return this; - } + }, + + // Explicit resource management: `await using` disposes by terminating. + async [Symbol.asyncDispose](): Promise { + await gen.return(undefined); + }, } as AsyncGenerator; + + return gen; }; } } From 781834b2f85c699722e79dc7727b65e9508eaf87 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:14:54 -0700 Subject: [PATCH 08/15] feat(data): allow optional schema slot on Service for runtime introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-add `schema?: Schema` to the base Service so a factory may attach a service's authored schema to the instance (runtime introspection), excluded from IsValid like `serviceName`. Fix both IsValidProperty variants (async-data-service and ui-service) to exclude base-Service metadata keys when recursing into nested objects — otherwise validating a nested `Service & {...}` recurses into the self-referential Schema and errors. createLazy exposes the schema on the lazy instance before load. Co-Authored-By: Claude Opus 4.8 --- .../async-data-service/create-lazy.test.ts | 31 +++++++++++++++++++ .../service/async-data-service/create-lazy.ts | 4 ++- .../service/async-data-service/is-valid.ts | 9 ++++-- packages/data/src/service/service.ts | 14 ++++++--- .../data/src/service/ui-service/is-valid.ts | 7 +++-- 5 files changed, 55 insertions(+), 10 deletions(-) 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 332e799f..aab616c1 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 @@ -364,6 +364,37 @@ const errorMalformedReturns = 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; 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 12df714f..84c22a6b 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -163,9 +163,11 @@ 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 member based on the strategy derived from its schema 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/service.ts b/packages/data/src/service/service.ts index 5cd6c756..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. @@ -12,11 +14,15 @@ * - 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). Schemas are sideloaded — published - * beside the service (e.g. `MyService.schema`) and provided explicitly where - * needed — never attached to the service instance. See - * `async-data-service/is-valid-with-*-schema.ts`. + * 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; From 8e3718583ca9addcfc18898fe6b3d41da49f5f65 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:22:57 -0700 Subject: [PATCH 09/15] feat(data): add external invocation policy to function schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive optional `external?: { agent?; link? }` on the function schema node, read at runtime to gate invocation from untrusted channels. Metadata only — Schema.ToType ignores it. Channels have opposite default polarity: `link` (deeplink/URL) is a default-deny whitelist; `agent` is a default-allow blacklist. resolveExternalInvocation() is the single source of truth for that polarity. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/schema/external.test.ts | 35 +++++++++++++++++++++++ packages/data/src/schema/external.ts | 26 +++++++++++++++++ packages/data/src/schema/public.ts | 1 + packages/data/src/schema/schema.ts | 10 +++++++ packages/data/src/schema/to-type.ts | 7 +++++ 5 files changed, 79 insertions(+) create mode 100644 packages/data/src/schema/external.test.ts create mode 100644 packages/data/src/schema/external.ts diff --git a/packages/data/src/schema/external.test.ts b/packages/data/src/schema/external.test.ts new file mode 100644 index 00000000..d8dea29c --- /dev/null +++ b/packages/data/src/schema/external.test.ts @@ -0,0 +1,35 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. + +import { describe, it, expect } from "vitest"; +import { resolveExternalInvocation } from "./external.js"; +import type { Schema } from "./schema.js"; + +const fn = (external?: Schema["external"]): Schema => ({ + type: "function", + 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/external.ts b/packages/data/src/schema/external.ts new file mode 100644 index 00000000..eb202837 --- /dev/null +++ b/packages/data/src/schema/external.ts @@ -0,0 +1,26 @@ +// © 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 } { + return { + link: schema.external?.link === true, + agent: schema.external?.agent !== false, + }; +} diff --git a/packages/data/src/schema/public.ts b/packages/data/src/schema/public.ts index 7a80bf6a..796248c8 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 "./external.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/schema.ts b/packages/data/src/schema/schema.ts index f2435e71..159808d2 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -74,6 +74,16 @@ export interface Schema { // absent `returns` ⇒ void. parameters?: readonly Schema[]; returns?: Schema; + // Invocation policy for a `function` schema, read at runtime by an executor + // that may invoke it from an untrusted channel. Metadata only — `Schema.ToType` + // ignores it, so it never affects the derived function type. The two channels + // have DELIBERATELY OPPOSITE default polarity; resolve with the `External` + // accessors rather than re-deriving per call site (see `external.ts`): + // - `link` (deeplink / URL — least trusted, attacker-craftable): default-DENY + // whitelist. Invocable from a link only when `link === true`. + // - `agent` (acting on the user's behalf — more trusted): default-ALLOW + // blacklist. Invocable by an agent unless `agent === false`. + 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 0da0d01e..0a5847c0 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -331,6 +331,13 @@ type CheckFunctionVoid = True void>>; type TestFunctionNoParams = ToType<{ type: 'function' }>; // () => void type CheckFunctionNoParams = True void>>; +// `external` invocation-policy metadata never affects the derived function type. +type TestFunctionExternalIgnored = ToType<{ + type: 'function', 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<{ From b6cdeecd7164d5c871662af523de5fb6f3bd9ea7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:25:55 -0700 Subject: [PATCH 10/15] docs(data): clarify the function schema `external` invocation-policy JSDoc Co-Authored-By: Claude Opus 4.8 --- packages/data/src/schema/schema.ts | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index 159808d2..c5c6a379 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -74,15 +74,25 @@ export interface Schema { // absent `returns` ⇒ void. parameters?: readonly Schema[]; returns?: Schema; - // Invocation policy for a `function` schema, read at runtime by an executor - // that may invoke it from an untrusted channel. Metadata only — `Schema.ToType` - // ignores it, so it never affects the derived function type. The two channels - // have DELIBERATELY OPPOSITE default polarity; resolve with the `External` - // accessors rather than re-deriving per call site (see `external.ts`): - // - `link` (deeplink / URL — least trusted, attacker-craftable): default-DENY - // whitelist. Invocable from a link only when `link === true`. - // - `agent` (acting on the user's behalf — more trusted): default-ALLOW - // blacklist. Invocable by an agent unless `agent === false`. + /** + * Invocation policy for a `function` schema — who may call it from an + * **untrusted channel**. Read at runtime by the executor that performs the + * invocation; it is pure metadata and does NOT affect the type produced by + * `Schema.ToType` (a function differing only in `external` derives the same + * signature), nor does it affect service-schema validation or lazy wrapping. + * + * The two channels have **deliberately opposite default polarity**, matching + * their trust level. Resolve them with `resolveExternalInvocation(schema)` + * (see `external.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`. + */ external?: { readonly agent?: boolean; readonly link?: boolean }; properties?: { readonly [key: string]: Schema }; required?: readonly string[]; From 24fb0397b32a24464ff9358e191b5e975c89b496 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:29:59 -0700 Subject: [PATCH 11/15] feat(data): export resolveExternalInvocation from @adobe/data/schema Add a flat named export (alongside the Schema namespace) so consumers can import { resolveExternalInvocation } from "@adobe/data/schema". Co-Authored-By: Claude Opus 4.8 --- packages/data/src/schema/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index f33dd1aa..a12c1a02 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -17,6 +17,7 @@ export * from "./boolean/index.js"; export { F32, I32, U32, F64 } from "../math/index.js"; export * from "./time/index.js"; export { toVertexBufferLayout, toVertexBufferLayoutForType } from "./to-vertex-buffer-layout.js"; +export { resolveExternalInvocation } from "./external.js"; export type { GPUVertexBufferLayout, GPUVertexAttributeDescriptor, GPUVertexFormat } from "./to-vertex-buffer-layout.js"; export * from "./fractional-index/fractional-index.js"; export * from "./guid/index.js"; From 1021cfe1034e7187908ba1846231c0451e84e6a3 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:36:38 -0700 Subject: [PATCH 12/15] docs(data): drop @link to non-exported SchemaMismatch in createLazy jsdoc Silences the doc-gen warning; SchemaMismatch is intentionally internal. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/service/async-data-service/create-lazy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 84c22a6b..9f1d3999 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -92,7 +92,7 @@ function memberKind(member: Schema): WrapKind { * * TypeScript enforces that `schema` describes every member of the loaded service * with the correct wrapper kind; otherwise the `schema` argument reports a - * {@link SchemaMismatch}. Note: member value/parameter schemas authored as `{}` + * `SchemaMismatch`. 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. * From 930ed3a61d712acbaeddf54c06166d7f38fec873 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 21:38:39 -0700 Subject: [PATCH 13/15] docs(data): inline createLazy mismatch marker to silence doc-gen warning The named SchemaMismatch type was referenced by createLazy's public signature, so typedoc warned it wasn't documented. Inline the marker object (keeping the __createLazyError message) so there's no named symbol to warn about, without exporting an internal type. Enforcement unchanged (46 createLazy tests pass). Co-Authored-By: Claude Opus 4.8 --- .../src/service/async-data-service/create-lazy.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) 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 9f1d3999..8b37f831 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -23,12 +23,6 @@ type InferArgs = ? A : never; -// Surfaced on the `schema` argument when it does not completely and correctly -// describe the loaded service, so the mismatch is caught at the call site. -type SchemaMismatch = { - readonly __createLazyError: "createLazy: schema must completely and correctly describe the loaded service"; -}; - // 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 @@ -91,8 +85,8 @@ function memberKind(member: Schema): WrapKind { * @returns A factory function that creates lazy service instances * * TypeScript enforces that `schema` describes every member of the loaded service - * with the correct wrapper kind; otherwise the `schema` argument reports a - * `SchemaMismatch`. Note: member value/parameter schemas authored as `{}` + * 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. * @@ -124,7 +118,8 @@ export function createLazy< preload?: boolean } & (IsValidWithCompleteSchema, S> extends true ? unknown - : { schema: SchemaMismatch }) + // 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 { From 08441dd9368eb67af34e7c389974f266017e9cef Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 22:00:24 -0700 Subject: [PATCH 14/15] refactor(data): conform external resolver to the namespace file/export standard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename external.ts -> resolve-external-invocation.ts so the filename maps deterministically to its single export (do-bar.ts -> doBar), and drop the non-standard flat re-export from schema/index.ts — reach it via the Schema namespace (Schema.resolveExternalInvocation), per data-ai global/namespace.md. Co-Authored-By: Claude Opus 4.8 --- packages/data/src/schema/index.ts | 1 - packages/data/src/schema/public.ts | 2 +- .../{external.test.ts => resolve-external-invocation.test.ts} | 2 +- .../src/schema/{external.ts => resolve-external-invocation.ts} | 0 packages/data/src/schema/schema.ts | 2 +- 5 files changed, 3 insertions(+), 4 deletions(-) rename packages/data/src/schema/{external.test.ts => resolve-external-invocation.test.ts} (94%) rename packages/data/src/schema/{external.ts => resolve-external-invocation.ts} (100%) diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index a12c1a02..f33dd1aa 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -17,7 +17,6 @@ export * from "./boolean/index.js"; export { F32, I32, U32, F64 } from "../math/index.js"; export * from "./time/index.js"; export { toVertexBufferLayout, toVertexBufferLayoutForType } from "./to-vertex-buffer-layout.js"; -export { resolveExternalInvocation } from "./external.js"; export type { GPUVertexBufferLayout, GPUVertexAttributeDescriptor, GPUVertexFormat } from "./to-vertex-buffer-layout.js"; export * from "./fractional-index/fractional-index.js"; export * from "./guid/index.js"; diff --git a/packages/data/src/schema/public.ts b/packages/data/src/schema/public.ts index 796248c8..3edfe078 100644 --- a/packages/data/src/schema/public.ts +++ b/packages/data/src/schema/public.ts @@ -4,7 +4,7 @@ export * from "./schema.js"; export * from "./to-vertex-buffer-layout.js"; export * from "./nullable.js"; export * from "./to-type.js"; -export * from "./external.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/external.test.ts b/packages/data/src/schema/resolve-external-invocation.test.ts similarity index 94% rename from packages/data/src/schema/external.test.ts rename to packages/data/src/schema/resolve-external-invocation.test.ts index d8dea29c..4dee2bac 100644 --- a/packages/data/src/schema/external.test.ts +++ b/packages/data/src/schema/resolve-external-invocation.test.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it, expect } from "vitest"; -import { resolveExternalInvocation } from "./external.js"; +import { resolveExternalInvocation } from "./resolve-external-invocation.js"; import type { Schema } from "./schema.js"; const fn = (external?: Schema["external"]): Schema => ({ diff --git a/packages/data/src/schema/external.ts b/packages/data/src/schema/resolve-external-invocation.ts similarity index 100% rename from packages/data/src/schema/external.ts rename to packages/data/src/schema/resolve-external-invocation.ts diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index c5c6a379..126a0266 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -83,7 +83,7 @@ export interface Schema { * * The two channels have **deliberately opposite default polarity**, matching * their trust level. Resolve them with `resolveExternalInvocation(schema)` - * (see `external.ts`) — the single source of truth — rather than re-deriving + * (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 From 75626e0ca93d962d43ea59822845ce2d82675ea7 Mon Sep 17 00:00:00 2001 From: Kris Nye Date: Wed, 2 Sep 2026 22:13:53 -0700 Subject: [PATCH 15/15] refactor(data): group function schema fields under a nested signature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move parameters/returns/external off the flat Schema and under `{ type: "function", signature: { parameters, returns, external } }`, so these members live only on function schemas. Schema.ToType reads signature.parameters/ returns; resolveExternalInvocation reads signature.external; createLazy's memberKind + LazyMemberSchema read signature.returns. Migrated all function-schema literals in tests, example, and docs. Absent signature ⇒ () => void. Co-Authored-By: Claude Opus 4.8 --- .../resolve-external-invocation.test.ts | 7 +- .../src/schema/resolve-external-invocation.ts | 5 +- packages/data/src/schema/schema.ts | 53 +++++---- packages/data/src/schema/to-type.ts | 47 ++++---- .../src/service/async-data-service/README.md | 2 +- .../service/async-data-service/create-lazy.md | 22 ++-- .../async-data-service/create-lazy.test.ts | 102 +++++++++--------- .../service/async-data-service/create-lazy.ts | 9 +- .../src/service/async-data-service/example.ts | 8 +- .../is-valid-with-complete-schema.ts | 12 ++- 10 files changed, 144 insertions(+), 123 deletions(-) diff --git a/packages/data/src/schema/resolve-external-invocation.test.ts b/packages/data/src/schema/resolve-external-invocation.test.ts index 4dee2bac..bd82c3ff 100644 --- a/packages/data/src/schema/resolve-external-invocation.test.ts +++ b/packages/data/src/schema/resolve-external-invocation.test.ts @@ -4,10 +4,11 @@ import { describe, it, expect } from "vitest"; import { resolveExternalInvocation } from "./resolve-external-invocation.js"; import type { Schema } from "./schema.js"; -const fn = (external?: Schema["external"]): Schema => ({ +type External = NonNullable["external"]; + +const fn = (external?: External): Schema => ({ type: "function", - parameters: [], - ...(external ? { external } : {}), + signature: { parameters: [], ...(external ? { external } : {}) }, }); describe("resolveExternalInvocation", () => { diff --git a/packages/data/src/schema/resolve-external-invocation.ts b/packages/data/src/schema/resolve-external-invocation.ts index eb202837..771aaf67 100644 --- a/packages/data/src/schema/resolve-external-invocation.ts +++ b/packages/data/src/schema/resolve-external-invocation.ts @@ -19,8 +19,9 @@ 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; return { - link: schema.external?.link === true, - agent: schema.external?.agent !== false, + 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 126a0266..0c9240f9 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -69,31 +69,38 @@ export interface Schema { // The wrapped value type for the `observe`/`promise`/`generator` constructors: // `{ type: "observe", value: S }` → `Observe>`, etc. Absent ⇒ any. value?: Schema; - // Signature of the `function` constructor: `{ type: "function", parameters, - // returns }` → `(...args) => ToType`. Absent `parameters` ⇒ no args; - // absent `returns` ⇒ void. - parameters?: readonly Schema[]; - returns?: Schema; /** - * Invocation policy for a `function` schema — who may call it from an - * **untrusted channel**. Read at runtime by the executor that performs the - * invocation; it is pure metadata and does NOT affect the type produced by - * `Schema.ToType` (a function differing only in `external` derives the same - * signature), nor does it affect 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`. + * 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`. */ - external?: { readonly agent?: boolean; readonly link?: boolean }; + 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 0a5847c0..66a3cbc6 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -59,21 +59,24 @@ type Decrement = ((...x: any[]) => void) extends ( // The wrapped value schema for observe/promise/generator; absent ⇒ any. type ValueSchema = T extends { value: infer V } ? V : {}; -// The function-constructor mapping: parameters → positional args, returns → result. +// The function-constructor mapping: `signature.parameters` → positional args, +// `signature.returns` → result. Absent `signature` ⇒ `() => void`. type FromSchemaFunction = - T extends { parameters: infer P } - ? P extends readonly Schema[] - ? (...args: FromSchemaArgs) => FromSchemaReturns - : never - : (...args: []) => FromSchemaReturns; + 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 = - T extends { returns: infer R } ? R extends Schema ? ToType : void : void; +type FromSchemaReturns = + Sig extends { returns: infer R } ? R extends Schema ? ToType : void : void; type FromSchemaArray = T extends { items: infer Items; @@ -321,20 +324,22 @@ type CheckGenerator = True; // (a: number, b: string) => boolean type CheckFunction = True boolean>>; -type TestFunctionVoid = ToType<{ type: 'function', parameters: [] }>; // () => void +type TestFunctionVoid = ToType<{ type: 'function', signature: { parameters: [] } }>; // () => void type CheckFunctionVoid = True void>>; -type TestFunctionNoParams = ToType<{ type: 'function' }>; // () => 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', parameters: [{ type: 'number' }], returns: { type: 'promise', value: { type: 'number' } }, - external: { link: true, agent: false } + type: 'function', signature: { + parameters: [{ type: 'number' }], returns: { type: 'promise', value: { type: 'number' } }, + external: { link: true, agent: false } + } }>; // (a: number) => Promise type CheckFunctionExternalIgnored = True Promise>>; @@ -342,13 +347,15 @@ type CheckFunctionExternalIgnored = True; // (arg: { readonly chunks: AsyncGenerator }) => Promise type CheckStreamingAction = True` property -- `{ type: "function", returns: { type: "observe", … } }` → a function returning `Observe` -- `{ type: "function", returns: { type: "generator", … } }` → a function returning `AsyncGenerator` -- `{ type: "function", returns: { type: "promise", … } }` → a function returning `Promise` -- `{ type: "function" }` (no `returns`) → a function returning `void` +- `{ 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` -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 `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` (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 @@ -77,7 +77,7 @@ namespace AuthService { properties: { isSignedIn: { type: "observe", value: {} }, accessToken: { type: "observe", value: {} }, - signIn: { type: "function", parameters: [{}], returns: { type: "promise" } }, + signIn: { type: "function", signature: { parameters: [{}], returns: { type: "promise" } } }, signOut: { type: "function" }, }, required: ["isSignedIn", "accessToken", "signIn", "signOut"], @@ -108,7 +108,7 @@ namespace ConfigService { type: "object", properties: { config: { type: "observe", value: {} }, - fetch: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, }, required: ["config", "fetch"], additionalProperties: false, @@ -143,9 +143,9 @@ namespace ComplexService { type: "object", properties: { status: { type: "observe", value: {} }, - selectById: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, - streamEvents: { type: "function", returns: { type: "generator", value: {} } }, - fetchData: { type: "function", returns: { type: "promise", 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"], @@ -169,7 +169,7 @@ AsyncDataService.createLazy({ load: () => import('./auth').then(m => m.create()), schema: { type: "object", - properties: { isSignedIn: { type: "observe", value: {} }, signIn: { type: "function", parameters: [{}], returns: { type: "promise" } } }, + 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`) 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 aab616c1..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 @@ -125,11 +125,11 @@ const validAuth = createLazy({ isSignedIn: { type: "observe", value: {} }, accessToken: { type: "observe", value: {} }, userProfile: { type: "observe", value: {} }, - showSignInDialog: { type: "function", parameters: [] }, - hideSignInDialog: { type: "function", parameters: [] }, - refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, - signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, - signOut: { type: "function", parameters: [], returns: { type: "promise", 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 @@ -143,8 +143,8 @@ const validObserveFn = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, - fetchData: { type: "function", parameters: [], returns: { type: "promise", 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 @@ -158,8 +158,8 @@ const validGenerator = createLazy({ type: "object", properties: { status: { type: "observe", value: {} }, - streamEvents: { type: "function", parameters: [], returns: { type: "generator", value: {} } }, - cancel: { type: "function", parameters: [] } + streamEvents: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } }, + cancel: { type: "function", signature: { parameters: [] } } }, required: ["status", "streamEvents", "cancel"], additionalProperties: false @@ -182,7 +182,7 @@ const validWithArgs = createLazy({ type: "object", properties: { config: { type: "observe", value: {} }, - fetch: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } + fetch: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["config", "fetch"], additionalProperties: false @@ -196,8 +196,8 @@ const validWithPreload = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, - fetchData: { type: "function", parameters: [], returns: { type: "promise", 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 @@ -219,11 +219,11 @@ const errorMissing = createLazy({ isSignedIn: { type: "observe", value: {} }, accessToken: { type: "observe", value: {} }, userProfile: { type: "observe", value: {} }, - showSignInDialog: { type: "function", parameters: [] }, - hideSignInDialog: { type: "function", parameters: [] }, + showSignInDialog: { type: "function", signature: { parameters: [] } }, + hideSignInDialog: { type: "function", signature: { parameters: [] } }, // Missing: refreshToken - signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, - signOut: { type: "function", 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", "signIn", "signOut"], additionalProperties: false @@ -237,14 +237,14 @@ const errorWrongType1 = createLazy({ schema: { type: "object", properties: { - isSignedIn: { type: "function", parameters: [], returns: { type: "observe", value: {} } }, // WRONG: should be observe + isSignedIn: { type: "function", signature: { parameters: [], returns: { type: "observe", value: {} } } }, // WRONG: should be observe accessToken: { type: "observe", value: {} }, userProfile: { type: "observe", value: {} }, - showSignInDialog: { type: "function", parameters: [] }, - hideSignInDialog: { type: "function", parameters: [] }, - refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, - signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, - signOut: { type: "function", parameters: [], returns: { type: "promise", 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 @@ -261,11 +261,11 @@ const errorWrongType2 = createLazy({ isSignedIn: { type: "observe", value: {} }, accessToken: { type: "observe", value: {} }, userProfile: { type: "observe", value: {} }, - showSignInDialog: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, // WRONG: should be void - hideSignInDialog: { type: "function", parameters: [] }, - refreshToken: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, - signIn: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, - signOut: { type: "function", parameters: [], returns: { type: "promise", 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 @@ -280,8 +280,8 @@ const errorExtra = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, - fetchData: { type: "function", parameters: [], returns: { type: "promise", 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"], @@ -298,7 +298,7 @@ const errorObserveFn = createLazy({ properties: { allUsers: { type: "observe", value: {} }, selectUser: { type: "observe", value: {} }, // WRONG: should be a function returning observe - fetchData: { type: "function", parameters: [], returns: { type: "promise", value: {} } } + fetchData: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } } }, required: ["allUsers", "selectUser", "fetchData"], additionalProperties: false @@ -313,8 +313,8 @@ const errorGenerator = createLazy({ type: "object", properties: { status: { type: "observe", value: {} }, - streamEvents: { type: "function", parameters: [], returns: { type: "promise", value: {} } }, // WRONG: should be generator - cancel: { type: "function", parameters: [] } + streamEvents: { type: "function", signature: { parameters: [], returns: { type: "promise", value: {} } } }, // WRONG: should be generator + cancel: { type: "function", signature: { parameters: [] } } }, required: ["status", "streamEvents", "cancel"], additionalProperties: false @@ -350,9 +350,9 @@ const errorMalformedReturns = createLazy({ type: "object", properties: { allUsers: { type: "observe", value: {} }, - selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, + selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } }, // @ts-expect-error - `returns` has no recognized type-constructor - fetchData: { type: "function", parameters: [], returns: { value: {} } }, + fetchData: { type: "function", signature: { parameters: [], returns: { value: {} } } }, }, required: ["allUsers", "selectUser", "fetchData"], additionalProperties: false @@ -538,7 +538,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -581,7 +581,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -626,7 +626,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -671,7 +671,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -702,7 +702,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { fetchData: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } } }, + properties: { fetchData: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } } }, required: ["fetchData"], additionalProperties: false } @@ -751,7 +751,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { track: { type: "function", parameters: [{}] } }, + properties: { track: { type: "function", signature: { parameters: [{}] } } }, required: ["track"], additionalProperties: false } @@ -792,7 +792,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -834,7 +834,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -868,7 +868,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -910,7 +910,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { selectUser: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } } }, + properties: { selectUser: { type: "function", signature: { parameters: [{}], returns: { type: "observe", value: {} } } } }, required: ["selectUser"], additionalProperties: false } @@ -960,7 +960,7 @@ describe('createLazy', () => { load: createTestService, schema: { type: "object", - properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -998,7 +998,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -1031,7 +1031,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -1065,7 +1065,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { streamData: { type: "function", parameters: [], returns: { type: "generator", value: {} } } }, + properties: { streamData: { type: "function", signature: { parameters: [], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -1099,7 +1099,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { streamData: { type: "function", parameters: [{}], returns: { type: "generator", value: {} } } }, + properties: { streamData: { type: "function", signature: { parameters: [{}], returns: { type: "generator", value: {} } } } }, required: ["streamData"], additionalProperties: false } @@ -1148,7 +1148,7 @@ describe('createLazy', () => { }), schema: { type: "object", - properties: { track: { type: "function", parameters: [{}] } }, + properties: { track: { type: "function", signature: { parameters: [{}] } } }, required: ["track"], additionalProperties: false } @@ -1199,7 +1199,7 @@ describe('createLazy preload option', () => { const trackSchema = { type: "object", - properties: { track: { type: "function", parameters: [{}] } }, + properties: { track: { type: "function", signature: { parameters: [{}] } } }, required: ["track"], additionalProperties: false } as const; 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 8b37f831..d029b413 100644 --- a/packages/data/src/service/async-data-service/create-lazy.ts +++ b/packages/data/src/service/async-data-service/create-lazy.ts @@ -32,7 +32,7 @@ type InferArgs = // runtime `memberKind` dispatch to disagree with the type-level check. type LazyMemberSchema = | { readonly type: "observe" } - | { readonly type: "function"; readonly returns?: { readonly type: "observe" | "promise" | "generator" } }; + | { readonly type: "function"; readonly signature?: { readonly returns?: { readonly type: "observe" | "promise" | "generator" } } }; type LazyServiceSchema = Schema & { readonly properties?: { readonly [name: string]: LazyMemberSchema }; @@ -51,8 +51,9 @@ type WrapKind = "observe" | "fn:observe" | "fn:promise" | "fn:generator" | "fn:v function memberKind(member: Schema): WrapKind { if (member.type === "observe") return "observe"; if (member.type === "function") { - if (member.returns === undefined) return "fn:void"; // absent returns ⇒ void - switch (member.returns.type) { + 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"; @@ -60,7 +61,7 @@ function memberKind(member: Schema): WrapKind { // 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 "${member.returns.type}" — must be observe, promise, generator, or omitted (void)`, + `createLazy: unsupported function returns schema type "${returns.type}" — must be observe, promise, generator, or omitted (void)`, ); } } diff --git a/packages/data/src/service/async-data-service/example.ts b/packages/data/src/service/async-data-service/example.ts index e2c21a7a..9875c8b9 100644 --- a/packages/data/src/service/async-data-service/example.ts +++ b/packages/data/src/service/async-data-service/example.ts @@ -53,9 +53,9 @@ namespace UserService { properties: { currentUser: { type: "observe", value: {} }, allUsers: { type: "observe", value: {} }, - selectUserById: { type: "function", parameters: [{}], returns: { type: "observe", value: {} } }, - fetchUser: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, - updateUser: { type: "function", parameters: [{}, {}], returns: { type: "promise" } }, + 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"], @@ -106,7 +106,7 @@ namespace ConfigurableUserService { properties: { config: { type: "observe", value: {} }, currentUser: { type: "observe", value: {} }, - fetchUser: { type: "function", parameters: [{}], returns: { type: "promise", value: {} } }, + fetchUser: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: {} } } }, }, required: ["config", "currentUser", "fetchUser"], additionalProperties: false, diff --git a/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts index fafdabe2..6a44e314 100644 --- a/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts +++ b/packages/data/src/service/async-data-service/is-valid-with-complete-schema.ts @@ -33,8 +33,10 @@ const _accountSchema = { balance: { type: "observe", value: { type: "number" }, description: "current balance" }, deposit: { type: "function", - parameters: [{ type: "number" }], - returns: { type: "promise", value: { type: "number" } }, + signature: { + parameters: [{ type: "number" }], + returns: { type: "promise", value: { type: "number" } }, + }, description: "add funds; resolves to the new balance", }, }, @@ -71,8 +73,10 @@ const _assetSchema = { thumbnail: { type: "observe", value: { type: "blob" }, description: "current thumbnail" }, download: { type: "function", - parameters: [{ type: "string" }], - returns: { type: "promise", value: { type: "blob" } }, + signature: { + parameters: [{ type: "string" }], + returns: { type: "promise", value: { type: "blob" } }, + }, description: "download an asset by id", }, },