Skip to content

feat(data): schema type-constructors + sideloaded service schemas driving createLazy - #193

Merged
krisnye merged 15 commits into
mainfrom
krisnye/service-descriptor
Sep 3, 2026
Merged

feat(data): schema type-constructors + sideloaded service schemas driving createLazy#193
krisnye merged 15 commits into
mainfrom
krisnye/service-descriptor

Conversation

@krisnye

@krisnye krisnye commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

Description

Teaches Schema to describe data-adjacent types (reactive values, async values, streams, callables), so a service's whole surface can be described by one plain, serializable Schema. Service schemas are sideloaded — published beside the service and passed explicitly — and now drive AsyncDataService.createLazy.

This started as a bespoke ServiceDescriptor (states/actions/services buckets) and collapsed, over the course of the work, into "a service is just an object Schema" once Schema.ToType learned to express these types. The bespoke descriptor, its ToService transform, and the Service.descriptor instance slot were all removed.

1. Schema type-constructors (schema.ts, to-type.ts)

Four new, nestable type constructors, mapped by Schema.ToType:

Schema ToType
{ type: "observe", value: S } Observe<ToType<S>>
{ type: "promise", value: S } Promise<ToType<S>>
{ type: "generator", value: S } AsyncGenerator<ToType<S>>
{ type: "function", signature: { parameters: S[], returns?: S } } (...args) => ToType<returns> (absent returns ⇒ void; absent signature() => void; absent value ⇒ any)

They compose and nest (e.g. an AsyncGenerator<Data> parameter inside an object argument), which the plain-Data-only argument model could not express. Like the existing blob/typed-buffer types, they are confined by usage — they belong in service schemas, not ECS component / typed-buffer schemas (there is deliberately no DataSchema split: Schema already produces non-immutable / non-Data types such as TypedBuffer, and consumers already handle-or-throw per context).

The function constructor's members (parameters, returns, and the invocation-policy external) are grouped under a nested signature so they live only on function schemas rather than on every Schema node.

signature.external?: { agent?: boolean; link?: boolean } is optional invocation-policy metadata — who may invoke the function from an untrusted channel — with deliberately opposite default polarity: link (deeplink/URL, least trusted) is a default-deny whitelist (invocable only when link === true); agent (acting on the user's behalf) is a default-allow blacklist (invocable unless agent === false). It is metadata only: Schema.ToType ignores it (a function differing only in external derives the same signature), and it does not interact with the schema validators or createLazy. resolveExternalInvocation(schema) (reached as Schema.resolveExternalInvocation) is the single source of truth for resolving that polarity — call sites must not re-derive it.

2. Sideloaded service schemas (service.ts, is-valid-with-*-schema.ts)

  • A "service descriptor" is now just a Schema; ToService is just Schema.ToType.
  • Schemas are authored beside the service (MyService.schema via the namespace pattern) and passed explicitly. The base Service also carries an optional schema?: Schema slot so a factory may attach it to the instance for runtime introspection — excluded from IsValid like serviceName (both IsValidProperty variants now skip base-Service metadata keys when recursing into nested objects, so validating a nested Service & {…} doesn't recurse into the self-referential Schema).
  • Two opt-in validators (each in its own file), taking the schema as a sideloaded type arg:
    • IsValidWithPartialSchema<T, S> — service members INCLUDE everything S describes (subset).
    • IsValidWithCompleteSchema<T, S> — service members are EXACTLY what S describes.

3. createLazy driven by a schema (create-lazy.ts)

createLazy now takes { load, schema, preload? }. Each member's runtime wrapper is derived from its schema (observe, or function classified by returns). The gate is schema-match onlyEquivalentTypes<Schema.ToType<S>, members>, the actual correctness condition for building the wrappers — and is deliberately decoupled from IsValid: a service that legitimately returns non-Data (e.g. a fetch(): Promise<Response> port) can still be lazily chunk-loaded, while async-data-service conformance stays a separate definition-site concern. A LazyMemberSchema constraint rejects unsupported members (nested-object, malformed returns) at compile time rather than at runtime, so the type-level gate and the runtime dispatch cannot disagree.

Drive-by fixes

  • Schema.ToType blob mapping. { type: "blob" } resolved to any (its test passed only vacuously); it now resolves to Blob, guarded by a regression test.
  • Bump only publishable packages. pnpm run bump now runs scripts/bump.mjs, which bumps the root anchor + every publishable package (private !== true) and skips private sample/app packages, keyed off the same private field that governs pnpm -r publish. Removes the per-PR churn of ~9 unpublished package.jsons.
  • Lazy generator correctness. The lazy AsyncGenerator wrapper could resurrect after termination (return()/throw() before the first next() didn't latch); it now latches a terminal state and supports [Symbol.asyncDispose].

Test evidence

  • pnpm run lint — clean. pnpm run typecheck (all caches cleared, whole monorepo) — exit 0, 0 errors. check:workspace — OK.
  • Type-level (colocated True/False/EquivalentTypes + @ts-expect-error): every constructor incl. the nested-AsyncGenerator-in-object-arg driver; the Blob-as-Blob-not-any regression guard; partial/complete schema matching; createLazy inference + the compile-time rejection of nested-object / malformed-returns members.
  • Runtime (create-lazy.test.ts, vitest): 46 passed — all wrapper kinds, the generator terminal-state / async-dispose red→green tests, and the lazy instance exposing its schema before load; full src/service suite 120 passed.
  • Load-bearing checks verified by deliberate breaks (produced TS2344); @ts-expect-error directives all consumed.
  • Post-review: a fable subagent reviewed the diff; its two MAJOR findings (gate/runtime disagreement on unrecognized returns and on nested-object members) are fixed and covered by new compile-time tests, along with the minor/nit findings.

Related PRs

None.

Jira ticket

N/A — exploratory addition to @adobe/data.

Checklist

  • Code compiles and type-checks (pnpm run typecheck — exit 0).
  • Lint passes (pnpm run lint — clean); pnpm run check:workspace — OK.
  • Tests added/updated — colocated compile-time tests + create-lazy.test.ts (44 passing, incl. red→green generator tests).
  • Version bumped (publishable packages → v0.10.8; 0.10.7 was already published, so the continued work targets 0.10.8; private samples intentionally not bumped, see scripts/bump.mjs).
  • Docs/comments — JSDoc on new types; README.md + create-lazy.md migrated to the schema API; removed a false IsDataService backwards-compat note.
  • Backwards compatible where it can be — new schema constructors are additive; createLazy's input changed from properties to schema (pre-1.0), all callers migrated.

krisnye and others added 3 commits September 1, 2026 21:30
…ptor validation

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…a.ToType blob → Blob

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
Comment thread packages/data/src/service/service.ts Outdated
* is absent; an observe factory (`bar(args): Observe`) when present.
*/
export interface StateDescriptor {
readonly schema: Schema;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Building on the note I sent yesterday — this is the spot where dynamic schemas would land. Today schema (here, and parameters on ActionDescriptor) is static, but for agent tools the valid inputs often depend on live state: which models a user is entitled to, or options that change once a model is picked. Could the descriptor let a schema be either static or dynamic — e.g. also accepting an Observe<Schema> (Schema is Data, so it still round-trips) — so both the static shape and the runtime-narrowed one are first-class? Happy to be the first to exercise it on our side.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concerning the dynamic schema. The services only expose static typed api endpoints. Those static endpoints can provide observability and invocation of dynamic things, naturally, but they can't expose statically typed constraints on each.

The service descriptor is merely a runtime description of the strongly typed service interface. So it also cannot provide dynamic schema.

That said, you can still support dynamic interactions, just like how you can today across the strongly typed service interfaces.

@krisnye
krisnye requested a review from kunalkindra September 2, 2026 18:32
Comment thread packages/data/src/service/service.ts Outdated
/** A callable invoked for its effect or async result. */
export interface ActionDescriptor {
readonly parameters: readonly Schema[];
readonly result: "promise" | "generator" | "void";

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed offline. We don't need yet, but may want a way to indicate expected max duration of a promise or async generator.

krisnye and others added 4 commits September 2, 2026 20:19
…uctors

Extend Schema + Schema.ToType to express data-adjacent types so a schema can
describe a service surface, not just data. Nestable (e.g. AsyncGenerator<Data>
as a function parameter). Confined by usage, like blob/typed-buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
- 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@krisnye krisnye changed the title feat(data): serializable Service.Descriptor + compile-time descriptor validation feat(data): schema type-constructors + sideloaded service schemas driving createLazy Sep 3, 2026
krisnye and others added 8 commits September 2, 2026 21:14
…ection

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…JSDoc

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…sdoc

Silences the doc-gen warning; SchemaMismatch is intentionally internal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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 <noreply@anthropic.com>
…t standard

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@krisnye
krisnye merged commit 8ee695e into main Sep 3, 2026
3 checks passed
@krisnye
krisnye deleted the krisnye/service-descriptor branch September 3, 2026 15:17
krisnye added a commit that referenced this pull request Sep 3, 2026
…(TS2321)

#193 inserted the observe/promise/generator/function branches BEFORE the data
branches in Schema.ToType, so every data-schema ToType (run per component by
FromSchemas across a plugin combine chain) evaluated 4 extra conditionals deeper.
On a ~5-deep chain already near TS's stack-depth ceiling that tipped it into
TS2321 (excessive stack depth). Move the constructor branches AFTER the data
branches so data schemas return to the pre-#193 nesting depth; only service
schemas (never in the Plugin hot path) fall through. Verified: fixes a downstream
deep-plugin consumer's TS2321 with no behavior change (to-type tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
krisnye added a commit that referenced this pull request Sep 3, 2026
…azy gate from IsValid (#195)

* feat(data): decouple createLazy gate from IsValid (schema-match only)

createLazy now gates on whether the schema completely and correctly describes the
service's members (EquivalentTypes<Schema.ToType<S>, members>) — the actual
correctness condition for building the wrappers — and no longer requires the
service to be a valid AsyncDataService. This lets services that legitimately
return non-Data (e.g. fetch(): Promise<Response>) be lazily chunk-loaded, while
IsValid stays a separate definition-site concern. Collapses the two failure modes
into one clear schema-mismatch error.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(data): bump publishable packages to 0.10.8

0.10.7 was already published; bump the continued work (schema type-constructors,
sideloaded schemas, schema-driven createLazy, external policy) to 0.10.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(data): move ToType type-constructor branches after data branches (TS2321)

#193 inserted the observe/promise/generator/function branches BEFORE the data
branches in Schema.ToType, so every data-schema ToType (run per component by
FromSchemas across a plugin combine chain) evaluated 4 extra conditionals deeper.
On a ~5-deep chain already near TS's stack-depth ceiling that tipped it into
TS2321 (excessive stack depth). Move the constructor branches AFTER the data
branches so data schemas return to the pre-#193 nesting depth; only service
schemas (never in the Plugin hot path) fall through. Verified: fixes a downstream
deep-plugin consumer's TS2321 with no behavior change (to-type tests pass).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants