diff --git a/package.json b/package.json index ec8b7451b..323c628ac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "data-monorepo", - "version": "0.10.23", + "version": "0.10.24", "private": true, "engines": { "node": ">=24" diff --git a/packages/data-ai/.claude-plugin/plugin.json b/packages/data-ai/.claude-plugin/plugin.json index 83848c768..1e0ab986a 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.23", + "version": "0.10.24", "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/.claude/rules/README.md b/packages/data-ai/.claude/rules/README.md index 8f2d37011..4f9e9cd89 100644 --- a/packages/data-ai/.claude/rules/README.md +++ b/packages/data-ai/.claude/rules/README.md @@ -26,6 +26,11 @@ features/ core.md indexes.md transactions.md computed.md services.md actions.md systems.md conformance.md ui/ # element / presentation / lazy-wrapper rules + data/state.md # LEGACY state-based layout (existing features only) + services/main-service/ + index.md core-database.md components.md resources.md archetypes.md + indexes.md transactions.md computed.md services.md actions.md + systems.md conformance.md app.md # src/app/: the composition root ``` @@ -34,9 +39,9 @@ etc.) apply everywhere, by design. Alongside `features/`, the remaining rules-ro `.md` files hold **cross-cutting** patterns the feature rules reference — `app.md`, `data-modelling.md`, `archetypes.md` (the query and iteration model), `plugin-modelling.md`, `versioning.md`, and the UI rules `element.md` and -`lazy-element.md` (`features/ui/` holds `presentation.md` and the binding-element and -lazy-wrapper rules). They live here so the bundle is -self-contained; this repo symlinks them into its own `.claude/rules/`. +`lazy-element.md` (`features/ui/` holds `presentation.md` and the lazy-wrapper rule). +They live here so the bundle is self-contained; this repo symlinks them into its own +`.claude/rules/`. Each feature rule's `paths:` glob is scoped to `**/features/*//…`, so these rules apply once an application organizes its source under `features//`. diff --git a/packages/data-ai/.claude/rules/ecs.md b/packages/data-ai/.claude/rules/ecs.md index 3e47be416..9664cb26f 100644 --- a/packages/data-ai/.claude/rules/ecs.md +++ b/packages/data-ai/.claude/rules/ecs.md @@ -150,7 +150,7 @@ Synchronous, deterministic atomic mutations. Receive `store` and a payload. ### actions -UI components that call actions must never consume returned values — see `features/ui/binding-element.md` (Actions are fire-and-forget). +UI components that call actions must never consume returned values — see `element.md` (Actions are fire-and-forget). **At most one transaction per action.** Multiple transactions in a single action corrupt the undo/redo stack. diff --git a/packages/data-ai/.claude/rules/element.md b/packages/data-ai/.claude/rules/element.md index 1700bc9ad..40437e7a0 100644 --- a/packages/data-ai/.claude/rules/element.md +++ b/packages/data-ai/.claude/rules/element.md @@ -106,6 +106,15 @@ Actions are fire-and-forget. If a caller needs a result, that's a transaction the binding invokes directly, *or* a new observable to subscribe to — never a return value awaited in render. +## Presentation contract + +- The element imports its sibling `*-presentation.ts`, which exports exactly `render` + (consumers derive props with `Parameters[0]`). +- Callback props are named as a verb or verbNoun (`toggleComplete`), never `on*`; the + element's variable matches the prop name. +- Cleanup on unmount goes through a hook (`useEffect(() => () => …, [])`), not a + lifecycle override. + ## Deletion test Strip the file to its essentials. What remains should be: optional diff --git a/packages/data-ai/.claude/rules/features/data/components.md b/packages/data-ai/.claude/rules/features/data/components.md index 6ebf7df96..8154a99ed 100644 --- a/packages/data-ai/.claude/rules/features/data/components.md +++ b/packages/data-ai/.claude/rules/features/data/components.md @@ -23,7 +23,7 @@ export const todo = True.schema; export const components = { todo, name, complete, order, dragPosition }; ``` -- **Scope** is declared per file with `Scope` from `@adobe/data/ecs`: +- **Scope** is declared per file with `Scope` from `@adobe/data/schema`: | scope | shared | durable | spread | |---|:-:|:-:|---| @@ -32,6 +32,8 @@ export const components = { todo, name, complete, order, dragPosition }; | presence | yes | no | `...Scope.presence` | | session | no | no | `...Scope.session` | + `presence` is not synced yet: `@adobe/data-sync` does not replicate + `nonPersistent` data, so a presence value behaves like `session` until it does. On a component, the flags scope the **column**. An entity's own scope comes from its marker components (`entities.md`). - **Every column lives here**, including implementation-only ones (a tag, a drag @@ -39,8 +41,9 @@ export const components = { todo, name, complete, order, dragPosition }; - **A tag** is a component with `True.schema`. Add one only when an entity's component set would otherwise be contained in another's (`entities.md`). - **The name describes the role**, the schema the shape (`position` of a `Vec3`). -- **Reserved names:** `id`, `nonPersistent`, `nonShared` (built into the ECS), and - `index` (it would collide with the `index.ts` barrel). +- **Reserved names:** `id`, `nonPersistent`, `nonShared` are built into the ECS + (`Database.Plugin.create` rejects them at compile time); `index` would collide with + the `index.ts` barrel. - **A feature built on another** spreads the lower barrel first and pins that its own names don't shadow it (a spread would overwrite silently): ```ts diff --git a/packages/data-ai/.claude/rules/features/data/index.md b/packages/data-ai/.claude/rules/features/data/index.md index 47d596195..36d24ce48 100644 --- a/packages/data-ai/.claude/rules/features/data/index.md +++ b/packages/data-ai/.claude/rules/features/data/index.md @@ -5,6 +5,8 @@ paths: # data/ — the feature's pure Data declarations +> **Legacy feature** (has `data/state/` or `services/main-service/`)? Follow `data/state.md` instead. + The foundation layer. Everything here is a declaration about plain, serializable Data: JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob` (serialize with `Data.stringify` / `Data.parse`). No functions in values, no services, @@ -26,4 +28,5 @@ fields, it takes them structurally (`Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>`). Components and resources share **one namespace** across every feature in an app: -a name is one column or one resource, never both, and has one owning feature. +a name is one column or one resource, never both (the store warns on a clash, and the resource's schema replaces the component's), and +has one owning feature. diff --git a/packages/data-ai/.claude/rules/features/data/state.md b/packages/data-ai/.claude/rules/features/data/state.md new file mode 100644 index 000000000..433ed3ced --- /dev/null +++ b/packages/data-ai/.claude/rules/features/data/state.md @@ -0,0 +1,211 @@ +--- +paths: + - '**/features/*/data/state/**/*.ts' +--- + +# data/state/ — the State specification + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../index.md`. + +`State` is the whole feature as **one immutable object** — the pure, fully-tested +source of truth. Each transition is a read→write **patch** over state; each +derivation a pure selector. The ECS implementation is proven equivalent to it by +conformance (`../services/main-service/conformance.md`). Reference: +`data-lit-todo`'s `data/state/`. + +The presence of this folder makes the feature **state-based**. A feature without +`data/state/` is **ECS-based** (no spec, no conformance) — see `../index.md`. + +## The `State` shape + +```ts +// data/state/state.ts +export type State = { + readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name + readonly entities: ReadonlyMap; // ALL entities, keyed by id; the value has no id + readonly selectedTodo: Entity; // a reference singleton (Entity is a branded number) +}; +export * as State from "./public.js"; +``` + +- **Singletons** — every non-`entities` field → an ECS **resource** of the same name. +- **`entities`** — one `ReadonlyMap` holding every entity, keyed + by a numeric id; the value type is the union of the feature's entity value types and + carries **no `id`** (identity is the key). Omit `entities` for a feature with no + entities (all singletons). + +`State` may reuse plain ECS value types (the `Entity` id type, a `{ parent, order }` +bundle) but never ECS machinery (a store, a transaction, an index). Entity value types +are their own `data//` namespaces with a structural `is` guard (`../index.md`). + +Two conventional exports drive conformance, re-exported through `public.js`: +- **`create(): State`** (`create.ts`) — the default; every case's `before`/`input` is a + delta over it. +- **`samples: readonly State[]`** (`samples.ts`) — representative full states the ecs + projection round-trips (`toState ∘ fromState ≡ identity`). + +## Files: the pure function and its cases are SEPARATE + +Each conformed function is two files: + +- **`.ts`** — the pure function, a **single export**, with **zero test-tier + imports** (no `@adobe/data-testing`, no fakes). It depends only on `@adobe/data`, + other `data/` types, and — for a transition — the service interfaces it injects. +- **`.cases.ts`** — the spec-owned cases as **inert data**, imported by the + manifest. It imports the case type with `import type` and never constructs a live + service. + +This split is enforced by the package's project-reference wall (`tsconfig.build.json` +excludes `*.cases.ts`): a runtime file importing test-tier code is a **compile error**. + +### The transform — `.ts` + +```ts +// toggle-complete.ts +import type { Services } from "../../services/services.js"; +import type { State } from "./state.js"; + +// Reads entities, writes entities — an `{ entities }` patch — and logs `todoToggled`. +export const toggleComplete = ( + state: Pick, + { id, analytics }: { readonly id: number } & Pick, +): Pick => { + analytics.todoToggled(); + const target = state.entities.get(id); + if (target === undefined) return { entities: state.entities }; + return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) }; +}; +``` + +- **Transition signature** `(state: Pick, args) => Pick` — + a read→write patch. The parameter is the smallest `Pick` it reads; the return + is only the fields it writes. No ` => T` generic, no `...state` spread — return the + patch and let the runner merge it. All non-state inputs go in the single `args` object; + a transition that takes neither args nor services takes only `(state)`. +- **Inject services with `Pick`** — never an inline `{ analytics: … }`. Data + args sit alongside via intersection: `{ readonly count: number } & Pick`. +- **Guard no-ops by returning an empty/unchanged patch**, never by throwing. Reserve + `throw` for a genuine precondition violation a case names with `throws`. +- **Derivation signature** `(state) => value` — a pure selector over **two or more** + `State` fields. An entity query returns entity **ids** (`readonly number[]` / + `ReadonlySet`); look values up in `entities`. Value math over a **single** + `data/` lives on that type's namespace, not here. + +### The cases — `.cases.ts` + +```ts +// toggle-complete.cases.ts +import { Entity } from "@adobe/data/ecs"; +import type { Conformance } from "@adobe/data-testing"; +import type { State } from "./state.js"; +import type { toggleComplete } from "./toggle-complete.js"; + +export const cases: Conformance.SpecCases = { + args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args + cases: [ + { name: "marks an incomplete todo complete", + before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) }, + args: { id: 1 }, // DATA only — services are never here + after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) }, + effects: { analytics: [["todoToggled"]] } }, // service calls to assert + ], +}; +``` + +- Annotate `Conformance.SpecCases` for a transition, + `Conformance.SpecDerivations` for a derivation. That annotation is the + transition/derivation discriminator — nothing is inferred from the signature. +- **`before` is a delta over `State.create()`**; **`after` is the writes patch** (only + the fields the transition changes). A derivation case is `{ name, input, value }`. +- **`args` is data only.** Injected services are stripped from the case's `args` type; + the runner synthesizes each service's recording double from the manifest's `services` + templates. A transition with only services takes no `args`. +- **Service returns are scheduled as data in `responses`**, keyed by service then method; + only value-returning methods take a schedule (a per-call FIFO — exhausting it throws). + **Service calls to assert go in `effects`** as `[method, ...args]` tuples (`Array` = + ordered, `Set` = any order); a value-returning read you don't assert is simply omitted. + The case thus **owns both the input and the expectation** — the double invents nothing. + ```ts + // create-random-todo.cases.ts (a value-returning service) + responses: { nameGenerator: { generateName: ["random task"] }, + analytics: { randomTodoRequested: [{ startedAt: 0 }] } }, + effects: { analytics: [["randomTodoRequested"], + ["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }]] }, + ``` +- **Entity ids are plain spec-id numbers everywhere** — map keys in `before`/`after`/ + `input`, and reference fields hold the id they point at (`{ selectedTodo: 2 }`). The + ecs mints its own ids, so conformance compares up to an id-bijection. An + entity-addressed transition adds the `args` schema (above) marking each `Entity.schema` + field; the case then writes the id plain (`args: { id: 2 }`). For a minted scalar a + case won't pin (a timestamp), use `Match.anyNumber` / `Match.anyString`. +- **A `throws` case** declares `throws: true` (any error) or `throws: "substring"` + instead of `after`/`effects`/`responses` — the two are mutually exclusive. + +## `transforms.ts` — the conformed-function barrel + +```ts +// transforms.ts — exactly the conformed transforms and derivations, nothing else +export { createTodo } from "./create-todo.js"; +export { toggleComplete } from "./toggle-complete.js"; +export { visibleTodos } from "./visible-todos.js"; +// … +``` + +Its keys are the conformance surface. A genuine **non-transition helper** (`appendTodo`, +`create`, `samples`) is *not* listed here and carries no cases; it keeps its own +`*.test.ts`. + +## `spec.ts` — the conformance manifest + +The single object both `spec.test.ts` and the ecs `conformance.test.ts` import. It is a +**test-tier** file (excluded from the runtime build) — the one place the feature touches +`@adobe/data-testing`. + +```ts +// spec.ts +import { Conformance } from "@adobe/data-testing"; +import { State } from "./state.js"; +import * as transforms from "./transforms.js"; +import { MainService } from "../../services/main-service/main-service.js"; +import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js"; +import { projection } from "../../services/main-service/conformance/projection.js"; +import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js"; +import { cases as createTodo } from "./create-todo.cases.js"; +import { cases as toggleComplete } from "./toggle-complete.cases.js"; +import { cases as visibleTodos } from "./visible-todos.cases.js"; +// …one import per conformed fn + +export const spec = Conformance.feature({ + state: State, + fns: transforms, + plugin: MainService.plugin, + computedPlugin: ComputedDatabase.plugin, // required iff the feature has a derivation + projection, + hydrate: ["visibleTodos"], // EXACTLY the entity-id-list derivations + services: { analytics, nameGenerator }, // required iff a transition injects a service + cases: { createTodo, toggleComplete, visibleTodos, /* …one per fn */ }, +}); +``` + +`Conformance.feature` enforces four guards at **compile time**: +- **`cases` coverage** — exactly one case module per `fns` entry (missing or extra → error). +- **`hydrate` exactness** — exactly the derivations that return a collection of the + projection's entity value (its ecs computed emits ids); miss one or add a non-hydrating + name → error. +- **`services` completeness** — exactly the injected-service keys the transitions declare, + each a `() => Service` template; absent for a service-free feature. +- **`computedPlugin` presence** — required when any case module is a derivation. + +## `spec.test.ts` — the pure spec + +```ts +// spec.test.ts +import { Conformance } from "@adobe/data-testing"; +import { spec } from "./spec.js"; +Conformance.checkSpec(spec); +``` + +One call verifies every transform and derivation against its cases (seeding `before` +over `State.create()`, synthesizing service doubles from `services` + `responses`, +asserting `after`/`value`/`effects`/`throws`). The ecs side is one matching call in +`conformance.test.ts` (`../services/main-service/conformance.md`). diff --git a/packages/data-ai/.claude/rules/features/ecs/conformance.md b/packages/data-ai/.claude/rules/features/ecs/conformance.md index 69babcace..d9f7a895c 100644 --- a/packages/data-ai/.claude/rules/features/ecs/conformance.md +++ b/packages/data-ai/.claude/rules/features/ecs/conformance.md @@ -76,10 +76,16 @@ calls actions, so conforming actions covers the path the app runs. - **Extra ECS tests** (collision selection, a refill after the field clears) live here too, since they need the spec or the projection. -## Escape hatch: per-case ambient context +## Per-case ambient context -A feature whose action reads ambient per-peer context (a `userId` seeded under a -custom concurrency, as in p2p presence) can't use `checkFeature`. It drives -`Conformance.runActions` directly, feeding it -`Conformance.adaptCases(spec.fns, spec.cases, spec.services, true)` with its own -`makeDb` and `seedContext`. This is the only sanctioned use of the lower-level runners. +An action that reads context the spec can't carry (the acting peer's `userId`, +stamped by the db's concurrency at dispatch, as in p2p presence) still uses +`checkFeature`. Pass a test `concurrency` and a `seedContext(db, before, args)` that +primes it from each case: + +```ts +Conformance.implementation(spec, { plugin, projection, + concurrency: peerConcurrency, + seedContext: (_db, _before, args) => { peerUserId = (args as { mark: string }).mark; }, +}); +``` diff --git a/packages/data-ai/.claude/rules/features/index.md b/packages/data-ai/.claude/rules/features/index.md index 0d53fb55c..c034017e6 100644 --- a/packages/data-ai/.claude/rules/features/index.md +++ b/packages/data-ai/.claude/rules/features/index.md @@ -36,6 +36,10 @@ src/ A feature creates only the layers and folders it uses. +A feature that has `data/state/` or `services/main-service/` uses the **legacy +state-based layout**: follow `data/state.md` and `services/main-service/` for it, and +don't migrate it unless asked. + ## Dependencies ``` diff --git a/packages/data-ai/.claude/rules/features/services/index.md b/packages/data-ai/.claude/rules/features/services/index.md index 97d60a60e..64464d109 100644 --- a/packages/data-ai/.claude/rules/features/services/index.md +++ b/packages/data-ai/.claude/rules/features/services/index.md @@ -5,6 +5,8 @@ paths: # services/ — the feature's services +> **Legacy feature** (has `data/state/` or `services/main-service/`)? Follow `services/main-service/` instead. + A service is the boundary between the feature and the outside world (generation, analytics, networking). Each is a namespace folder (`global/namespace.md`) holding its interface, its implementation and a fake. Services depend only on `data/`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/actions.md b/packages/data-ai/.claude/rules/features/services/main-service/actions.md new file mode 100644 index 000000000..08113df61 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/actions.md @@ -0,0 +1,72 @@ +--- +paths: + - '**/features/*/services/main-service/action-database/actions/**/*.ts' +--- + +# services/main-service/actions/ — async orchestration + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One action per file: a function taking the **whole database** as its first +argument and pure `data/` args. Actions orchestrate anything *outside* a +single transaction — awaiting a `services/` port, sequencing calls, deriving +timing — and then commit the result through a transaction. + +**Every *app-facing, transaction-backed* transition has a corresponding +same-named action** — the async realization the UI drives. It reads the same +services the transition injects from `db.services`, so it reproduces both the +transition's state change (through a transaction) and its side effects. It may +reuse another transition's transaction (`createRandomTodo` reuses `createTodo`) — +there need not be a same-named transaction; transactions are the looser layer. + +**Per-frame / system transitions are exempt.** In a real-time feature the `step*` +/ physics / collision transitions are realized by the **systems** tick loop, not +by an action, and are conformed by the tick-loop test (`systems.md`), not the +action surface of `checkFeature`. Give an action only to transitions a user/UI invokes directly +(and skip it too when the realization needs more than one transaction — e.g. a +`newGame` that both sets bounds and resets is conformed via its transaction). + +```ts +import type { ServiceDatabase } from "../../service-database/service-database.js"; + +export const addRandomTodo = async (service: ServiceDatabase) => { + const name = await service.services.nameGenerator.generateName(); // await a services/ port + service.transactions.createTodo({ name }); // then exactly one commit +}; +``` + +- Type the `service` parameter on the lowest database layer exposing what the + action touches — usually `ServiceDatabase` (services **and** + transactions). Never the action layer itself (that would be a cycle). +- **Call at most one transaction** per action, so undo/redo stays one step + per operation. +- **Fire-and-forget.** An action's return value is not consumed; results flow + back through observables. `service.services.*` calls are `void` or + awaited-internally, never surfaced to the caller. +- Do the outside-world work here: await/sequence `services/` calls, and if a + slow call needs timing, compute it here around the call. +- **Read current state synchronously from the store** (`db.resources` / `db.read` + / `db.select`, then a pure `data/` helper) — never from a cached `computed`. + Reactive computeds refresh only on a committed transaction, so an imperative + read of one can hand back a stale shared cache (and it's the UI's layer, not + the action's). This also keeps the action correct under the conformance seed. +- **ECS-based feature (no `data/state/`):** no conformance cases exist, so **every + action carries its own unit test** — build a db, run the action with fake + `services`, and assert the committed state and the recorded service calls. The + bullet below applies only to **state-based** features (see `../../index.md`, Two modes). +- **Conformance** is the action surface of the feature's single + `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call. It pulls + actions off **`plugin.actions`** and pairs each to the **same-named** `data/state` + transition. **The action is the primary seam** — it reads injected services from + `db.services`, so the runner synthesizes each recording double from the manifest's + `services` templates + the case's `responses` and builds + `Database.toSystemDatabase(Database.create(plugin, { services }))`; it runs the action + with the case's data `args`, then `Match.assert`s `toState ≡ after` **and** checks the + recorded calls against the case's `effects`. No per-op wiring, no coverage guard. A thin + **same-named** action gives a transaction-only or renamed transition something to + pair with (todo's `reorderTodo`). A streaming/capability action with no transition + is skipped. **A per-transition action kept out of the facet** (to bound the + plugin's type) is supplied via the manifest's `ops.actions` glob — + `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }` + (p2p negotiation). That is the *only* reason to set `ops` (see `conformance.md`). +- An `index.ts` barrel feeds the `actions` plugin facet. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md b/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md new file mode 100644 index 000000000..f4763342a --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/archetypes.md @@ -0,0 +1,51 @@ +--- +paths: + - '**/features/*/services/main-service/**/core-database/archetypes.ts' +--- + +# core-database/archetypes.ts — the feature's archetypes + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One file, one export: `archetypes`, built with `Database.archetypes(components, …)`, +which validates every key against the component map and preserves each archetype's +literal tuple (so no per-archetype `as const satisfies`). The archetype **model** — +query/superset semantics, base archetypes for logical supertypes, row iteration — is +the rules-root `archetypes.md`; this file is the declaration convention. + +```ts +import { Database } from "@adobe/data/ecs"; +import { components } from "./components.js"; + +export const archetypes = Database.archetypes(components, { + Todo: ["todo", "name", "complete", "order", "dragPosition", "assignees"], +}); +``` + +- `UpperCase` names — an archetype names a *shape*, like a class. +- Archetypes may **span scopes**: a document entity can include an ephemeral + `session` column — `components` merges every scope, so any-scope key validates. + +## satisfies CoreDatabase.Archetype + +Type-check a shared base tuple against `CoreDatabase.Archetype` (= `readonly Component[]`; +see `core-database.md`) — the same type checks every `count` / `select` query key downstream: + +```ts +const layer = ["placement", "name", "visible", "opacity"] as const satisfies CoreDatabase.Archetype; +``` + +## Drift guard against the data-type + +An archetype that mirrors a `data/` row type carries a **non-exported** compile-time +check — infer the row shape from the archetype and assert it equals the data-type, so +the two can't drift: + +```ts +import { Schema } from "@adobe/data/schema"; +import type { Assert, Equal } from "@adobe/data/types"; +import type { PlacedMark } from "../../data/placed-mark/placed-mark.js"; + +const _schema = Schema.fromArchetype(components, archetypes.PlacedMark); +type _Check = Assert, PlacedMark>>; +``` diff --git a/packages/data-ai/.claude/rules/features/services/main-service/components.md b/packages/data-ai/.claude/rules/features/services/main-service/components.md new file mode 100644 index 000000000..733b583a7 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/components.md @@ -0,0 +1,43 @@ +--- +paths: + - '**/features/*/services/main-service/**/core-database/components.ts' +--- + +# core-database/components.ts — the feature's components + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One file, one export: `components`, built with `Database.components(...)`. A +component binds a `data/` type into ECS column storage, grouped by schema scope. +The scope groups are optional — declare only the ones you use. + +Bind each schema straight from its `data/` type (or a primitive schema); don't +author shapes here. Component entries are any `Schema` — no `default` required +(that's resources): + +```ts +import { Database } from "@adobe/data/ecs"; +import { Boolean, True } from "@adobe/data/schema"; +import { PlayerMark } from "../../data/player-mark/player-mark.js"; +import { DragPosition } from "../../data/drag-position/drag-position.js"; + +export const components = Database.components({ + document: { mark: PlayerMark.schema, complete: Boolean.schema }, + session: { dragPosition: DragPosition.schema }, +}); +``` + +- A component name may differ from the type it stores (`position` of a `Vec3`, + `mark` of a `PlayerMark`) — the field name describes the role, the schema the + shape. +- **Scope = which group it sits in** (see `services/main-service/index.md` for the four scopes and + their flags). `document` is the common case; hover a scope key for its + meaning. Never write `nonShared` / `nonPersistent` by hand — the group applies + them. +- **Shared cross-feature columns live in `data/`** and go in `document` (which + preserves schema identity) so `combinePlugins` dedupes them. +- Struct vs. object storage and schema selection are properties of the `data/` + type, decided there. +- Don't infer entity type based upon component membership. + Consider using a const: true "tag" component or a type/kind enumerated value if needed. + Note that *many* good ecs designs don't care the type of the entity and only act on components. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/computed.md b/packages/data-ai/.claude/rules/features/services/main-service/computed.md new file mode 100644 index 000000000..7ceb4cc2c --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/computed.md @@ -0,0 +1,84 @@ +--- +paths: + - '**/features/*/services/main-service/computed-database/computed/**/*.ts' +--- + +# database/computed/ — derived observable values + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One derived value per file: a `cached` function of a database layer that +returns an `Observe` of state projected through pure `data/` helpers. +Derivation logic itself lives in `data/`; a computed only wires a service +observable to it. + +**Reuse tested pure logic; let performance pick the wiring.** Prefer wiring the +pure **`data/` helper** (or composing indexes) directly — the tictactoe +computeds delegate their observable straight to +`BoardState.deriveStatus`/`getWinner`/`currentPlayer` — because a helper reads +exactly the resource/entities it needs, and both the computed and the matching +`state/` derivation call it, so they agree by construction. + +You **may** also import a pure `state/` derivation `(state) => value` into a +computed where performance is adequate: a small-N or resource/scalar composition, +not a hot per-entity or large-N path. Reusing a tested derivation is good — the +only cost is that it takes the **whole `State`**, so the computed must observe the +full-state projection and re-runs on *any* field change (fine for a small feature, +wasteful on a large or hot one, where you hand-wire the minimal resource/index +reads instead). A derivation takes **no services**, so its sibling `*.cases.ts` is +inert `{ input, value }` data (no test-doubles); like every `*.cases.ts` it is +test-tier and excluded from the runtime build by the project-reference wall, so it +never reaches the app bundle. **Performance is the first-class constraint**: reuse +freely where it doesn't matter, hand-wire minimal reads where it does. + +```ts +import { cached } from "@adobe/data/cache"; +import { Observe } from "@adobe/data/observe"; +import { BoardState } from "../../../data/board-state/board-state.js"; +import type { IndexDatabase } from "../../index-database/index-database.js"; + +export const status = cached((service: IndexDatabase) => + Observe.withFilter(service.observe.resources.board, BoardState.deriveStatus), +); +``` + +Type the parameter on the lowest database layer that exposes what it +reads. An `index.ts` barrel re-exports every computed; `computed-database.ts` +registers it under the `computed` facet. + +For a computed that only needs *how many* entities match an archetype (an +empty-state flag, a badge count), read `db.observe.count(archetype.components, +{ where })` — an `Observe` that sums archetype row counts and re-emits +only when the count changes, allocating no entity array. Prefer it over +`db.observe.select(...)` mapped through `.length`. + +**Conform a computed to its `data/state` derivation** whenever one exists. The +derivation's sibling `*.cases.ts` holds `{ input, value }` cases +(`Conformance.SpecDerivations`), and the feature's single +`conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call conforms +computeds: it pulls them off **`computedPlugin.computed`**, pairs each to its +**same-named** derivation, seeds the store from `input`, reads the computed's +synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`). +There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase` +layer** — the runner builds computed conformance from that layer, not the assembled +`MainService`, so a `withCache` above can't hand back a stale pre-seed value (a +direct `fromState` seed emits no transaction to invalidate it). Comparison is +identity by default; a computed that emits an **entity-id list** names itself in +`hydrate: [...]` (todo's `visibleTodos`) so the runner projects each id through +`toData`. A computed with **no `state/` derivation is skipped** — +single-`data/` math is covered by that helper's own test. A feature with no +`state/` derivation omits `computedPlugin` entirely. + +**What needs conformance is proportional to wiring logic.** A computed that +composes/branches over the aggregate *is* a `state/` derivation (composes ≥2 +fields) and is conformed here. A computed that **trivially applies one +`data/` helper to one field** (`winner` ← `BoardState.getWinner(board)`) is +already covered by its parts — the field by `toState` conformance, the math by +that helper's unit test — so an added conformance would just re-invoke the same +helper on both sides (tautological); the helper's unit test is sufficient. Two +exceptions: (1) to pin a particular non-obvious wiring, promote it to a thin +`state/` derivation `(state) => Type.helper(state.field)` and the runner conforms +it — no new mechanism; (2) if a computed folds **entities into a value with no +intermediate `State` field**, that projection *is* unconformed — promote the +intermediate to a `State` field so `toState` covers it, making it a normal +`state/` derivation. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/conformance.md b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md new file mode 100644 index 000000000..a0ed975da --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/conformance.md @@ -0,0 +1,97 @@ +--- +paths: + - '**/features/*/services/main-service/conformance/**/*.ts' +--- + +# services/main-service/conformance/ — keeping the ECS honest against the spec + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +**State-based features only** (a feature with a `data/state/` spec). Test-only: imported +only by `*.test.ts`, in no facet barrel. The `data/state` cases (authored per +`../../../data/state.md`, gathered by the `spec.ts` manifest) are the shared truth; the +`@adobe/data-testing` runner replays them against the ECS. This folder holds only the +**feature-specific projection** (`projection.ts`) plus one `conformance.test.ts`. +Reference: `data-lit-tictactoe` (minimal), `data-lit-todo` (entities + `hydrate`). + +**The projection helpers and `Match` are test-only — never a runtime branch.** +`fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through +observables/indexes and writes through transactions. + +## `projection.ts` — the store ⇄ State mapping (the one per-feature piece) + +A single aggregated export — three test-only helpers, one `export`: + +```ts +export const projection = { fromState, toState, toData }; +``` + +- `fromState(store, state)` seeds a store to a `State` (clear, insert entities, set + resources) and **returns the `id → entity` map** it built (`ReadonlyMap`). + The ecs mints its own ids, so the runner turns this map into the resolver that maps a + case's spec-id to the seeded entity — no feature writes id resolution by hand. Return + `void` for an index-addressed / singleton feature (ids then resolve to `Entity.none`). +- `toState(store)` reads the whole store back, built on `toData`. +- `toData(store, entity)` reads one entity as its `data/` value — the single place the + ecs↔data mapping lives. Its **return type** is what tells conformance which derivations + are entity-list computeds. Present only when the feature has entities. + +## `conformance.test.ts` — one call + +```ts +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../../data/state/spec.js"; +Conformance.checkFeature(spec); +``` + +`checkFeature` replays the manifest's shared cases against the ECS: it pulls the +transactions/actions off `spec.plugin`, the computeds off `spec.computedPlugin`, seeds +each case's `before` over `State.create()`, synthesizes each service's recording double +from `spec.services` + the case's `responses`, round-trips `State.samples` through +`spec.projection`, and hydrates the id-emitting computeds named in `spec.hydrate` through +`toData`. It shares `spec` with `spec.test.ts` — same cases, substituted implementation. + +Everything `checkFeature` needs lives on the manifest (`../../../data/state.md`): +`plugin`, `computedPlugin`, `projection`, `hydrate`, `services`, `state`, `fns`, `cases`. +The manifest's compile-time guards mean the two calls (`checkSpec` + `checkFeature`) are +the whole conformance surface — no per-op wiring, no coverage guard to hand-maintain. + +- **Name-pairing.** Each ecs op conforms to its **same-named** transition/derivation. An + op with no same-named transition (infrastructure like `setInput`, a streaming action, + a richer UI op) is skipped — so when the real op is renamed (`dragTodo`), add a thin + **same-named** op (`reorderTodo`) for the transition to pair with; never a per-item + adapter. +- **Projection round-trip.** When `State.samples` is non-empty the runner adds a + `toState ∘ fromState ≡ identity` test per sample. +- **Comparison.** Ordering is carried by the value's type — `ReadonlyArray` positional, + `ReadonlySet`/`ReadonlyMap` order-independent. Entity ids compare up to an id-bijection + from the schema-marked spec-ids (no matcher). Float noise is absorbed by + `match?: { tolerance }` (default `0.01`). A minted scalar uses `Match.anyNumber` / + `Match.anyString`. + +## The escape hatch — per-surface `seedContext` / custom concurrency + +A feature that needs **ambient per-case context differing per surface** — a user-scoped +`userId` seeded before the raw transaction *and* independently before the action dispatch +(**p2p presence**) — cannot use `checkFeature` (it has no place to thread two different +`seedContext`s or a custom concurrency). It drives the lower-level runners directly, +feeding them the manifest's cases via `Conformance.adaptCases`: + +```ts +import { Conformance } from "@adobe/data-testing"; +import { spec } from "../../../data/state/spec.js"; + +Conformance.runTransactions({ + createStore, fromState, toState, + transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false), + transactions, + seedContext: (store, _before, args) => seedUserId(store, (args as { mark: string }).mark), +}); +``` + +`adaptCases(fns, cases, services, injectServices)` turns the manifest into the discovery +map the runners consume — `injectServices: false` for the transaction store (a +transaction never receives services), `true` for the action db. `runActions` / +`runComputeds` take the same shape and their own `makeDb` / `seedContext` / concurrency. +This is the **only** sanctioned use of the lower runners; ordinary features use +`checkFeature`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/core-database.md b/packages/data-ai/.claude/rules/features/services/main-service/core-database.md new file mode 100644 index 000000000..8046f1e35 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/core-database.md @@ -0,0 +1,50 @@ +--- +paths: + - '**/features/*/services/main-service/**/core-database/core-database.ts' +--- + +# core-database/core-database.ts — the assembled schema + its namespace + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +A thin assembler: `Database.Plugin.create({ components?, resources?, archetypes? })` +over the facet files, exposed through the `CoreDatabase` namespace. This is the +single-file namespace form (one value export — `plugin` — everything else a type; +see `global/namespace.md`). + +```ts +import { Database, type Store } from "@adobe/data/ecs"; +import { components } from "./components.js"; +import { resources } from "./resources.js"; +import { archetypes } from "./archetypes.js"; + +const coreDatabasePlugin = Database.Plugin.create({ components, resources, archetypes }); + +export type CoreDatabase = Database.Plugin.ToDatabase; +// Resolved component map, at module scope so the imported `Store` namespace isn't +// shadowed by `CoreDatabase.Store` below. +type CoreComponents = Store.Components>; + +export namespace CoreDatabase { + export const plugin = coreDatabasePlugin; + /** The store transactions operate on — a store *is* the transaction context. */ + export type Store = Database.Plugin.ToStore; + /** Index-declaration type bound to this database's components. */ + export type Index = Database.Index; + /** A component name, and a component-key tuple — an archetype's defining shape, or the key + * list of a `count` / `select` query. */ + export type Component = Extract; + export type Archetype = readonly Component[]; +} +``` + +## Derive `Component` / `Archetype` from `components`, never the plugin + +Read the keys straight from the leaf `components` module +(`Extract`). Do **not** derive them from the +plugin (`Store.Components>`, i.e. `CoreComponents`): the plugin is +built *from* `archetypes`, so a plugin-derived `Archetype` is **circular** the +moment it is used to `satisfies`-check an archetype tuple — and the cycle +collapses the entire database type to `{}`. The leaf derivation is cycle-free and +usable both in `archetypes.ts` (to check shared bases) and downstream (to check +`count` / `select` query keys). diff --git a/packages/data-ai/.claude/rules/features/services/main-service/index.md b/packages/data-ai/.claude/rules/features/services/main-service/index.md new file mode 100644 index 000000000..3ea4e3f11 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/index.md @@ -0,0 +1,189 @@ +--- +paths: + - '**/features/*/services/main-service/main-service.ts' + - '**/features/*/services/main-service/*-database/*-database.ts' +--- + +# services/main-service/ — the feature service (ECS implementation) + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +`main-service/` is the feature's single entrypoint service (see `../index.md`), +implemented as a reactive Entity-Component-System — an implementation detail +behind the service. Everything ECS-specific lives here: the schema that stores `data/` types as +entity state (`core-database/`), the derived and mutating logic over it +(`indexes/`, `transactions/`, `computed/`), the database-bound service factories +(`services/`), the async orchestrators (`actions/`), and the layered plugins +that compose it all. + +The distinction from `data/`: a `data/` type is ECS-agnostic (a plain +serializable value); an ECS construct is *how that type is stored and operated +on* inside a database. `mark: PlayerMark.schema` is not a data-model fact — it +is "the ECS keeps a PlayerMark here". + +## The core schema — `core-database/` + +A feature's whole schema — every component, resource, and archetype — lives in +one `core-database/` folder: `core-database.ts` plus one file per facet the +feature uses (all three facets are optional on `Database.Plugin.create`): + +``` +core-database/ + core-database.ts # Database.Plugin.create({ components?, resources?, archetypes? }) → CoreDatabase + components.ts # export const components = Database.components({ … }) + resources.ts # export const resources = Database.resources({ … }) + archetypes.ts # export const archetypes = Database.archetypes(components, { … }) +``` + +`core-database.ts` is a thin assembler; each facet file exports one typed object +built by a `Database.*` helper. No per-item files, no barrels. Create only the +facet files the feature needs — a singleton feature with no entities is just +`resources.ts` + `core-database.ts` (no empty `components({})` boilerplate). + +### Schema scopes + +State divides on two orthogonal axes — **scope** (shared with peers vs. local to +this client) and **lifetime** (durable across reloads vs. ephemeral) — giving +four scopes, each stamping a flag pair: + +| scope | shared? | durable? | flags | +|-------|:--:|:--:|-------| +| **document** | yes | yes | — | +| **settings** | no | yes | `nonShared` | +| **presence** | yes | no | `nonPersistent` | +| **session** | no | no | `nonPersistent` + `nonShared` | + +`Database.components` / `Database.resources` group declarations by scope and +stamp the flags. Every scope key is optional — declare only the ones you use +(most features are document-only): + +```ts +// components.ts +export const components = Database.components({ + document: { todo: True.schema, name: Name.schema, complete: Boolean.schema }, + session: { dragPosition: DragPosition.schema }, // omit settings / presence +}); + +// resources.ts — entries must carry a `default` (enforced at the call) +export const resources = Database.resources({ + settings: { displayCompleted: Boolean.schema }, +}); +``` + +A component/resource *value* is a plain `data/`-schema re-export (or a small +inline literal); its scope — and therefore its flags — is stated only by which +group it sits in. Hover a scope key for its meaning. The database does not yet +act on these flags (see the `nonShared` schema note); they model +shared/local × durable/ephemeral now, and the durable-shared subset +(`document`) is the set a bare store could be rebuilt from for reconciliation. + +**Shared columns live in `data/`.** A column two features share (e.g. a `name` +on both todos and users) is a `data/` type both reference by identity +(`Name.schema`), so `combinePlugins` dedupes it. Only the `document` scope +preserves schema identity (it applies no flags) — never share a +settings/presence/session column across features by identity. + +### Archetypes + +`archetypes.ts` declares the feature's entity shapes with +`Database.archetypes(components, { … })`, which validates every key against the +component map and preserves each archetype's literal tuple — no per-archetype +`as const`. Archetypes are a packing convenience, not serialized data; one may +span scopes (a document entity with an ephemeral `dragPosition` slot). An +archetype mirroring a `data/` row type carries a private compile-time +drift-guard (see `archetypes.md`). + +## Layered composition (behaviour) + +Above the schema, behaviour is a chain of `Database.Plugin`s, each its own +**namespace folder** — `-database/` holding the eponymous +`-database.ts` (the plugin, which `extends` the previous layer) plus the +facet folder it adds. **Name each layer for the facet it adds — never for the +feature or app.** A feature creates only the layers it uses: + +``` +main-service/ + core-database/ # the schema (above) + index-database/ # extends core; indexes/ + transaction-database/ # extends index; transactions/ + service-database/ # extends transaction; services/ + computed-database/ # extends service; computed/ + action-database/ # extends computed; actions/ +``` + +Each exports a namespace mirroring the plugin — the composed object is a +`Database` (the `@adobe/data` term) even though the folder is `services/main-service/`: + +```ts +export type IndexDatabase = Database.Plugin.ToDatabase; +export namespace IndexDatabase { + export const plugin = indexDatabasePlugin; + export type Store = Database.Plugin.ToStore; +} +``` + +### The assembled database — `MainService` + +*Which* layer is topmost varies with the facets a feature uses (service / +computed / action / system). Consumers — `ui/`, the app entry, `services/main-service/conformance/` +— need the *whole* feature database but must not name that layer, or adding or +dropping a layer rewrites them all. Alias it once from the **folder-eponymous +`main-service.ts`** (per `global/namespace.md`, a folder's primary export lives in +its same-named file), as a namespace so consumers reach `MainService.plugin` / +`MainService.Store`: + +```ts +// services/main-service/main-service.ts +export { SystemDatabase as MainService } from "./system-database/system-database.js"; +``` + +Every consumer references **`MainService`** (`.plugin`, `.Store`) — never the +topmost layer. Add or drop a layer → change only this one line. + +**Cross-feature naming.** Inside a feature the core and assembled databases are +the bare `CoreDatabase` / `MainService`. When *another* feature or package +imports one — a peer built on it, the base `imports` a peer's `core-database`, a +downstream package — reference it **feature-qualified**: `CoreDatabase` +/ `MainService`, re-exported so from the feature's barrel +(`export { CoreDatabase as TodoCoreDatabase } from "…/core-database.js"`) — so +two features' `CoreDatabase`s never collide. + +## What each layer takes as input + +- **`plugin`** — every layer `extends` the previous layer's `plugin`; that is + the one universal thread up the chain. +- **`Store`** (via `ToStore`) — used *only* by **transactions** (`t: + .Store`). A store carries the index handles and the initiating + `userId`, so a store *is* the transaction context; there is no separate + transaction-context type. Use **`CoreDatabase.Store`** for a transaction that + reads/writes entities, resources, or archetypes; **`IndexDatabase.Store`** the + moment it reads an index. +- **the whole `Database`** — **services**, **computed**, and **actions** each + take `service: ` (the lowest layer whose database exposes what they + read/call): services read observables and call transactions; computed reads + `service.observe.*` and, once it needs a service's own reactive surface, + `service.services.*`; actions call `service.services.*` then + `service.transactions.*`. + +Each subfolder has its own rule. Modelling a plugin's authored vs. derived +surface is covered by `plugin-modelling.md`. + +## Facets are flat until they aren't + +A **behavioural** facet folder (`indexes/`, `transactions/`, `computed/`, +`actions/`) holds one file per item plus an `index.ts` barrel — flat while that +reads well, roughly up to half a dozen items. When a cohesive cluster grows past +that, promoting it into a themed subfolder with its own `index.ts` (re-exported +by the facet barrel) is a standard move — only once the folder actually feels +crowded: + +``` +transactions/ + order/ # a cohesive cluster: fractional-ordering helpers + index.ts next-order.ts normalize-order.ts select-ordered-todos.ts + create-todo.ts drag-todo.ts … +``` + +The **schema** facets (components / resources / archetypes) are *single files* +in `core-database/`, not folders. A whole feature should stay small enough that +subfoldering rarely bites — grow by adding features, not by bloating one. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/indexes.md b/packages/data-ai/.claude/rules/features/services/main-service/indexes.md new file mode 100644 index 000000000..f9413a0e6 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/indexes.md @@ -0,0 +1,65 @@ +--- +paths: + - '**/features/*/services/main-service/index-database/indexes/**/*.ts' +--- + +# database/indexes/ — ECS indexes + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One index descriptor per file: an object `satisfies CoreDatabase.Index` that +tells the store to maintain a lookup keyed on a component, so queries and +computed values can find entities without scanning. + +```ts +import type { CoreDatabase } from "../../core-database/core-database.js"; + +export const byComplete = { + key: "complete", +} as const satisfies CoreDatabase.Index; +``` + +`CoreDatabase.Index` is bound to every component (all scopes live in the one +`core-database`). Archetype-scoped indexes carry an extra `archetype` field the +`Index` helper can't express, so declare those with a bare `as const` (no +`satisfies`) and let `index-database.ts`'s `create()` validate them. + +Name the export for what it indexes (`byComplete`, `byOwner`). An +`index.ts` barrel re-exports every index; it feeds the `indexes` facet on +`index-database.ts`. + +## Discrete keys only — derive a bucket for spatial / range queries + +An index matches keys by **equality on a discrete value**, so a continuous or +range-valued column (a `position`, a timestamp, a score) can't be indexed as-is +— `{ key: "position" }` buckets by exact value and finds nothing near a point. +Derive a discrete **bucket key** — but prefer a **computed-key extractor** +(`key: { cell: (c) => cellKey(c.position!) }`, naming the read columns in +`components`) over storing the derived key as its own component. The extractor +recomputes the key from its source columns, so the index stays correct with no +maintenance; a stored bucket column must instead be recomputed at every +insertion/update site (or by a system) and silently drifts if one is missed. The +derivation itself is a pure `data/` helper. + +Bucketed broad-phase only pays off at scale (hundreds+ candidates per query); +below that a linear scan over an archetype wins. Index when the scan is a +**proven** cost, not preemptively. + +## Scoping, arrays, and many-to-many + +- **Coverage is by columns.** An index applies to every archetype whose + components are a superset of the key's columns. Two archetypes that share a + column are both indexed — so a unique index keyed on a shared column (e.g. + `name`, held by both users and todos) would collide. +- **Scope with `archetype`.** `{ key: "name", archetype: "User", unique: true }` + restricts the index to one archetype, excluding others that share the column. + `archetype` is **not** on the `Database.Index` `satisfies` helper (it carries + no archetype map), so declare such an index with `as const` (no `satisfies`) + and let `index-database.ts`'s `create()` validate it against the real facet. +- **Array columns fan out automatically.** Keying on a `string[]` column makes a + multi-value index — one bucket entry per element — and `find({ col: element })` + takes the element, not the array. +- **Model a many-to-many as a denormalized array + two indexes.** A + `todo.assignees: string[]` (owned/displayed by one feature) plus a unique + `usersByName` (name → user) and a multi-value `todosByAssignee` (assignee → + todos) gives efficient navigation in both directions from one join key. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/resources.md b/packages/data-ai/.claude/rules/features/services/main-service/resources.md new file mode 100644 index 000000000..cff0fb486 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/resources.md @@ -0,0 +1,30 @@ +--- +paths: + - '**/features/*/services/main-service/**/core-database/resources.ts' +--- + +# core-database/resources.ts — the feature's resources + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +A resource is a singleton component — one instance held on a single entity. One +file, one export: `resources`, built with `Database.resources(...)`. It is +identical to `Database.components` except **every entry must carry a `default`** +— a missing `default` is a compile error here, at the declaration site. + +```ts +import { Database } from "@adobe/data/ecs"; +import { Boolean } from "@adobe/data/schema"; + +export const resources = Database.resources({ + settings: { displayCompleted: Boolean.schema }, // per-device view toggle +}); +``` + +- Give each resource a `default` — spread the data-type's schema and add one, or + bind a `data/` / primitive schema that already carries a sensible default. +- **Scope = which group it sits in** (see `services/main-service/index.md`). A per-device + preference is `settings` (durable, `nonShared`); a synced-but-unsaved value is + `presence`; a saved shared value is `document`. +- Because there is only ever one instance, linear-memory packing buys nothing — + never choose a struct schema for efficiency here. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/services.md b/packages/data-ai/.claude/rules/features/services/main-service/services.md new file mode 100644 index 000000000..bd969e37d --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/services.md @@ -0,0 +1,27 @@ +--- +paths: + - '**/features/*/services/main-service/service-database/services/**/*.ts' +--- + +# database/services/ — database-bound service factories + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One factory per file: `createService(service, …)` that binds a service +implementation to a live database — reading its observables and invoking +its transactions. This is where the async `services/` contracts (or +framework services like `AgenticService`) are wired to ECS state. + +```ts +export const createAgentService = (service: TransactionDatabase, mark: PlayerMark): AgenticService => + /* … reads service.observe.*, calls service.transactions.* … */; +``` + +Type `service` on the lowest layer exposing what the factory reads/calls (never +the `service-database` itself — that would be a cycle). + +An `index.ts` barrel re-exports the factories; `service-database.ts` +registers them under the `services` facet, each keyed by the name +consumers read it as (`service.services.agent`). Defining a standalone +service contract (not yet bound to a database) belongs in the feature +`services/` layer. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/systems.md b/packages/data-ai/.claude/rules/features/services/main-service/systems.md new file mode 100644 index 000000000..2085ad35e --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/systems.md @@ -0,0 +1,145 @@ +--- +paths: + - '**/features/*/services/main-service/system-database/**/*.ts' +--- + +# database/systems/ — the real-time tick loop + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +Systems are the framework's real-time backbone: functions the scheduler runs +**every frame**, in dependency order, to advance a simulation. A feature only +has a `systems/` folder when it is real-time (a game, a physics/particle sim). +Turn-based features never need one — they mutate through transactions on user +input and stop. + +A system is a `SystemDeclaration` — `{ create, schedule?: { before?, after?, during? } }`. +`create` runs ONCE at construction (capture closures / query args); it returns the +per-frame function (or `void` for an init-only system, e.g. seeding the first +entities). Ordering lives under **`schedule`** (not top-level). + +**Declare the `systems` map inline in `system-database.ts`.** The `create: (db) => …` +arrow must be written inline in `Plugin.create` for two reasons: only there is `db` +strongly typed (to the assembled plugin's database, with a **writable `store`**), and +a pre-typed standalone `create` reference breaks the scheduler's system-name inference +(the name union collapses to `never`, which then rejects every `schedule` entry). Keep +each `create` thin by delegating the real per-frame work to a pure `data/` step +function; if an ecs-specific body is substantial, extract it to a `systems/.ts` +helper `(db) => () => void` (annotate its `db`) and call it from the inline `create`. + +```ts +// in system-database.ts +import { Database, scheduler } from "@adobe/data/ecs"; +import { Motion } from "../../data/motion/motion.js"; + +const plugin = Database.Plugin.create({ + // combine scheduler with the CURRENT TOP layer — ComputedDatabase here, but + // ActionDatabase / ServiceDatabase if the feature built those (systems come last). + extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), + systems: { + control: { create: (db) => () => { /* input → ship; db.transactions.fireBullet() */ } }, + movement: { + schedule: { after: ["control"], before: ["collision"] }, + create: (db) => () => { + const dt = db.store.resources.frameDelta; + for (const arch of db.store.queryArchetypes(["position", "velocity"])) { + const pos = arch.columns.position, vel = arch.columns.velocity; + for (let i = 0; i < arch.rowCount; i++) { + pos.set(i, Motion.advance(pos.get(i), vel.get(i), dt)); // in-place, no migration + } + } + }, + }, + collision: { schedule: { after: ["movement"] }, create: (db) => () => { /* … */ } }, + }, +}); +``` + +- **Systems mutate `db.store` directly** — that is the writable surface a system + receives (unlike the readonly `.store` consumers get). In-place column writes + (`col.set(i, …)`) for hot per-row work that stays in the same archetype; + `db.store.update / insert / delete` for lifecycle (spawning, dying) that changes + archetype membership. Resources: `db.store.resources.x = …`. +- **Dispatch a transaction for a discrete atomic event** — when the effect is a + self-contained event that already has a conformant transaction (score a hit, + lose a life, spawn a wave, fire a bullet), call `db.transactions.*` instead of + re-implementing it: you reuse the `data/`-verified logic and observers (a reactive + HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths + (movement, aging) that run on every entity every frame and shouldn't pay + per-entity transaction overhead. +- **Optimize hot paths for speed.** With many entities, query with efficient + `queryArchetypes` and read/write minimal columns directly; avoid intermediate + allocations. On a hot path treat `data/` step functions as **spec only** — use + them when the path isn't hot or you can't beat them by writing the store + directly; off hot paths, reuse them freely. (Only transactions are observable — + direct store writes fire no observers.) +- **Iterate archetypes per `archetypes.md`.** Express selection with + `queryArchetypes(include, { exclude })`; when a system destroys/migrates rows + (bullets expiring, entities dying) iterate **tail → head** so hole-fills don't + invalidate the cursor. A per-frame system touching many rows is exactly where + those rules pay off. **A `db.transactions.*` call mid-scan migrates rows too** — + a transaction that inserts/deletes invalidates any live archetype cursor, so + snapshot the ids you'll act on (or reverse-iterate) *before* dispatching + transactions inside a per-frame loop. +- **Replicate the oracle's top-level guard in every system.** If the `data/` + `step` opens with `if (isGameOver(state)) return state`, each split system must + early-return on the same condition — a frozen frame is only a true no-op if + *every* system honors the guard. +- **Index reads go through `db.indexes`** (plural) — the writable `db.store` a + system receives is typed *without* its index handles, so a broad-phase query is + `db.indexes.byCell.find({ cell })`, not `db.store.indexes.…`. +- **Ordering is declared under `schedule`, not implied.** `schedule.before` / + `schedule.after` are hard constraints (name sibling systems); `schedule.during` + is a soft same-tier hint. Systems with no ordering relation share a tier + (conceptually parallel) — never rely on declaration order. When you split a + monolithic `data/` `step` into several ordered systems, the schedule **must + mirror the step's internal sequence** (advance → fire → age → collide → refill) + — otherwise the tick diverges from the oracle even though each system is + individually correct. Watch the subtle case: if a spawn step (fire) is followed + by a step that also processes the just-spawned entity (the new bullet is aged + and advanced this same tick), you can't advance *all* bodies in one system + before the spawn — split that entity's advance out so it runs *after* the spawn, + alongside the step that owns it. +- Immediate-mode rendering: store the canvas in a session resource and render to + it inside a system (see @adobe/data-gpu for patterns). + +## Driving the loop — the scheduler + +Systems don't run themselves. Combine the built-in scheduler so they run on +`requestAnimationFrame`: + +```ts +import { Database, scheduler } from "@adobe/data/ecs"; +// in system-database.ts: +Database.Plugin.create({ extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), systems }); +``` + +The scheduler adds a `schedulerState` resource (`"running" | "paused" | +"disposed"`) — gate start/pause/resume through it (start `"paused"` if the game +begins on a user action). A headless host (tests, server sim) omits the +scheduler and drives frames itself by calling `db.system.functions[name]()` for +each `name` in `db.system.order` — so a simulation is fully runnable and testable +with no rAF and no rendering attached. + +## Layer + +`system-database.ts` extends the feature's **current top** main-service layer (`ActionDatabase` / +`ServiceDatabase` / `ComputedDatabase` — whichever it built; systems come last in the pipeline, so +they must sit atop any service/action layers, not a hardcoded `ComputedDatabase`), combined with +`scheduler`, and declares the `systems` map **inline** (see above — +this is the one facet not split one-per-file, because `create`'s `db` is only typed +inline and standalone declarations break name inference). A `systems/` folder is +optional: it holds extracted per-frame body helpers (`(db) => () => void`) only when +an inline body grows too large — the schedule and registration still live inline in +`system-database.ts`. The headless conformance test drives one frame via +`db.system.order` / `db.system.functions` and compares to the `data/` `step` oracle, +**reusing the feature projection** (`services/main-service/conformance.md`); it lives at +`system-database/tick-loop.test.ts`. Test **selection/detection** logic (which +entities collide, which pair resolves) separately, with seeded edge-case geometries +— it's a system concern, not a transaction, and where subtle bugs hide. + +## Iterating archetype rows + +A per-frame system reads/writes archetype columns directly. The row-iteration +rules — declarative `queryArchetypes` selection, tail → head migration, snapshot +avoidance — are general ECS concerns in the rules-root `archetypes.md`. diff --git a/packages/data-ai/.claude/rules/features/services/main-service/transactions.md b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md new file mode 100644 index 000000000..38f91dfc5 --- /dev/null +++ b/packages/data-ai/.claude/rules/features/services/main-service/transactions.md @@ -0,0 +1,61 @@ +--- +paths: + - '**/features/*/services/main-service/transaction-database/transactions/**/*.ts' +--- + +# database/transactions/ — atomic mutations + +> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../../index.md`. + +One mutation per file: a function taking the transaction store as its +first argument and pure `data/` args, returning `void`. It reads and +writes `t.resources` / entities; all its writes commit or roll back +together. + +```ts +import { PlayMoveArgs } from "../../../data/play-move-args/play-move-args.js"; +import type { CoreDatabase } from "../../core-database/core-database.js"; + +export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => { + // guard, then mutate t.resources / entities / archetypes +}; +``` + +- Type the store parameter as `.Store`: **`CoreDatabase.Store`** for a + transaction that reads/writes entities, resources, or archetypes (the common + case — all schema lives in `core-database`); **`IndexDatabase.Store`** the + moment it reads an index. A store *is* the transaction context: it carries the + index handles and the initiating `t.userId`. There is no separate + transaction-context type. +- Keep transactions idempotent under replay where sync/P2P applies — + validate and silently return on illegal or out-of-turn input rather + than throwing. +- Decisions come from pure `data/` helpers; the transaction only applies the + result — read the touched slice, call the `data/` transform, write the diff. +- Keep transaction files **single-export** (the `transactions/` barrel is + `export *`-ed into the plugin facet, so a second export would pollute it). +- **ECS-based feature (no `data/state/`):** there are no conformance cases, so + **every transaction carries its own unit test** — `Store.create(plugin)`, run the + transaction, assert the resulting resources/entities/archetypes. That test is the + direct substitute for the conformance oracle (see `../../index.md`, Two modes). The + bullet below applies only to **state-based** features. +- **Conformance is wired once, centrally, and auto-paired** — not per-file. The + feature's single `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` + call conforms transactions: it pulls them off **`plugin.transactions`** (the + registered facet), pairs each to the **same-named** `data/state` transition, and + conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after` + (state only; service effects are asserted through the action). There is **no** + `define`/`conforms` adapter and **no** `covers` guard. A transaction taking + **entity ids** takes them under the transition's own arg key (`{ id }`); the runner + resolves each id the case's `args` schema marks via the id→entity map `fromState` returns. A + transaction with **no same-named transition** is infrastructure (`setInput`, + `setBounds`) or the drag UI op (`dragTodo`) or system-dispatched — it is simply + **skipped**, no guard needed. Don't add a per-item adapter for a renamed op; add a + thin same-named transaction instead (see `conformance.md`). +- An `index.ts` barrel feeds the `transactions` plugin facet — so it must + re-export **only** the mutations. A read/query helper shared by several + transactions (`readShip`, `readBoard` — a `(t) => value` function) may live + beside them in `transactions/`, but keep it **out of the barrel**, or it gets + registered as a dispatchable transaction. (These are related helpers, so they + belong here, not in a separate folder — `global/cohesion.md`'s peer-level caution + doesn't apply.) diff --git a/packages/data-ai/.claude/rules/features/ui/binding-element.md b/packages/data-ai/.claude/rules/features/ui/binding-element.md deleted file mode 100644 index 6e4cecabb..000000000 --- a/packages/data-ai/.claude/rules/features/ui/binding-element.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -paths: - - '**/src/**/*element*.ts' - - '**/src/**/elements/**/*.ts' - - '**/src/**/ui/**/*-element.ts' ---- - -# Binding elements - -Binding elements are thin wires between application state and the UI — not designed for unit-testability. Non-trivial logic doesn't belong -here, it must be pushed into either the action/transaction layer or into presentations. - -## Scope - -This rule applies to **binding elements** — Lit elements that read state from a service via `useObservableValues`. If you are not using Lit then still try to adhere to a similar pattern to this with the same focus on separation of concerns and functional presentation if possible. -The discriminator is the base class: - -- `ApplicationElement` — from either `@adobe/data-lit` or `src/applications/application-element.ts`. Every app-level binding element - extends this (directly or transitively). Concrete app bases like `FooElement`, `VideoGenerationElement`, `ImageEditorElement`, - `StoryGenerationElement`, `ImageGenerationElement` are thin subclasses that bind a specific `MainService` type and inherit this contract. -- `DatabaseElement` — the lower-level `@adobe/data-lit` base used by apps with no intermediate `*Element` layer (e.g. - `HelloWorldElement`). - -Plain `LitElement` subclasses that expose their API through `@property` alone (shared UI primitives like `media-timeline`, `model-picker`, -`toast-with-image`) are **not** binding elements. They don't subscribe to a service; their props are the entire contract. Out of scope for -this rule. - -Presentation files (`*-presentation.ts`), CSS files (`*.css.ts`), tests (`*.test.ts`, `*.spec.ts`), and stories are also out of scope. - -## The shape - -A binding element is a thin wire between a service and a presentation. The class body contains: - -1. Optional static `styles` property definition -2. Zero or more `@property` declarations (allowed below). -3. One `render()` method whose body is: one `useObservableValues(...)` call, another lifecycle hook call (in rare cases), a null-guard, and - `return presentation.render({ ...values, ...callbacks })`. - -No other members. No `@state`. No private handler fields. No lifecycle methods (`connectedCallback`, `updated`, `firstUpdated`, -`disconnectedCallback`) except on app-entrypoint classes where the lifecycle concern is app-boot shaped but cannot be expressed as a hook -(see `hooks.md` for hook-first lifecycle). - -## `@property` allowlist - -The allowed set depends on **class role**: - -- **App entrypoint classes** — the (typically empty-body) subclass of `ApplicationElement` that binds a specific `MainService` type. - Examples: `VideoGenerationElement`, `HelloWorldElement`, `TodoElement`. These accept injected services and top-level inputs from the host - shell. -- **Concrete binding elements** — classes that extend an app entrypoint (or another concrete binding) and render UI. Examples: `Hud`, - `FooRotationControlElement`, `CommunitySubmissionConnector`. These accept only: - - Entity-id-shaped locator props (`entityId`, `entityIds`, `trackId`) that identify _where_ to load this element's data from. - - Rare parent-forwarded flags passed straight to the presentation without branching on them (e.g. `highVolumeFlag` in `audio-view.ts`). - Prefer promoting the flag into a service computed. - -All other values come from the service. - -## Observed values must be raw reads - -Every expression inside `useObservableValues` is either a direct read of a service observable (`this.service.observe.*`) or a direct read of -a computed (`this.service.computed.*`). No `.map`, `.filter`, `.reduce`, arithmetic, conditionals, or `Observe.withMap`-inline composition. - -If derivation is needed: ECS Database apps place it in a database computed (`plugins/computed`, read back as `this.service.computed.*`); -non-ECS apps place it in a dependent-state service function (`dependent-state-service`). If it is used only by this element, define it -locally in a sibling file and import it. Computed and dependent-state must use utilities from `Obsereve.*` rather than trying to call -observable functions. - -**Bad - doesn't use utilities from `Obsereve.*`** - -```ts -return notify => { - const unobserve = oValue(value => { - notify(value ** 2); - }); - return unobserve; -}; -``` - -**Good - uses utilities from `Obsereve.*`** - -```ts -return Observe.withMap(oValue => value ** 2); -``` - -## Callbacks are one-liners - -- Each callback passed to the presentation invokes a UI action with optional arguments or a transaction with optional arguments. -- Callbacks must not encode domain knowledge, branch on state, or build complex shapes. Logic belongs in the UI action layer or transactions - layer. -- UI action functions and transactions must be called only via `this.service`, they must never be imported directly. -- Never consume the return value of a UI action or transaction call. -- Simple argument transformations are allowed — arithmetic, negation, fixed arguments, simple ternaries that select an argument value. - -```ts -render() { - return presentation.render({ - // UI action call without parameters — good - saveProgress: this.service.actions.saveProgress, - // UI action call with fixed argument — good - setLandscape: () => this.service.actions.setOrientation('landscape'), - // UI action call with simple ternary — good - jump: (heroMode: boolean) => this.service.actions.move(heroMode ? 100 : 10), - // transaction call - good - play: this.service.actions.playTrack - }); -} -``` - -## No derivation math in `render()` - -**Derivation math** is forbidden in `render()`. Simple argument assembly in callbacks is the only exception — see **Action callbacks are -one-liners** above. - -## Lifecycle goes through hooks - -Any need for mount/unmount/update behavior routes through a hook. **Reuse order, one hook per file, presentation vs binding placement, and -testing notes:** `hooks.md`. - -Never inline lifecycle logic in the binding. App-entrypoint classes may override lifecycle methods when the concern is app-boot shaped -(service wiring, ancestor lookup); even then, prefer a hook. - -Rare cases when an additional hook can be used in `render()`: - -- focus on element -- scroll to element -- clean up on un-mount -- and other lifecycle-bound behaviour - -Use `useEffect` for cleanup on unmount rather than `disconnectedCallback`: - -```ts -render() { - useEffect(() => { - return () => this.service.actions.someCleanup(); - }, []); // empty array → runs cleanup on unmount only - - const values = useObservableValues(() => ({ ... })); - if (!values) return nothing; - return presentation.render(values); -} -``` - -In binding-elements `render()` doesn't need to be explicitly decorated with `withHooks()` because it's done implicitly in the -`ApplicationElement` base class. - -## No timers, emitters, or broadcast channels - -- Never `setInterval`, `setTimeout`, or `requestAnimationFrame`. -- Never `new EventEmitter`. Cross-element state flows through service observables. -- Never `new BroadcastChannel`. Cross-tab concerns go through a dedicated service. - -## No direct database reads - -Never call `this.service.get()`, `this.service.read()`, `this.service.select()`, or any synchronous accessor. The service interface does not -expose them by design; reaching for them means a missing computed. - -## Presentation contract - -A binding element imports a sibling `*-presentation.ts` file that exports exactly `render` (required) and optionally `unlocalized`. Nothing -else — no types, no styles, no helpers. Consumers derive the props type via `Parameters[0]`. Presentation stays hook-free by -default; element hooks live here or in shared modules per `hooks.md` and `features/ui/presentation.md`. - -**For refactors whose stated scope does not include presentations**, the matching `*-presentation.ts` file must not change. Verify with -`git diff --name-only | grep presentation`. If a scope-limited refactor requires a presentation signature change, stop and reconsider the -binding-side helper — the scope fence exists because presentations are the public contract between binding and render output. - -Callback prop names use **verb** or **verbNoun** form. Never `on*` prefix. The binding-side variable matches the presentation prop name 1:1. -See `features/ui/presentation.md` for full examples. - -## Size budget - -A compliant binding element file is small enough that the `render()` body fits on screen without scrolling. If it doesn't, a concern has not -been extracted yet — find it. - -## Deletion test - -Delete everything except the class body. What remains should be: static styles assignment, property declarations, one -`useObservableValues(...)` call, another lifecycle hook call (in rare cases), a null-guard, and one render call. Anything else is a -violation. - -## Good examples (in-tree) - -- `src/applications/image-generation/elements/sub-navigation-panel/sub-navigation-panel.ts` — concrete binding element; one-liner action - callback -- `src/applications/foo/elements/properties-panel/views/audio-view.ts` — entity-id-only + parent-forwarded flag -- `src/applications/image-editor/elements/hud/hud.ts` — concrete binding element (extends `ImageEditorElement`); `useObservableValues` reads - a dependentState observable; forwards to presentation with spread. -- `src/applications/video-generation/elements/commerce-coachmark/commerce-coachmark.ts` — localization-only binding; trivially small and - compliant. - -## Counter-example (do not land) - -```ts -@customElement(tagName) -export class BadControlElement extends FooElement { - @property({ attribute: false }) entityIds: readonly Entity[] = []; - @state() private _cachedSum = 0; // violation: @state - private _handleChange?: (e: Event) => void; // violation: private handler - - connectedCallback() { - // violation: lifecycle override - super.connectedCallback(); - this._handleChange = e => (this._cachedSum += Number((e.target as HTMLInputElement).value)); - } - - @withHooks // Redundant - render() { - const values = useObservableValues(() => ({ - items: this.service.observe.select(/* ... */), - total: Observe.withMap( - // violation: logic in observed values - this.service.observe.select(/* ... */), - rows => rows.reduce((a, r) => a + r.duration, 0) - ), - })); - const clamped = Math.max(0, Math.min(100, values.total)); // violation: derivation in render - const onSave = (v: number) => { - // violation: logic in callback + onX name - const normalized = Math.round(v * 100) / 100; - if (!Number.isFinite(normalized)) return; - this.service.actions.updateThing({ value: normalized }); - }; - return presentation.render({ total: clamped, onSave }); - } -} -``` diff --git a/packages/data-ai/.claude/rules/features/ui/index.md b/packages/data-ai/.claude/rules/features/ui/index.md index d38b07caf..db46e5eba 100644 --- a/packages/data-ai/.claude/rules/features/ui/index.md +++ b/packages/data-ai/.claude/rules/features/ui/index.md @@ -6,6 +6,8 @@ paths: # ui/ — user interface +> **Legacy feature** (has `data/state/` or `services/main-service/`)? Its data and services follow the legacy rules; the ui rules here still apply. + The top layer: UI that binds the feature's `MainService` database (`ecs/main-service.ts`) to what the user sees. It owns no business logic — it subscribes to state, hands it to a pure presentation, and wires user input to `service.actions.*`. diff --git a/packages/data-ai/.claude/rules/service.md b/packages/data-ai/.claude/rules/service.md index 6f357110a..4e328d65a 100644 --- a/packages/data-ai/.claude/rules/service.md +++ b/packages/data-ai/.claude/rules/service.md @@ -6,7 +6,7 @@ paths: # Service authoring -> Inside a feature, `features/services/index.md` governs the folder layout: a small service keeps `create.ts` and `create-fake.ts` flat beside the interface (the single-file exception below); one with helpers moves its implementation into a sub-folder. Database-bound factories in `ecs/services/` follow `features/ecs/services.md`. +> Inside a feature, `features/services/index.md` covers the service layer; database-bound factories in `ecs/services/` follow `features/ecs/services.md`. Asynchronous data services. Live in the `services/` layer. Adhere to the namespace rule for type and function organization. @@ -44,74 +44,26 @@ Compile-time check: `Assert>`. --- -## Folder structure and cohesion - -**MUST NOT** place implementation files alongside the interface file. The `-service/` folder MUST contain only the interface file and -`public.ts`. +## Folder structure ``` services/-service/ - -service.ts ← interface + contract types; `export * as ServiceName from './public.js'` at bottom - public.ts ← re-exports factory (and other public exports) from the implementation sub-folder - / ← REQUIRED sub-folder for all implementation files - create--service.ts ← factory function - *.ts ← helpers, utils, constants (implementation-private) - *.test.ts ← tests live here alongside implementation -``` - -**Single-file exception**: a bare factory with no helpers, no utilities, and no constants MAY live as a flat file alongside the interface. -The moment any additional implementation file is needed, the factory MUST move into a sub-folder. - -**Interface file** (`-service.ts`) — interface declaration and types that define the public service contract. MUST NOT import from any -implementation file or sub-folder. At the bottom of the file, exports the namespace: `export * as ServiceName from './public.js'`. This -merges the interface type and the factory namespace under one identifier — callers type against the interface and invoke factory functions -through the same name. - -**Separate types file at the interface level** — only permitted if it contains exclusively public-contract types (types that appear in the -interface's method signatures or `Observe<>` generic arguments). Implementation-detail types — third-party API shapes, `window.*` global -augmentations, internal utilities — MUST live inside the implementation sub-folder. - -**`*.test.ts` files MUST NOT appear at the interface folder level.** Every test file belongs inside the implementation sub-folder next to -the code it tests; there is nothing at the interface level to test. - -**Implementation sub-folder** — all implementation logic including the factory, utilities, constants, helpers, and their tests. MUST NOT -depend on the interface file's types via the `public.js` re-export; import directly from `-service.js`. - -**public.ts** — MUST re-export the factory function and any other public exports from the implementation sub-folder. Does NOT re-export the -interface — the interface is exported directly from `-service.ts`. - -**No classes** — factory function or static plain object only. `this` bindings are brittle and block higher-order composition. - ---- - -## Factory export shape — `singletonFactory` + dual export - -Every service factory MUST be wrapped with `singletonFactory` from `factory-functions.js`. This guarantees one instance per argument tuple -when the same factory is called multiple times, preventing duplicate subscriptions and torn state. - -Export **both** the raw `factory` and the singleton-wrapped `create`: - -```ts -// -service//create--service.ts -import { singletonFactory } from 'factory-functions.js'; - -export const factory = (deps: Deps): NameService => { - // implementation -}; - -// `factory` — for test injection (bypasses the singleton cache; each test gets a fresh instance) -// `create` — for production use (singleton per deps-tuple) -export const create = singletonFactory(factory); -``` - -`public.ts` re-exports both: - -```ts -export { create, factory } from './/create--service.js'; + -service.ts ← interface + contract types; `export * as ServiceName from './public.js'` at bottom + create.ts ← the implementation factory (or a factory that throws when the app must inject it) + create-fake.ts ← an inert fake with every method present + public.ts ← re-exports `create` and `createFake` ``` -**Why:** Tests must create fresh instances without sharing a singleton cache. Production callers use `create`; tests that need isolation -import `factory` directly. +- **Interface file** (`-service.ts`) — the interface and the types in its method + signatures. It never imports an implementation file. The namespace export at the + bottom merges the interface type and the factories under one name: callers type + against the interface and call `ServiceName.create`. +- **An implementation that grows helpers** moves into a named sub-folder + (`create--service/` with its helpers and their tests); `public.ts` re-exports + its factory. Implementation-only types (third-party API shapes, `window.*` + augmentations) live there too, never in the interface file. +- **No classes** — factory functions or plain objects only. `this` bindings are brittle + and block higher-order composition. --- @@ -124,7 +76,7 @@ identifiers in any method signature, property name, or type parameter. ```ts interface SessionService extends Service { - readonly dunamis: DunamisSdk; // UI now knows Dunamis exists + readonly analyticsSdk: AnalyticsSdk; // UI now knows which SDK is used } ``` @@ -136,7 +88,7 @@ interface SessionService extends Service { } ``` -The interface is the contract between UI and data layers. If the underlying tool is swapped (Dunamis → another SDK, cookies → localStorage), +The interface is the contract between UI and data layers. If the underlying tool is swapped (one SDK → another, cookies → localStorage), the interface must not change. Any leak of an internal tool name into the interface couples the UI to an implementation detail it must not know about. @@ -146,21 +98,19 @@ know about. When creating or modifying a service: -1. Place in `services/-service/`. +1. Place it in `services/-service/`. 2. Interface in `-service.ts` — types only, extend `Service`, add `Assert>`. -3. All implementation files go inside a named sub-folder (e.g. `create--service/`). +3. Add `create.ts`, `create-fake.ts` and `public.ts`; move the implementation into a sub-folder once it needs helpers. 4. At the bottom of `-service.ts`, add `export * as ServiceName from './public.js'`. -5. In `public.ts`, re-export the factory (and any other public exports) from the implementation sub-folder. -6. For front-end services: actions return void only; observables observe Data or Service only. For back-end services: functions return +5. For front-end services: actions return void only; observables observe Data or Service only. For back-end services: functions return `Promise` or `AsyncGenerator`. --- ## Verify -When any `src/**/services/**/*.ts` file is in the diff, confirm all three invariants: +When any `services/**` file is in the diff, confirm: -1. **Interface/implementation separation** — every `.ts` file added or modified in `-service/` is either `-service.ts`, - `public.ts`, or inside a named sub-folder. Any file at the root of the folder that is neither is a violation. -2. **`public.ts` completeness** — `public.ts` re-exports the factory function (and any other public exports from the sub-folder). -3. **Interface file purity** — `-service.ts` contains no imports from implementation sub-folders. +1. **Interface file purity** — `-service.ts` imports no implementation file. +2. **`public.ts` completeness** — it re-exports `create` and `createFake` (and any other public factory). +3. **No implementation-only types in the interface** — they live with the implementation. diff --git a/packages/data-ai/package.json b/packages/data-ai/package.json index 12c1efee7..18a8cf18f 100644 --- a/packages/data-ai/package.json +++ b/packages/data-ai/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-ai", - "version": "0.10.23", + "version": "0.10.24", "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).", "type": "module", "private": false, diff --git a/packages/data-ai/skills/build-actions/SKILL.md b/packages/data-ai/skills/build-actions/SKILL.md index 798051183..21995ea14 100644 --- a/packages/data-ai/skills/build-actions/SKILL.md +++ b/packages/data-ai/skills/build-actions/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create `ecs/actions/`: `action-database.ts` (extends the previous layer, adds `actions` from `./index.js`), one action per file, and a barrel. Every spec action has a same-named action. It awaits services and commits through at most one transaction diff --git a/packages/data-ai/skills/build-app-entry/SKILL.md b/packages/data-ai/skills/build-app-entry/SKILL.md index 11797c649..e9695e125 100644 --- a/packages/data-ai/skills/build-app-entry/SKILL.md +++ b/packages/data-ai/skills/build-app-entry/SKILL.md @@ -5,6 +5,8 @@ input: app output: app --- +A legacy feature (has `data/state/` or `services/main-service/`) exposes its `MainService` from `services/main-service/`; compose it from there. + Create `src/app/`: - `schema.ts` — the composed plugin: the base feature's `MainService.plugin`, `imports` diff --git a/packages/data-ai/skills/build-computed/SKILL.md b/packages/data-ai/skills/build-computed/SKILL.md index e84494032..2fac87f4d 100644 --- a/packages/data-ai/skills/build-computed/SKILL.md +++ b/packages/data-ai/skills/build-computed/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Skip if the spec has no derivations and the UI needs no derived values. Create `ecs/computed/`: `computed-database.ts` (extends the previous layer, adds diff --git a/packages/data-ai/skills/build-core-database/SKILL.md b/packages/data-ai/skills/build-core-database/SKILL.md index c603eeb16..91746c1b6 100644 --- a/packages/data-ai/skills/build-core-database/SKILL.md +++ b/packages/data-ai/skills/build-core-database/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create: - `ecs/core/archetypes.ts` — `Database.archetypes(components, { … })`, spreading each diff --git a/packages/data-ai/skills/build-data/SKILL.md b/packages/data-ai/skills/build-data/SKILL.md index 61e76762f..d094c5a7b 100644 --- a/packages/data-ai/skills/build-data/SKILL.md +++ b/packages/data-ai/skills/build-data/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create the feature's `data/` layer: pure Data declarations, depending only on `@adobe/data` and the `data/` of features this one builds on. diff --git a/packages/data-ai/skills/build-feature/SKILL.md b/packages/data-ai/skills/build-feature/SKILL.md index 2ef8b6165..d3ebf9669 100644 --- a/packages/data-ai/skills/build-feature/SKILL.md +++ b/packages/data-ai/skills/build-feature/SKILL.md @@ -5,6 +5,9 @@ input: feature output: feature --- +If the feature already has `data/state/` or `services/main-service/`, it uses the legacy +state-based layout: extend it in place per those legacy rules; don't migrate it unless asked. + /graph-execute /build-data /build-services diff --git a/packages/data-ai/skills/build-indexes/SKILL.md b/packages/data-ai/skills/build-indexes/SKILL.md index 57a93ad4f..ec310ed7e 100644 --- a/packages/data-ai/skills/build-indexes/SKILL.md +++ b/packages/data-ai/skills/build-indexes/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Skip if the feature needs no indexed lookups. Create `ecs/indexes/`: `index-database.ts` (extends `CoreDatabase`, adds `indexes` from diff --git a/packages/data-ai/skills/build-service-database/SKILL.md b/packages/data-ai/skills/build-service-database/SKILL.md index 5854880c9..120d97b17 100644 --- a/packages/data-ai/skills/build-service-database/SKILL.md +++ b/packages/data-ai/skills/build-service-database/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Skip if the feature has no services. Create `ecs/services/service-database.ts` (extends the previous layer), registering diff --git a/packages/data-ai/skills/build-services/SKILL.md b/packages/data-ai/skills/build-services/SKILL.md index f6b84b2fe..c78c1e4fb 100644 --- a/packages/data-ai/skills/build-services/SKILL.md +++ b/packages/data-ai/skills/build-services/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Skip if the feature talks to nothing outside itself. Create one `services/-service/` namespace per service: the interface (an async diff --git a/packages/data-ai/skills/build-spec/SKILL.md b/packages/data-ai/skills/build-spec/SKILL.md index 120799151..b61273f18 100644 --- a/packages/data-ai/skills/build-spec/SKILL.md +++ b/packages/data-ai/skills/build-spec/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create the feature's `spec/` folder, the specification the ECS will be conformed to: - `state.ts` — `State` built from `data/` types (singletons named for resources, diff --git a/packages/data-ai/skills/build-systems/SKILL.md b/packages/data-ai/skills/build-systems/SKILL.md index 902d86714..97020219f 100644 --- a/packages/data-ai/skills/build-systems/SKILL.md +++ b/packages/data-ai/skills/build-systems/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Skip unless the feature is real-time (games, simulations). Create `ecs/systems/system-database.ts`: `Database.Plugin.create({ extends: diff --git a/packages/data-ai/skills/build-transactions/SKILL.md b/packages/data-ai/skills/build-transactions/SKILL.md index e7248ce32..deb7491c7 100644 --- a/packages/data-ai/skills/build-transactions/SKILL.md +++ b/packages/data-ai/skills/build-transactions/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create `ecs/transactions/`: `transaction-database.ts` (extends the previous layer, adds `transactions` from `./index.js`), one mutation per file, and a barrel. Type the store `CoreDatabase.Store`, or `IndexDatabase.Store` once it reads an index. Guard by entity diff --git a/packages/data-ai/skills/build-ui/SKILL.md b/packages/data-ai/skills/build-ui/SKILL.md index ef2f2eb57..1a7b188c8 100644 --- a/packages/data-ai/skills/build-ui/SKILL.md +++ b/packages/data-ai/skills/build-ui/SKILL.md @@ -5,6 +5,8 @@ input: feature output: feature --- +Legacy feature (has `data/state/` or `services/main-service/`)? Extend it in place per the legacy rules; don't migrate unless asked. + Create `ui/`: one folder per UI unit — `/{.ts (lazy wrapper), -element.ts (container), -presentation.ts (pure render), .css.ts}`. The element subscribes to `MainService` state and calls `service.actions.*`; the diff --git a/packages/data-ai/skills/review/SKILL.md b/packages/data-ai/skills/review/SKILL.md index 2e288d84c..294fc3d46 100644 --- a/packages/data-ai/skills/review/SKILL.md +++ b/packages/data-ai/skills/review/SKILL.md @@ -6,6 +6,8 @@ input: a path to built output (a layer, a feature, or a whole app) output: a review — OPTIMAL, or a list of issues each tagged code | skill | rule --- +Check a legacy feature (has `data/state/` or `services/main-service/`) against the legacy rules; don't flag the layout itself. + Read the files under the given path and assess them against the relevant `features/` rules (each layer's rule and the cross-cutting rules — `namespace`, `data-modelling`, `type-casts`, `cohesion`, …): diff --git a/packages/data-ai/skills/structure/SKILL.md b/packages/data-ai/skills/structure/SKILL.md index 0d1f126aa..d3133a9b8 100644 --- a/packages/data-ai/skills/structure/SKILL.md +++ b/packages/data-ai/skills/structure/SKILL.md @@ -3,5 +3,7 @@ name: structure description: Use when laying out or reasoning about a feature's file/folder structure and layer dependencies. --- +Legacy feature (has `data/state/` or `services/main-service/`)? It keeps its state-based layout (the legacy rules); don't migrate unless asked. + See `features/index.md` (and the per-folder `features/` rules) for how features are structured and layered. diff --git a/packages/data-gpu-hopper/src/features/main/data/components/kind.ts b/packages/data-gpu-hopper/src/features/main/data/components/kind.ts index eed35fecd..c35ebab04 100644 --- a/packages/data-gpu-hopper/src/features/main/data/components/kind.ts +++ b/packages/data-gpu-hopper/src/features/main/data/components/kind.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { HazardKind } from "../values/hazard-kind/hazard-kind.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/lane.ts b/packages/data-gpu-hopper/src/features/main/data/components/lane.ts index b1d6ea42b..fe6a4fc18 100644 --- a/packages/data-gpu-hopper/src/features/main/data/components/lane.ts +++ b/packages/data-gpu-hopper/src/features/main/data/components/lane.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; // The board row a hazard moves along. diff --git a/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts b/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts index 89c2c8f9f..47a0176c1 100644 --- a/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts +++ b/packages/data-gpu-hopper/src/features/main/data/components/velocity.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; -import { F32, type Schema } from "@adobe/data/schema"; +import { F32, type Schema, Scope } from "@adobe/data/schema"; // A hazard's signed speed in cells/second. export const velocity = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/width.ts b/packages/data-gpu-hopper/src/features/main/data/components/width.ts index 1b86313a3..020bba2f8 100644 --- a/packages/data-gpu-hopper/src/features/main/data/components/width.ts +++ b/packages/data-gpu-hopper/src/features/main/data/components/width.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; -import { F32, type Schema } from "@adobe/data/schema"; +import { F32, type Schema, Scope } from "@adobe/data/schema"; // A hazard's width in cells. export const width = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/components/x.ts b/packages/data-gpu-hopper/src/features/main/data/components/x.ts index c7545b956..deb2d0949 100644 --- a/packages/data-gpu-hopper/src/features/main/data/components/x.ts +++ b/packages/data-gpu-hopper/src/features/main/data/components/x.ts @@ -1,6 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; -import { F32, type Schema } from "@adobe/data/schema"; +import { F32, type Schema, Scope } from "@adobe/data/schema"; // A hazard's left edge, a continuous column. export const x = { ...F32.schema, ...Scope.session } satisfies Schema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/board-height.ts b/packages/data-gpu-hopper/src/features/main/data/resources/board-height.ts new file mode 100644 index 000000000..7a13900c6 --- /dev/null +++ b/packages/data-gpu-hopper/src/features/main/data/resources/board-height.ts @@ -0,0 +1,6 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Scope } from "@adobe/data/schema"; +import type { ResourceSchema } from "@adobe/data/ecs"; + +// Board height in rows. +export const boardHeight = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/width.ts b/packages/data-gpu-hopper/src/features/main/data/resources/board-width.ts similarity index 51% rename from packages/data-gpu-hopper/src/features/main/data/resources/width.ts rename to packages/data-gpu-hopper/src/features/main/data/resources/board-width.ts index 22aed284e..ea29fa931 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/width.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/board-width.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // Board width in columns. Every hopper resource is session scope. -export const width = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; +export const boardWidth = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts b/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts index 637bcc15b..a2778923d 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/frame-delta.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // Seconds the next tick advances. The render loop feeds it from the frame clock; headless diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts b/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts index 1ebccdeb5..186beff86 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/frog.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Frog } from "../values/frog/frog.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/height.ts b/packages/data-gpu-hopper/src/features/main/data/resources/height.ts deleted file mode 100644 index c1b46ce47..000000000 --- a/packages/data-gpu-hopper/src/features/main/data/resources/height.ts +++ /dev/null @@ -1,6 +0,0 @@ -// © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; -import type { ResourceSchema } from "@adobe/data/ecs"; - -// Board height in rows. -export const height = { type: "integer", minimum: 1, default: 9, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/index.ts b/packages/data-gpu-hopper/src/features/main/data/resources/index.ts index 034d72af4..7fbce4937 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/index.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/index.ts @@ -1,6 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { width } from "./width.js"; -import { height } from "./height.js"; +import { boardWidth } from "./board-width.js"; +import { boardHeight } from "./board-height.js"; import { lanes } from "./lanes.js"; import { frog } from "./frog.js"; import { lives } from "./lives.js"; @@ -8,4 +8,4 @@ import { score } from "./score.js"; import { status } from "./status.js"; import { frameDelta } from "./frame-delta.js"; -export const resources = { width, height, lanes, frog, lives, score, status, frameDelta }; +export const resources = { boardWidth, boardHeight, lanes, frog, lives, score, status, frameDelta }; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts b/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts index bea15b81f..ef44611f0 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/lanes.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Lane } from "../values/lane/lane.js"; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts b/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts index dff215368..8fa38c810 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/lives.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; export const lives = { type: "integer", minimum: 0, default: 3, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/score.ts b/packages/data-gpu-hopper/src/features/main/data/resources/score.ts index 71bcf7288..4a1fa944e 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/score.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/score.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; export const score = { type: "integer", minimum: 0, default: 0, ...Scope.session } as const satisfies ResourceSchema; diff --git a/packages/data-gpu-hopper/src/features/main/data/resources/status.ts b/packages/data-gpu-hopper/src/features/main/data/resources/status.ts index f7e76674b..89b7dab46 100644 --- a/packages/data-gpu-hopper/src/features/main/data/resources/status.ts +++ b/packages/data-gpu-hopper/src/features/main/data/resources/status.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { GameStatus } from "../values/game-status/game-status.js"; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts b/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts index e5b989542..890760968 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/conformance/projection.ts @@ -29,8 +29,8 @@ export const projection = { for (const arch of store.queryArchetypes(store.archetypes.Hazard.components)) { for (let row = arch.rowCount - 1; row >= 0; row--) store.delete(arch.columns.id.get(row)); } - store.resources.width = state.width; - store.resources.height = state.height; + store.resources.boardWidth = state.boardWidth; + store.resources.boardHeight = state.boardHeight; store.resources.lanes = state.lanes; store.resources.frog = state.frog; store.resources.lives = state.lives; @@ -41,8 +41,8 @@ export const projection = { } }, toState: (store: CoreDatabase.Store): State => ({ - width: store.resources.width, - height: store.resources.height, + boardWidth: store.resources.boardWidth, + boardHeight: store.resources.boardHeight, lanes: store.resources.lanes, entities: readEntities(store), frog: store.resources.frog, diff --git a/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts index c1e3f5238..d84c981d3 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/systems/system-database.ts @@ -26,7 +26,7 @@ const systemDatabasePlugin = Database.Plugin.create({ const { resources } = db.store; if (!GameStatus.isPlaying(resources.status)) return; const dt = resources.frameDelta; - const { width, frog } = resources; + const { boardWidth: width, frog } = resources; const hazards = db.store.archetypes.Hazard.components; const lane = Lane.at(resources.lanes, frog.y); @@ -65,7 +65,7 @@ const systemDatabasePlugin = Database.Plugin.create({ if (lane === undefined) return; let covered = false; - if (Frog.onBoard(frog, resources.width)) { + if (Frog.onBoard(frog, resources.boardWidth)) { for (const arch of db.store.queryArchetypes(db.store.archetypes.Hazard.components)) { const { lane: laneCol, x: xCol, width: widthCol } = arch.columns; for (let i = 0; i < arch.rowCount && !covered; i++) { diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts index d281a0d77..db7f52b81 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/hop.ts @@ -8,5 +8,5 @@ import type { CoreDatabase } from "../core/core-database.js"; export const hop = (t: CoreDatabase.Store, { direction }: { readonly direction: Direction }) => { const { resources } = t; if (!GameStatus.isPlaying(resources.status)) return; - resources.frog = Frog.hop(resources.frog, direction, resources.width, resources.height); + resources.frog = Frog.hop(resources.frog, direction, resources.boardWidth, resources.boardHeight); }; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts index 2b41c2102..47322f1d0 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/lose-life.ts @@ -13,5 +13,5 @@ export const loseLife = (t: CoreDatabase.Store) => { return; } resources.lives = lives; - resources.frog = Frog.start(resources.width); + resources.frog = Frog.start(resources.boardWidth); }; diff --git a/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts b/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts index 9e8afdcd2..abc974057 100644 --- a/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts +++ b/packages/data-gpu-hopper/src/features/main/ecs/transactions/new-game.ts @@ -11,8 +11,8 @@ export const newGame = (t: CoreDatabase.Store) => { for (const arch of t.queryArchetypes(t.archetypes.Hazard.components)) { for (let row = arch.rowCount - 1; row >= 0; row--) t.delete(arch.columns.id.get(row)); } - t.resources.width = width; - t.resources.height = height; + t.resources.boardWidth = width; + t.resources.boardHeight = height; t.resources.lanes = lanes; t.resources.frog = Frog.start(width); t.resources.lives = lives; diff --git a/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts b/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts index 5612bff5d..b28b80a63 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/case-boards.ts @@ -17,8 +17,8 @@ export const riverLanes: readonly Lane[] = [ // A full in-play state on the road board, frog resting on grass, no hazards. export const board = (overrides: Partial): State => ({ - width: 5, - height: 3, + boardWidth: 5, + boardHeight: 3, lanes: roadLanes, entities: new Map(), frog: { x: 2, y: 0 }, diff --git a/packages/data-gpu-hopper/src/features/main/spec/collision.ts b/packages/data-gpu-hopper/src/features/main/spec/collision.ts index 08ee83fab..1bc28e9f4 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/collision.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/collision.ts @@ -11,7 +11,7 @@ type CollisionPatch = Pick; // The `collision` system: resolve the frog's fate where it stands — score a win, or on // a fatal outcome spend a life (respawn, or game over on the last life). export const collision = ( - state: Pick, + state: Pick, ): CollisionPatch => { const held: CollisionPatch = { frog: state.frog, lives: state.lives, score: state.score, status: state.status }; return whilePlaying(state.status, held, () => { diff --git a/packages/data-gpu-hopper/src/features/main/spec/create.test.ts b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts index 05e3f5840..5d2af6326 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/create.test.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/create.test.ts @@ -15,12 +15,12 @@ describe("State.create", () => { }); it("spawns the frog centred on the bottom row", () => { - expect(state.frog).toEqual(Frog.start(state.width)); + expect(state.frog).toEqual(Frog.start(state.boardWidth)); expect(state.frog.y).toBe(0); }); it("has a lane for every row of the board", () => { - for (let row = 0; row < state.height; row++) { + for (let row = 0; row < state.boardHeight; row++) { expect(Lane.at(state.lanes, row)).toBeDefined(); } }); diff --git a/packages/data-gpu-hopper/src/features/main/spec/create.ts b/packages/data-gpu-hopper/src/features/main/spec/create.ts index 829fcaa83..9c68fbf7b 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/create.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/create.ts @@ -8,8 +8,8 @@ import { Frog } from "../data/values/frog/frog.js"; export const create = (): State => { const { width, height, lanes, hazards, lives } = Level.initial; return { - width, - height, + boardWidth: width, + boardHeight: height, lanes, entities: new Map(hazards.map((hazard, index) => [index + 1, hazard])), frog: Frog.start(width), diff --git a/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts index 9023d2865..90229edf3 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.test.ts @@ -13,7 +13,7 @@ const lanes: readonly Lane[] = [ ]; const board = (hazards: readonly Hazard[], x: number, y: number): StateType => ({ - width: 5, height: 4, lanes, + boardWidth: 5, boardHeight: 4, lanes, entities: new Map(hazards.map((hazard, index) => [index + 1, hazard])), frog: { x, y }, lives: 3, score: 0, status: "playing", }); diff --git a/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts index 6cf188591..5e06f6565 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/frog-outcome.ts @@ -7,11 +7,11 @@ import type { State } from "./state.js"; // The frog's fate on the current board: its lane's terrain, and whether a hazard on // that lane covers it. A frog carried off the board edge is covered by nothing. -export const frogOutcome = (state: Pick): Outcome => { +export const frogOutcome = (state: Pick): Outcome => { const lane = Lane.at(state.lanes, state.frog.y); if (!lane) return "safe"; const covered = - Frog.onBoard(state.frog, state.width) && + Frog.onBoard(state.frog, state.boardWidth) && [...state.entities.values()].some( (hazard) => hazard.lane === state.frog.y && Lane.coversAt(hazard.x, hazard.width, state.frog.x), ); diff --git a/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts index ce07efcf1..d8fcd0ce4 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/hop.cases.ts @@ -6,8 +6,8 @@ import type { hop } from "./hop.js"; // A bare 5-wide, 3-tall board. `hop` reads only frog / width / height / status, // so the lanes and hazards are irrelevant and left empty here. const base: Omit = { - width: 5, - height: 3, + boardWidth: 5, + boardHeight: 3, lanes: [], entities: new Map(), lives: 3, diff --git a/packages/data-gpu-hopper/src/features/main/spec/hop.ts b/packages/data-gpu-hopper/src/features/main/spec/hop.ts index 0a5222141..a3fc6ef15 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/hop.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/hop.ts @@ -7,9 +7,9 @@ import type { State } from "./state.js"; // Hop the frog one cell in `direction` (see `Frog.hop`). A no-op unless the game is in // play. Writes only `frog`. export const hop = ( - state: Pick, + state: Pick, { direction }: { readonly direction: Direction }, ): Pick => GameStatus.isPlaying(state.status) - ? { frog: Frog.hop(state.frog, direction, state.width, state.height) } + ? { frog: Frog.hop(state.frog, direction, state.boardWidth, state.boardHeight) } : { frog: state.frog }; diff --git a/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts index f09395d2a..905d254ba 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/lose-life.ts @@ -7,12 +7,12 @@ import type { State } from "./state.js"; // no-op once the game has finished. A `collision` sub-transition, not an operation of // its own. export const loseLife = ( - state: Pick, + state: Pick, ): Pick => { if (!GameStatus.isPlaying(state.status)) return { lives: state.lives, status: state.status, frog: state.frog }; const lives = state.lives - 1; return lives <= 0 ? { lives: 0, status: "gameOver", frog: state.frog } - : { lives, status: state.status, frog: Frog.start(state.width) }; + : { lives, status: state.status, frog: Frog.start(state.boardWidth) }; }; diff --git a/packages/data-gpu-hopper/src/features/main/spec/movement.ts b/packages/data-gpu-hopper/src/features/main/spec/movement.ts index e696ebd5c..8f2e1448d 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/movement.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/movement.ts @@ -7,7 +7,7 @@ import { whilePlaying } from "./while-playing.js"; // The `movement` system: scroll every hazard by `dt` and carry the frog on the log it // rides, found from the pre-scroll positions. export const movement = ( - state: Pick, + state: Pick, { dt }: { readonly dt: number }, ): Pick => whilePlaying(state.status, { entities: state.entities, frog: state.frog }, () => { @@ -20,7 +20,7 @@ export const movement = ( : undefined; const entities = new Map( [...state.entities].map( - ([id, hazard]) => [id, { ...hazard, x: Lane.nextX(hazard.x, hazard.velocity, dt, state.width) }] as const, + ([id, hazard]) => [id, { ...hazard, x: Lane.nextX(hazard.x, hazard.velocity, dt, state.boardWidth) }] as const, ), ); const frog = carrier ? { x: state.frog.x + carrier.velocity * dt, y: state.frog.y } : state.frog; diff --git a/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts index ef4f8a267..a1c9750b6 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/new-game.cases.ts @@ -12,8 +12,8 @@ export const cases: Conformance.SpecCases = { { name: "resets a mid-game store to the initial game", before: { - width: 3, - height: 3, + boardWidth: 3, + boardHeight: 3, lanes: [ { row: 0, kind: "grass" }, { row: 1, kind: "river" }, diff --git a/packages/data-gpu-hopper/src/features/main/spec/samples.ts b/packages/data-gpu-hopper/src/features/main/spec/samples.ts index 152ebe1d5..8f464dde6 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/samples.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/samples.ts @@ -9,8 +9,8 @@ import { create } from "./create.js"; export const samples: readonly State[] = [ create(), { - width: 5, - height: 3, + boardWidth: 5, + boardHeight: 3, lanes: [ { row: 0, kind: "grass" }, { row: 1, kind: "river" }, @@ -26,8 +26,8 @@ export const samples: readonly State[] = [ status: "playing", }, { - width: 4, - height: 2, + boardWidth: 4, + boardHeight: 2, lanes: [ { row: 0, kind: "grass" }, { row: 1, kind: "goal" }, diff --git a/packages/data-gpu-hopper/src/features/main/spec/state.ts b/packages/data-gpu-hopper/src/features/main/spec/state.ts index 82238ef26..f45fb8811 100644 --- a/packages/data-gpu-hopper/src/features/main/spec/state.ts +++ b/packages/data-gpu-hopper/src/features/main/spec/state.ts @@ -12,8 +12,8 @@ import type { resources } from "../data/resources/index.js"; // verified against. Every field but `entities` is a singleton (a resource); // `entities` holds the moving hazards keyed by a plain numeric id, the values id-less. export type State = { - readonly width: number; - readonly height: number; + readonly boardWidth: number; + readonly boardHeight: number; readonly lanes: readonly Lane[]; readonly entities: ReadonlyMap; readonly frog: Frog; diff --git a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts index 93e4c450e..41dfc7d73 100644 --- a/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts +++ b/packages/data-gpu-hopper/src/features/main/ui/render/hopper-render-plugin.ts @@ -57,8 +57,8 @@ const hopperRenderPlugin = Database.Plugin.create({ // and a fresh game. Called once when the canvas connects. initializeScene(t) { newGame(t); - const w = t.resources.width; - const h = t.resources.height; + const w = t.resources.boardWidth; + const h = t.resources.boardHeight; const cx = (w - 1) / 2; const cz = -(h - 1) / 2; const d = Math.max(w, h) * 1.15; @@ -163,7 +163,7 @@ const hopperRenderPlugin = Database.Plugin.create({ ensure(HazardKind.hazardColor.car); ensure(HazardKind.hazardColor.log); - const w = db.store.resources.width; + const w = db.store.resources.boardWidth; const cx = (w - 1) / 2; for (const lane of db.store.resources.lanes) { const meshAsset = ensure(LaneKind.laneColor[lane.kind]); diff --git a/packages/data-gpu/package.json b/packages/data-gpu/package.json index b6d906ba2..cc3384f11 100644 --- a/packages/data-gpu/package.json +++ b/packages/data-gpu/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-gpu", - "version": "0.10.23", + "version": "0.10.24", "description": "Adobe data WebGPU plugins and types for graphics and compute", "type": "module", "private": false, diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts index 8e439f635..5ef8f16d0 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/age.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { F32 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts index c7783e10e..82c403af2 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/position.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { Vec2 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts index 8f2da7ef8..60c98fec4 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/size.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { Size } from "../values/size/size.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts b/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts index 5c2e17102..d40678aba 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/components/velocity.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { Vec2 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts index 2d2f5419a..b68944662 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/bounds.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Vec2 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts index 0637fe87b..a3babcd5c 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/frame-delta.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { F32 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts index 1da38cf6f..beddb9bde 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/input.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Input } from "../values/input/input.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts index f17a09f23..7ac4ed829 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/lives.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Lives } from "../values/lives/lives.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts index 314e158fb..30372c448 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/score.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { F32 } from "@adobe/data/math"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts index 663afe9b8..fc1725b3a 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/ship.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Ship } from "../values/ship/ship.js"; diff --git a/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts b/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts index 5ae126786..5d9c2900e 100644 --- a/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts +++ b/packages/data-lit-space-rock-game/src/features/main/data/resources/wave.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Wave } from "../values/wave/wave.js"; diff --git a/packages/data-lit-todo/src/features/main/data/components/drag-position.ts b/packages/data-lit-todo/src/features/main/data/components/drag-position.ts index 4fa776f39..1229fadbf 100644 --- a/packages/data-lit-todo/src/features/main/data/components/drag-position.ts +++ b/packages/data-lit-todo/src/features/main/data/components/drag-position.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { Schema } from "@adobe/data/schema"; import { DragPosition } from "../values/drag-position/drag-position.js"; diff --git a/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts b/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts index bf6a842fa..efc3bf736 100644 --- a/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts +++ b/packages/data-lit-todo/src/features/main/data/resources/display-completed.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; import type { ResourceSchema } from "@adobe/data/ecs"; -import { Boolean } from "@adobe/data/schema"; +import { Boolean, Scope } from "@adobe/data/schema"; // Per-device view toggle: settings scope (durable, not shared). export const displayCompleted = { ...Boolean.schema, ...Scope.settings } satisfies ResourceSchema; diff --git a/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts b/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts index 8b41a03ac..702bddd45 100644 --- a/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts +++ b/packages/data-lit-todo/src/features/main/data/resources/selected-todo.ts @@ -1,5 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Entity, Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; +import { Entity } from "@adobe/data/ecs"; import type { ResourceSchema } from "@adobe/data/ecs"; // The selected todo: a reference to one entity (`Entity.none` = no selection). The diff --git a/packages/data-lit/package.json b/packages/data-lit/package.json index 2f45cc2bd..544d4acc3 100644 --- a/packages/data-lit/package.json +++ b/packages/data-lit/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-lit", - "version": "0.10.23", + "version": "0.10.24", "description": "Adobe data Lit bindings - hooks, elements, decorators", "type": "module", "private": false, diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts index 7595e5ff8..591b94a87 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/answer-code.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // The joiner's generated answer code. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts index 9ef25ac18..47e0f44cf 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-error.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; import type { ResourceSchema } from "@adobe/data/ecs"; -import { Boolean } from "@adobe/data/schema"; +import { Boolean, Scope } from "@adobe/data/schema"; // Whether the banner is styled as an error. export const bannerError = { ...Boolean.schema, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts index 1e0725605..f742f35bb 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/banner-text.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // The status banner text; empty hides the banner. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts index 09208807b..8cfe753df 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/connection.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { ConnectionState } from "../values/connection-state/connection-state.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts index 4d828cd19..60f3a1c89 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/game-db.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // The live synced game database, set once the peer link is up. A non-serializable diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts index 3ff5f6036..229b4b45f 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/host-answer-input.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // Live value of the host's "paste answer" textarea. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts index ea312ef38..2327a5f59 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/joiner-offer-input.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // Live value of the joiner's "paste invite" textarea. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts index 6e533c210..6d2269dc4 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/offer-code.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; // The host's generated invite code. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts index 8d47eb1e8..1d57b1a05 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/phase.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { Phase } from "../values/phase/phase.js"; diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts index dd0102e12..c4b8bf4d2 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/role.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; import type { ResourceSchema } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; +import { Schema, Scope } from "@adobe/data/schema"; import { Role } from "../values/role/role.js"; // This peer's side of the handshake; `null` until it hosts or joins. diff --git a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts index 5520997e4..b66a5f0bd 100644 --- a/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts +++ b/packages/data-p2p-tictactoe/src/features/negotiation/data/resources/session-id.ts @@ -1,7 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; import type { ResourceSchema } from "@adobe/data/ecs"; -import { Schema } from "@adobe/data/schema"; +import { Schema, Scope } from "@adobe/data/schema"; // The sync server's session id; `null` until the sync link is up. export const sessionId = { ...Schema.Nullable({ type: "string" }), default: null, ...Scope.session } satisfies ResourceSchema; diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts index 66a983cd5..beb2c01d4 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/conformance.test.ts @@ -1,54 +1,6 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { describe, it } from "vitest"; -import { Database, Store } from "@adobe/data/ecs"; -import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; -import { Conformance, Match } from "@adobe/data-testing"; -import { MainService } from "../main-service.js"; +import { Conformance } from "@adobe/data-testing"; import { implementation } from "./implementation.js"; -// `checkFeature` can't seed the peer identity, so this drives the lower-level -// runners from the same manifest. `movePresence` reads the transaction `userId`, -// which the db's concurrency stamps at dispatch: this test-only concurrency reads it -// from a closure that `seedContext` primes with the case's `mark`, and otherwise -// commits immediately. -const { spec, projection } = implementation; - -let peerUserId: string | undefined; -const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ - deferredCommit: false, - apply: (envelope) => { - if (envelope.time === 0) return undefined; - const transaction = getTransaction(envelope.name); - if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); - return execute((t) => transaction(t, envelope.args), { - intermediate: envelope.time < 0, - userId: peerUserId, - }); - }, - cancel: () => {}, - onReset: () => {}, -}); - -Conformance.runActions({ - makeDb: () => Database.toSystemDatabase(Database.create(MainService.plugin, { concurrency: peerConcurrency })), - store: (db) => db.store, - fromState: projection.fromState, - toState: projection.toState, - initial: spec.state.create(), - transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, true), - actions: MainService.plugin.actions, - seedContext: (_db, _before, args) => { - // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). - peerUserId = (args as { mark: string }).mark; - }, -}); - -describe("projection round-trips (toState ∘ fromState ≡ identity)", () => { - (spec.state.samples ?? []).forEach((sample, i) => { - it(`sample ${i}`, () => { - const store = Store.create(MainService.plugin); - projection.fromState(store, sample); - Match.assert(projection.toState(store), sample); - }); - }); -}); +// Replays every spec case against the same-named action and round-trips the samples. +Conformance.checkFeature(implementation); diff --git a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts index 05c3709b4..dadaa7604 100644 --- a/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts +++ b/packages/data-p2p-tictactoe/src/features/presence/ecs/conformance/implementation.ts @@ -1,13 +1,37 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. +import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; import { Conformance } from "@adobe/data-testing"; import { spec } from "../../spec/spec.js"; import { MainService } from "../main-service.js"; import { projection } from "./projection.js"; -// The spec paired with its ECS build: `movePresence` against the same-named action. -// `conformance.test.ts` runs it through the lower-level runners, because the action -// needs the peer `userId` seeded per case. +// `movePresence` reads the transaction `userId`, which the db's concurrency stamps at +// dispatch. This test-only concurrency reads it from a closure that `seedContext` +// primes with each case's `mark`, and otherwise commits immediately. +let peerUserId: string | undefined; +const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ + deferredCommit: false, + apply: (envelope) => { + if (envelope.time === 0) return undefined; + const transaction = getTransaction(envelope.name); + if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); + return execute((t) => transaction(t, envelope.args), { + intermediate: envelope.time < 0, + userId: peerUserId, + }); + }, + cancel: () => {}, + onReset: () => {}, +}); + +// The spec paired with its ECS build: `movePresence` against the same-named action, +// with the acting peer seeded per case. export const implementation = Conformance.implementation(spec, { plugin: MainService.plugin, projection, + concurrency: peerConcurrency, + seedContext: (_db, _before, args) => { + // Runtime invariant: presence cases carry a `mark` (see move-presence.cases.ts). + peerUserId = (args as { readonly mark: string }).mark; + }, }); diff --git a/packages/data-persistence/package.json b/packages/data-persistence/package.json index de9460b53..ea28e7221 100644 --- a/packages/data-persistence/package.json +++ b/packages/data-persistence/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-persistence", - "version": "0.10.23", + "version": "0.10.24", "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-pixie/src/features/main/data/resources/filter.ts b/packages/data-react-pixie/src/features/main/data/resources/filter.ts index 024601990..9b4f31771 100644 --- a/packages/data-react-pixie/src/features/main/data/resources/filter.ts +++ b/packages/data-react-pixie/src/features/main/data/resources/filter.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import { Scope } from "@adobe/data/ecs"; +import { Scope } from "@adobe/data/schema"; import type { ResourceSchema } from "@adobe/data/ecs"; import { FilterKind } from "../values/filter-kind/filter-kind.js"; diff --git a/packages/data-react/package.json b/packages/data-react/package.json index 157f429d8..5a046470f 100644 --- a/packages/data-react/package.json +++ b/packages/data-react/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-react", - "version": "0.10.23", + "version": "0.10.24", "description": "Adobe data React bindings — hooks and context for ECS database", "type": "module", "private": false, diff --git a/packages/data-rpc/package.json b/packages/data-rpc/package.json index 6987a57c3..995ce82ec 100644 --- a/packages/data-rpc/package.json +++ b/packages/data-rpc/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-rpc", - "version": "0.10.23", + "version": "0.10.24", "description": "Schema-driven, bidirectional projection of @adobe/data async data services across a boundary (iframe / MessagePort / Worker). Only Data crosses the wire.", "type": "module", "sideEffects": false, diff --git a/packages/data-rpc/src/consume/synthesize-service.ts b/packages/data-rpc/src/consume/synthesize-service.ts index b692be895..7c5ada6f1 100644 --- a/packages/data-rpc/src/consume/synthesize-service.ts +++ b/packages/data-rpc/src/consume/synthesize-service.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Observe } from "@adobe/data/observe"; -import type { Schema } from "@adobe/data/schema"; +import { Parameter, type Schema } from "@adobe/data/schema"; import { AsyncDataService, type Service } from "@adobe/data/service"; import { isPureDataSchema } from "../arg-marshal.js"; import type { CallerContext } from "./caller-context.js"; @@ -29,7 +29,7 @@ function buildMembers( const memberPath = [...path, key]; if (member.type === "observe" || member.type === "function") { const kind = AsyncDataService.memberKind(member); - const params = member.signature?.parameters?.map((p) => p.schema); + const params = member.signature?.parameters?.map(Parameter.schemaOf); switch (kind) { case "observe": out[key] = makeObserve(ctx, service, memberPath, []); diff --git a/packages/data-rpc/src/host/register-service.ts b/packages/data-rpc/src/host/register-service.ts index e69bb54d2..c20c8e682 100644 --- a/packages/data-rpc/src/host/register-service.ts +++ b/packages/data-rpc/src/host/register-service.ts @@ -2,7 +2,7 @@ import type { Data } from "@adobe/data"; import type { Observe } from "@adobe/data/observe"; -import { validate, type Schema } from "@adobe/data/schema"; +import { Parameter, validate, type Schema } from "@adobe/data/schema"; import { isPureDataSchema } from "../arg-marshal.js"; import { reconstructError } from "../consume/reconstruct-error.js"; import type { RpcError, RpcMessage } from "../protocol.js"; @@ -52,7 +52,7 @@ function resolve( } memberSchema = props[key]; } - const params = memberSchema.signature?.parameters?.map((p) => p.schema); + const params = memberSchema.signature?.parameters?.map(Parameter.schemaOf); if (params !== undefined) { const errors: string[] = []; params.forEach((p, i) => { diff --git a/packages/data-solid/package.json b/packages/data-solid/package.json index 2f8bbac04..991ba0a8c 100644 --- a/packages/data-solid/package.json +++ b/packages/data-solid/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-solid", - "version": "0.10.23", + "version": "0.10.24", "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 c2bafedc6..6bbdcfc22 100644 --- a/packages/data-sync/package.json +++ b/packages/data-sync/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-sync", - "version": "0.10.23", + "version": "0.10.24", "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/README.md b/packages/data-testing/README.md index d149f1a3d..2a9c7b9b9 100644 --- a/packages/data-testing/README.md +++ b/packages/data-testing/README.md @@ -57,5 +57,7 @@ Conformance.checkFeature(implementation); `State`'s keys to the feature's resource and component names. - **`Match`** provides tolerant, matcher-aware comparison (`matches`, `ref`, `anyNumber`, `anyString`). -- The lower-level runners (`runActions`, `runComputeds`, `runTransactions`, - `adaptCases`) remain for a feature that needs per-case ambient context. +- An action that reads ambient per-case context (a peer `userId`) passes a test + `concurrency` and a `seedContext` to `Conformance.implementation`. +- The lower-level runners (`runActions`, `runComputeds`, `runSystems`, + `runTransactions`, `adaptCases`) remain for custom harnesses. diff --git a/packages/data-testing/package.json b/packages/data-testing/package.json index 7b8f6cd42..b0649e7ef 100644 --- a/packages/data-testing/package.json +++ b/packages/data-testing/package.json @@ -1,6 +1,6 @@ { "name": "@adobe/data-testing", - "version": "0.10.23", + "version": "0.10.24", "description": "Conformance-testing utilities (Match + Conformance runners) for @adobe/data ECS features", "type": "module", "sideEffects": false, diff --git a/packages/data-testing/src/conformance/adapt-spec.ts b/packages/data-testing/src/conformance/adapt-spec.ts index 1c32e2dad..12aae7213 100644 --- a/packages/data-testing/src/conformance/adapt-spec.ts +++ b/packages/data-testing/src/conformance/adapt-spec.ts @@ -17,12 +17,13 @@ type LooseCaseModule = { readonly args?: Schema; readonly cases: readonly Record const injectDoubles = ( testCase: Record, services: Readonly object>>, + lenient: boolean, ): Record => { const responses = testCase["responses"] as Readonly> | undefined; const args = testCase["args"] as object | undefined; const built: Record = {}; for (const [name, template] of Object.entries(services)) { - built[name] = buildDouble(template, responses?.[name]); + built[name] = buildDouble(template, responses?.[name], lenient); } const { responses: _responses, args: _args, ...rest } = testCase; return { ...rest, args: { ...built, ...(args ?? {}) } }; @@ -36,12 +37,14 @@ const injectDoubles = ( // bridge from inert, data-only cases to the unchanged runners: the pure spec and the // ecs action side adapt WITH services (each side builds its own fresh doubles, so // their FIFO schedules never cross); the ecs transaction side adapts WITHOUT (a -// transaction is a pure store mutation and never receives services). +// transaction is a pure store mutation and never receives services). `lenientDoubles` +// builds the deprecated `Conformance.feature` path's doubles (see `buildDouble`). export const adaptCases = ( fns: Readonly>, cases: object, services: Readonly object>> | undefined, injectServices: boolean, + lenientDoubles = false, ): Record> => { const out: Record> = {}; for (const [name, fn] of Object.entries(fns)) { @@ -54,7 +57,7 @@ export const adaptCases = ( out[`./${name}`] = { [name]: fn, cases: { cases: list } }; continue; } - const adapted = injectServices && services ? list.map((testCase) => injectDoubles(testCase, services)) : list; + const adapted = injectServices && services ? list.map((testCase) => injectDoubles(testCase, services, lenientDoubles)) : list; out[`./${name}`] = { [name]: fn, cases: module.args ? { args: module.args, cases: adapted } : { cases: adapted }, diff --git a/packages/data-testing/src/conformance/build-double.test.ts b/packages/data-testing/src/conformance/build-double.test.ts index 8c84fd40a..861ecb8f6 100644 --- a/packages/data-testing/src/conformance/build-double.test.ts +++ b/packages/data-testing/src/conformance/build-double.test.ts @@ -32,4 +32,16 @@ describe("buildDouble", () => { const double = buildDouble(template, undefined) as Rng; expect(double.log()).toBeUndefined(); }); + + it("returns undefined for an unscheduled value method when lenient", () => { + const double = buildDouble(template, undefined, true) as Rng; + expect(double.next()).toBeUndefined(); + expect(double.fetch()).toBeUndefined(); + }); + + it("still throws once a lenient schedule is exhausted", () => { + const double = buildDouble(template, { next: [1] }, true) as Rng; + double.next(); + expect(() => double.next()).toThrow('response schedule exhausted for "rng.next"'); + }); }); diff --git a/packages/data-testing/src/conformance/build-double.ts b/packages/data-testing/src/conformance/build-double.ts index b0faa85b9..41e5e6f69 100644 --- a/packages/data-testing/src/conformance/build-double.ts +++ b/packages/data-testing/src/conformance/build-double.ts @@ -14,7 +14,10 @@ export type Schedule = Readonly>; // // The returned double still gets wrapped by `recordCalls` on its way into a case's // args, so effect assertion is unchanged; only the RETURNS come from the case. -export const buildDouble = (template: () => object, responses: Schedule | undefined): object => { +// +// `lenient` (the deprecated `Conformance.feature` path) skips the probe: an +// unscheduled call returns `undefined`, whatever the method. +export const buildDouble = (template: () => object, responses: Schedule | undefined, lenient = false): object => { const shape = template(); const service = "serviceName" in shape ? String(shape.serviceName) : "service"; const queues = new Map( @@ -23,7 +26,7 @@ export const buildDouble = (template: () => object, responses: Schedule | undefi return Object.fromEntries( Object.entries(shape).map(([key, value]) => { if (typeof value !== "function") return [key, value]; // serviceName and other non-method members pass through - const returnsValue = (value as () => unknown)() !== undefined; + const returnsValue = !lenient && (value as () => unknown)() !== undefined; return [ key, (..._args: unknown[]): unknown => { diff --git a/packages/data-testing/src/conformance/check-feature.ts b/packages/data-testing/src/conformance/check-feature.ts index 170070200..2af9f67f1 100644 --- a/packages/data-testing/src/conformance/check-feature.ts +++ b/packages/data-testing/src/conformance/check-feature.ts @@ -6,6 +6,7 @@ import { adaptCases } from "./adapt-spec.js"; import { withNoOp } from "./with-no-op.js"; import type { SchemaSource } from "./refify.js"; import type { Implementation } from "./implementation.js"; +import { checkLegacyFeature, type FeatureSpec } from "./legacy-feature.js"; type AnyFn = (...args: never[]) => unknown; @@ -21,7 +22,7 @@ const frameCasesOf = (frame: object | undefined, services: Readonly, StoreT extends SchemaSource>( +const checkImplementation = , StoreT extends SchemaSource>( impl: Implementation, ): void => { const { spec } = impl; @@ -33,6 +34,9 @@ export const checkFeature = void) | undefined, match: spec.match, ops: impl.ops, }); @@ -58,3 +62,23 @@ export const checkFeature = , StoreT extends SchemaSource>( + impl: Implementation, +): void; +/** @deprecated Pass a `Conformance.implementation` manifest. */ +export function checkFeature, StoreT extends SchemaSource>( + spec: FeatureSpec, +): void; +export function checkFeature( + manifest: Implementation, SchemaSource> | FeatureSpec, SchemaSource>, +): void { + if (isImplementation(manifest)) checkImplementation(manifest); + else checkLegacyFeature(manifest); +} + +// Runtime invariant: a `Conformance.implementation` wraps its pure manifest in `spec`; +// a `Conformance.feature` manifest is flat and has no `spec` member. +const isImplementation = ( + manifest: Implementation, SchemaSource> | FeatureSpec, SchemaSource>, +): manifest is Implementation, SchemaSource> => "spec" in manifest; diff --git a/packages/data-testing/src/conformance/check-spec.ts b/packages/data-testing/src/conformance/check-spec.ts index 50e566c8a..4a245a371 100644 --- a/packages/data-testing/src/conformance/check-spec.ts +++ b/packages/data-testing/src/conformance/check-spec.ts @@ -3,6 +3,7 @@ import { runSpec } from "./run-spec.js"; import { adaptCases } from "./adapt-spec.js"; import { withNoOp } from "./with-no-op.js"; import type { Spec } from "./spec.js"; +import { checkLegacySpec, type FeatureSpec } from "./legacy-feature.js"; type AnyFn = (...args: never[]) => unknown; @@ -10,9 +11,18 @@ type AnyFn = (...args: never[]) => unknown; // doubles built from the `services` templates + each case's `responses`. Reference // fields are found from `spec.schemas`; no store or plugin is built. Per-system cases // run here too; whole-frame cases need the ECS's system order, so `checkFeature` runs them. -export const checkSpec = , C extends object, Sys extends Record>( +export function checkSpec, C extends object, Sys extends Record>( spec: Spec, -): void => { +): void; +/** @deprecated Pass a `Conformance.spec` manifest. */ +export function checkSpec, StoreT>(spec: FeatureSpec): void; +export function checkSpec( + spec: Spec, object, Record> | FeatureSpec, unknown>, +): void { + if (isLegacy(spec)) { + checkLegacySpec(spec); + return; + } runSpec({ state: spec.state, transitions: { @@ -22,4 +32,10 @@ export const checkSpec = , object, Record> | FeatureSpec, unknown>, +): spec is FeatureSpec, unknown> => "plugin" in spec; diff --git a/packages/data-testing/src/conformance/context.test.ts b/packages/data-testing/src/conformance/context.test.ts new file mode 100644 index 000000000..76f01f0dc --- /dev/null +++ b/packages/data-testing/src/conformance/context.test.ts @@ -0,0 +1,66 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// An action that reads ambient context the spec can't carry (the acting peer's +// `userId`) conforms through `checkFeature`: `seedContext` primes a test concurrency +// that stamps the `userId` at dispatch. +import { Database } from "@adobe/data/ecs"; +import type { ConcurrencyStrategyFactory } from "@adobe/data/ecs"; +import { spec } from "./spec.js"; +import { implementation } from "./implementation.js"; +import { checkFeature } from "./check-feature.js"; +import type { SpecCases } from "./feature-types.js"; + +type State = { readonly lastMover: string }; + +const move = (_state: State, { peer }: { readonly peer: string }): State => ({ lastMover: peer }); + +const plugin = Database.Plugin.create({ + resources: { lastMover: { default: "" as string } }, + transactions: { + move: (t) => { + t.resources.lastMover = String(t.userId ?? ""); + }, + }, + actions: { + move: (db, _args: { readonly peer: string }) => { + db.transactions.move(); + }, + }, +}); +type Store = Database.Plugin.ToStore; + +let peer: string | undefined; +const peerConcurrency: ConcurrencyStrategyFactory = (execute, getTransaction) => ({ + deferredCommit: false, + apply: (envelope) => { + if (envelope.time === 0) return undefined; + const transaction = getTransaction(envelope.name); + if (!transaction) throw new Error(`Unknown transaction: ${envelope.name}`); + return execute((t) => transaction(t, envelope.args), { intermediate: envelope.time < 0, userId: peer }); + }, + cancel: () => {}, + onReset: () => {}, +}); + +const cases: SpecCases = { + cases: [ + { name: "records the acting peer", before: {}, args: { peer: "X" }, after: { lastMover: "X" } }, + { name: "records another peer", before: { lastMover: "X" }, args: { peer: "O" }, after: { lastMover: "O" } }, + ], +}; + +checkFeature( + implementation(spec({ state: { create: (): State => ({ lastMover: "" }) }, fns: { move }, cases: { move: cases } }), { + plugin, + projection: { + fromState: (store: Store, state: State): void => { + store.resources.lastMover = state.lastMover; + }, + toState: (store: Store): State => ({ lastMover: store.resources.lastMover }), + }, + concurrency: peerConcurrency, + seedContext: (_db, _before, args) => { + // Runtime invariant: every `move` case's args carry its `peer`. + peer = (args as { readonly peer: string }).peer; + }, + }), +); diff --git a/packages/data-testing/src/conformance/implementation.ts b/packages/data-testing/src/conformance/implementation.ts index b8261e725..2eef23fb6 100644 --- a/packages/data-testing/src/conformance/implementation.ts +++ b/packages/data-testing/src/conformance/implementation.ts @@ -1,5 +1,5 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Database } from "@adobe/data/ecs"; +import type { ConcurrencyStrategyFactory, Database } from "@adobe/data/ecs"; import type { HasDerivations, HydrateExact, @@ -41,6 +41,12 @@ export interface Implementation object>>; + // Ambient per-case context an action reads that the spec cannot carry (e.g. the + // acting peer's `userId`, stamped by `concurrency` at dispatch). `seedContext` runs + // after the store is seeded and before the action; `concurrency` builds every + // action db. + readonly concurrency?: ConcurrencyStrategyFactory; + readonly seedContext?: (db: never, before: State, args: unknown) => void; // Override the ops read off the plugin facets. readonly ops?: { readonly actions?: Record; @@ -64,8 +70,9 @@ export const implementation = < U extends string = never, >( spec: Spec, - impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "frame"> & { + impl: Omit>, "spec" | "plugin" | "projection" | "hydrate" | "computedPlugin" | "frame" | "seedContext"> & { readonly plugin: PL; + readonly seedContext?: (db: Database.Plugin.ToSystemDatabase, before: State, args: unknown) => void; readonly projection: P; readonly hydrate?: H; } & (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & diff --git a/packages/data-testing/src/conformance/legacy-feature.test.ts b/packages/data-testing/src/conformance/legacy-feature.test.ts new file mode 100644 index 000000000..76b1d3b12 --- /dev/null +++ b/packages/data-testing/src/conformance/legacy-feature.test.ts @@ -0,0 +1,69 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// The deprecated `Conformance.feature` manifest still conforms through `checkSpec` and +// `checkFeature` with its original semantics: `bump` is covered only by a same-named +// transaction (no action), and `relabel` calls a value method no case schedules, which +// the lenient doubles answer with `undefined`. +import { Database } from "@adobe/data/ecs"; +import { feature } from "./legacy-feature.js"; +import { checkSpec } from "./check-spec.js"; +import { checkFeature } from "./check-feature.js"; +import type { SpecCases } from "./feature-types.js"; + +type State = { readonly count: number; readonly label: string }; + +interface Namer { + readonly serviceName: "namer"; + next: () => string | undefined; +} + +const bump = (state: Pick, { by }: { readonly by: number }): Pick => ({ count: state.count + by }); +const relabel = (_state: State, { namer }: { readonly namer: Namer }): Pick => ({ label: namer.next() ?? "unnamed" }); + +const createNamer = (): Namer => ({ serviceName: "namer", next: () => "fake" }); + +const plugin = Database.Plugin.create({ + services: { namer: (): Namer => createNamer() }, + resources: { count: { default: 0 as number }, label: { default: "" as string } }, + transactions: { + bump: (t, { by }: { readonly by: number }) => { + t.resources.count += by; + }, + setLabel: (t, label: string) => { + t.resources.label = label; + }, + }, + actions: { + relabel: (db) => { + db.transactions.setLabel(db.services.namer.next() ?? "unnamed"); + }, + }, +}); +type Store = Database.Plugin.ToStore; + +const bumpCases: SpecCases = { + cases: [{ name: "adds", before: { count: 1 }, args: { by: 2 }, after: { count: 3 } }], +}; +const relabelCases: SpecCases = { + cases: [ + { name: "uses the scheduled name", before: {}, responses: { namer: { next: ["Ada"] } }, after: { label: "Ada" } }, + { name: "falls back when nothing is scheduled", before: {}, after: { label: "unnamed" } }, + ], +}; + +const manifest = feature({ + state: { create: (): State => ({ count: 0, label: "" }), samples: [{ count: 4, label: "x" }] }, + fns: { bump, relabel }, + cases: { bump: bumpCases, relabel: relabelCases }, + services: { namer: createNamer }, + plugin, + projection: { + fromState: (store: Store, state: State): void => { + store.resources.count = state.count; + store.resources.label = state.label; + }, + toState: (store: Store): State => ({ count: store.resources.count, label: store.resources.label }), + }, +}); + +checkSpec(manifest); +checkFeature(manifest); diff --git a/packages/data-testing/src/conformance/legacy-feature.ts b/packages/data-testing/src/conformance/legacy-feature.ts new file mode 100644 index 000000000..d6b5d4ec5 --- /dev/null +++ b/packages/data-testing/src/conformance/legacy-feature.ts @@ -0,0 +1,128 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Database } from "@adobe/data/ecs"; +import type { MatchOptions } from "../match/match.js"; +import type { + CaseModule, + HasDerivations, + HydrateExact, + HydrationKeys, + ProjectionShape, + ProjectionStore, + ProjectionValue, + RequiredServices, +} from "./feature-types.js"; +import type { Projection } from "./run-feature.js"; +import type { SchemaSource } from "./refify.js"; +import { Store } from "@adobe/data/ecs"; +import { runSpec } from "./run-spec.js"; +import { runLegacyFeature } from "./legacy-run-feature.js"; +import { adaptCases } from "./adapt-spec.js"; + +type AnyFn = (...args: never[]) => unknown; + +/** + * @deprecated One feature's whole conformance declaration in the state-based layout, + * imported by both `spec.test.ts` (pure) and `conformance.test.ts` (ecs). Spec + * transitions conform against same-named transactions AND actions, and unscheduled + * double calls return `undefined`. New features author a `Conformance.spec` plus a + * `Conformance.implementation`. + */ +export interface FeatureSpec, StoreT> { + // The `State` namespace: `create()` is the default each case's `before`/`input` + // deltas over; `samples` are full states for the projection round-trip. + readonly state: { create(): State; readonly samples?: readonly State[] }; + // The conformed functions — exactly the transforms and derivations under test, + // authored as an `import * as transforms` barrel. Its keys drive the guard below. + readonly fns: Fns; + // The corpus, one inert `*.cases.ts` module per fn. The mapped type over `keyof + // Fns` is the coverage guard: a fn with no cases, or a case with no fn, is a + // compile error — the guarantee the glob shape could never give. + readonly cases: { readonly [K in keyof Fns]: CaseModule }; + // Shape-only recording-double templates, keyed by injected-service arg name. The + // runner calls each once to enumerate the service's methods (no Proxy), then + // synthesizes returns from each case's `responses`. Omit for a service-free feature. + readonly services?: Readonly object>>; + readonly plugin: Database.Plugin; + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + readonly hydrate?: readonly string[]; + readonly match?: MatchOptions; + readonly ops?: { + readonly transactions?: Record; + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Author a feature's conformance manifest. Purely a typed identity — it exists so the +// generics infer from the argument and arm the compile-time guards. `P` (projection) +// and `C` (the concrete case modules) are captured, not widened, so their real types +// feed the checks: +// • `cases` coverage — `C extends { [K in keyof Fns]: CaseModule }` requires a module +// for every fn (missing → error); the `NoExtraCases` factor rejects a module whose +// key is not a fn (extra → error). +// • `hydrate` exactness — must be EXACTLY the entity-list derivations (`HydrateExact`). +// • `services` requiredness — exactly the injected-service keys the fns declare, or +// absent when none (`RequiredServices`); a missing template is a compile error, not +// a runtime crash. +// • `computedPlugin` requiredness — required when any authored case module is a +// derivation (`HasDerivations`), so a feature with derivations can't silently skip +// its computed conformance by omitting the plugin. +/** + * @deprecated Use `Conformance.spec` and `Conformance.implementation`. + */ +export const feature = < + State extends object, + Fns extends Record, + P extends ProjectionShape, + C extends { readonly [K in keyof Fns]: CaseModule }, + const H extends readonly Extract[] = readonly [], +>( + spec: Omit>, "projection" | "cases" | "hydrate" | "services" | "computedPlugin"> & { + readonly projection: P; + readonly cases: C; + readonly hydrate?: H; + } & RequiredServices & + (HasDerivations extends true ? { readonly computedPlugin: Database.Plugin } : { readonly computedPlugin?: Database.Plugin }) & + ([Exclude] extends [never] + ? unknown + : { readonly __casesError: "a case module names something that is not a conformed fn"; readonly extra: Exclude }) & + HydrateExact>>, +): FeatureSpec> => + // Runtime invariant the checker can't see: the constrained param IS this FeatureSpec + // (`P` widens to the stored `Projection`, `C` to the `cases` mapped type, `H` to + // `readonly string[]`, the fake factories to `() => object`). The several requiredness + // factors are compile-only phantoms with no runtime shape, and together they defeat + // TS's structural-overlap check for a direct `as`, so this goes through `unknown`. + spec as unknown as FeatureSpec>; + +// The pure-spec suite for a manifest (`checkSpec` dispatches here): fresh lenient +// doubles per case, and reference-field schemas read from the plugin through a +// throwaway store. +export const checkLegacySpec = , StoreT>( + spec: FeatureSpec, +): void => { + runSpec({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, spec.services, true, true), + schemas: Store.create(spec.plugin as never).componentSchemas, + match: spec.match, + }); +}; + +// The ecs suite for a manifest (`checkFeature` dispatches here): transactions ignore +// the injected services; actions consume each case's response schedule. +export const checkLegacyFeature = , StoreT extends SchemaSource>( + spec: FeatureSpec, +): void => { + runLegacyFeature({ + state: spec.state, + transitions: adaptCases(spec.fns, spec.cases, spec.services, true, true), + plugin: spec.plugin, + computedPlugin: spec.computedPlugin, + projection: spec.projection, + hydrate: spec.hydrate, + match: spec.match, + ops: spec.ops, + }); +}; diff --git a/packages/data-testing/src/conformance/legacy-feature.type-test.ts b/packages/data-testing/src/conformance/legacy-feature.type-test.ts new file mode 100644 index 000000000..1027d1af1 --- /dev/null +++ b/packages/data-testing/src/conformance/legacy-feature.type-test.ts @@ -0,0 +1,174 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +// Type-level checks for the deprecated `feature` manifest's two compile-time guards: the `cases` +// coverage guard (exactly one case module per conformed fn) and the `hydrate` +// exactness guard (exactly the entity-list derivations, no more, no fewer). Each guard +// gets a POSITIVE case (must compile) and NEGATIVE cases (`@ts-expect-error`, must NOT +// compile) — a negative that stops failing turns the directive into an "unused +// directive" error, so the guard can't silently rot. +import type { Database, Entity } from "@adobe/data/ecs"; +import { feature } from "./legacy-feature.js"; +import type { CaseModule, SpecCases, SpecDerivations } from "./feature-types.js"; + +type Foo = { readonly name: string }; +type State = { readonly entities: ReadonlyMap; readonly count: number }; + +// A transition (2 params) and an entity-list derivation (returns `readonly Foo[]`, so +// its ecs computed emits ids → it MUST be hydrated). +const bump = (s: Pick, args: { readonly by: number }): Pick => ({ count: s.count + args.by }); +const listFoos = (s: Pick): readonly Foo[] => [...s.entities.values()]; +const fns = { bump, listFoos }; + +interface Store { + readonly componentSchemas: Record; +} +declare const plugin: Database.Plugin; +declare const computedPlugin: Database.Plugin; +declare const state: { create(): State; readonly samples?: readonly State[] }; + +// A concrete projection whose `toData` returns the entity value `Foo` — that return +// type is what tells the guard `listFoos` is an entity-list derivation. +const projection = { + fromState: (_store: Store, _s: State): void => {}, + toState: (_store: Store): State => ({ entities: new Map(), count: 0 }), + toData: (_store: Store, _e: Entity): Foo => ({ name: "" }), +}; + +const bumpCases: SpecCases = { + cases: [{ name: "bumps", before: {}, args: { by: 1 }, after: { count: 1 } }], +}; +const listFoosCases: SpecDerivations = { + cases: [{ name: "lists", input: { entities: new Map() }, value: [] }], +}; + +// POSITIVE — every fn has a case, and `hydrate` is exactly the entity-list derivation. +export const ok = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// NEGATIVE (cases coverage) — a conformed fn with no case module. +// @ts-expect-error — `cases` is missing `listFoos` +export const missingCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases } }); + +// NEGATIVE (cases coverage) — a case module for a name that is not a conformed fn. +// @ts-expect-error — `cases` has `bogus`, which is not in `fns` +export const extraCase = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases, bogus: bumpCases } }); + +// NEGATIVE (hydrate exactness) — an entity-list derivation left out of `hydrate`. +// @ts-expect-error — `hydrate` is missing the entity-list derivation `listFoos` +export const missingHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: [], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// NEGATIVE (hydrate exactness) — a non-entity-list fn listed in `hydrate`. +// @ts-expect-error — `hydrate` lists `bump`, which does not need hydration +export const extraHydrate = feature({ state, fns, plugin, computedPlugin, projection, hydrate: ["listFoos", "bump"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// ── `SpecCases` case-shape guards (tested directly, without the manifest) ────────── +// A service-injected transition: `log` is fire-and-forget (void), `next` returns a value. +interface Logger { + log: (msg: string) => void; + next: () => Promise; +} +const record = (s: Pick, args: { readonly by: number; readonly logger: Logger }): Pick => ({ + count: s.count + args.by + (args.logger.log("x"), 0), +}); + +// POSITIVE — data-only `args` (the `logger` service is stripped), a scheduled return for +// the value method `next`, and the void `log` asserted in `effects`. +export const recOk: SpecCases = { + cases: [{ name: "ok", before: {}, args: { by: 1 }, responses: { logger: { next: [5] } }, after: { count: 6 }, effects: { logger: [["log", "x"]] } }], +}; + +// NEGATIVE — a live service instance in `args` (args are DATA only). +// @ts-expect-error — `logger` is a service; it must not appear in `args` +export const recBadArgsService: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1, logger: {} }, after: { count: 1 } }] }; + +// NEGATIVE — a required data arg omitted. +// @ts-expect-error — `args` is missing the required `by` +export const recMissingArg: SpecCases = { cases: [{ name: "x", before: {}, args: {}, after: { count: 1 } }] }; + +// NEGATIVE — scheduling a response for a `void` method (only value methods take one). +// @ts-expect-error — `log` is void; it cannot be scheduled in `responses` +export const recVoidResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { log: ["y"] } }, after: { count: 1 } }] }; + +// NEGATIVE — a scheduled response of the wrong payload type (`next` yields `number`). +// @ts-expect-error — `next` yields `number`, not `string` +export const recWrongResponse: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, responses: { logger: { next: ["nope"] } }, after: { count: 1 } }] }; + +// POSITIVE — a `throws` case (no `after`/`effects`/`responses`). +export const recThrows: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, throws: "boom" }] }; + +// NEGATIVE — `after` and `throws` are mutually exclusive. +// @ts-expect-error — a case is either an `after` expectation or a `throws`, never both +export const recBoth: SpecCases = { cases: [{ name: "x", before: {}, args: { by: 1 }, after: { count: 1 }, throws: true }] }; + +// ── `CaseModule` transition/derivation discrimination ────────────────────────────── +// The KIND is the authored `SpecCases`/`SpecDerivations` annotation, NOT the fn's +// signature — arity and return-type structure are both unsound discriminators, so +// `CaseModule` is a bare union. These pin the cases the union must (and must not) admit. + +// POSITIVE — a no-arg, service-free transition `(state) => patch`. This is the exact +// regression the old arity-based `CaseModule` caused (one param → misread as derivation). +declare const reset: (s: Pick) => Pick; +declare const resetCases: SpecCases; +export const resetOk: CaseModule = resetCases; + +// POSITIVE — an async transition `(state, args) => Promise` (return is a Promise, +// so a return-type-`extends Partial` check would have misclassified it). +declare const saveAsync: (s: Pick, a: { readonly id: number }) => Promise>; +declare const asyncCases: SpecCases; +export const asyncOk: CaseModule = asyncCases; + +// POSITIVE — derivations returning an array (also a hydration key), a set, and a scalar. +export const arrOk: CaseModule = listFoosCases; +declare const idsOf: (s: State) => ReadonlySet; +declare const idsCases: SpecDerivations; +export const setOk: CaseModule = idsCases; +declare const total: (s: State) => number; +declare const totalCases: SpecDerivations; +export const scalarOk: CaseModule = totalCases; + +// NEGATIVE — wrong-arm annotation, caught by the envelope's content (not its arity). +// @ts-expect-error — a transition envelope cannot hold derivation-shaped `{ input, value }` cases +export const wrongTransition: SpecCases = { cases: [{ name: "x", input: { count: 0 }, value: 0 }] }; +// @ts-expect-error — a derivation envelope cannot hold transition-shaped `{ before, after }` cases +export const wrongDerivation: SpecDerivations = { cases: [{ name: "x", before: {}, after: { count: 1 } }] }; + +// NEGATIVE — mis-pairing an array-derivation module into a transition fn's slot, caught +// by the `ReturnType`/args mismatch inside the envelope generics. +// @ts-expect-error — a derivation module for `listFoos` is not a valid case module for the transition `bump` +export const mispair: CaseModule = listFoosCases; + +// RESIDUAL GAP (documented, deliberately compiles — NOT a `@ts-expect-error`): a +// transition-shaped module accepted for a `(state) => value` derivation, because args +// align (`ArgsOf` is void) and a state patch is a valid `after`. Unreachable from the +// signature; caught by the conformance run when it name-pairs against a computed. +declare const count: (s: State) => number; +export const residualGap: CaseModule = resetCases; + +// ── `computedPlugin` requiredness (needed iff the manifest has a derivation) ───────── +// `fns` above has `listFoos` (a derivation), so `ok` (which includes `computedPlugin`) +// is the POSITIVE. NEGATIVE — omit it with a derivation present: +// @ts-expect-error — `computedPlugin` is required because `listFoos` is a derivation +export const missingComputedPlugin = feature({ state, fns, plugin, projection, hydrate: ["listFoos"], cases: { bump: bumpCases, listFoos: listFoosCases } }); + +// POSITIVE — a transition-only feature may omit `computedPlugin` (no derivations). +const txFns = { bump }; +declare const txCases: SpecCases; +export const noComputedOk = feature({ state, fns: txFns, plugin, projection, cases: { bump: txCases } }); + +// ── `services` requiredness (needed iff a fn injects a service) ────────────────────── +declare const logger: Logger; +const svcFns = { record }; +declare const recordCases: SpecCases; + +// POSITIVE — the injected service `logger` has a template. +export const servicesOk = feature({ state, fns: svcFns, plugin, projection, services: { logger: () => logger }, cases: { record: recordCases } }); + +// NEGATIVE — a fn injects `logger`, but `services` is omitted (today: a runtime crash). +// @ts-expect-error — `services` is required because `record` injects `logger` +export const missingServices = feature({ state, fns: svcFns, plugin, projection, cases: { record: recordCases } }); + +// NEGATIVE — `services` omits the required `logger` template. +// @ts-expect-error — `services` is missing the `logger` template +export const incompleteServices = feature({ state, fns: svcFns, plugin, projection, services: {}, cases: { record: recordCases } }); + +// NEGATIVE — a service-free feature must not carry dead templates. +// @ts-expect-error — `bump` injects no service, so `services` must be absent +export const extraServices = feature({ state, fns: txFns, plugin, projection, services: { logger: () => logger }, cases: { bump: txCases } }); diff --git a/packages/data-testing/src/conformance/legacy-run-feature.ts b/packages/data-testing/src/conformance/legacy-run-feature.ts new file mode 100644 index 000000000..9e34e01bc --- /dev/null +++ b/packages/data-testing/src/conformance/legacy-run-feature.ts @@ -0,0 +1,97 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it } from "vitest"; +import { Database, Store } from "@adobe/data/ecs"; +import type { MatchOptions } from "../match/match.js"; +import { runTransactions } from "./run-transactions.js"; +import { runActions } from "./run-actions.js"; +import { runComputeds } from "./run-computeds.js"; +import { expectAfter } from "./expect-after.js"; +import type { Projection } from "./run-feature.js"; +import type { SchemaSource } from "./refify.js"; + +/** + * @deprecated The runner behind `Conformance.feature`. Each spec transition conforms + * against the plugin's same-named TRANSACTION and its same-named ACTION, whichever + * exist. New features use `Conformance.implementation`, which conforms actions only. + */ +export interface LegacyFeatureRunConfig { + readonly state: { create(): State; readonly samples?: readonly State[] }; + // The adapted `{ fn, cases }` map (see `adaptCases`). + readonly transitions: Record>; + readonly plugin: Database.Plugin; + readonly computedPlugin?: Database.Plugin; + readonly projection: Projection; + readonly hydrate?: readonly string[]; + readonly match?: MatchOptions; + readonly ops?: { + readonly transactions?: Record; + readonly actions?: Record; + readonly computeds?: Record; + }; +} + +// Runtime invariant: a plugin object carries its registered facet maps (see +// `create-plugin.ts`), so this reads the ops directly off it. +type PluginFacets = { transactions: Record; actions: Record; computed: Record }; + +export function runLegacyFeature( + config: LegacyFeatureRunConfig, +): void { + const initial = config.state.create(); + const { fromState, toState, toData } = config.projection; + const facets = config.plugin as unknown as PluginFacets; + // A plugin carries the schema facets, so `Store.create` / `Database.create` + // accept it; the resulting store/db is the projection's `StoreT`/`Db`. + const makeStore = (): StoreT => Store.create(config.plugin as never) as unknown as StoreT; + + runTransactions({ + createStore: makeStore, + fromState, + toState, + initial, + transitions: config.transitions, + transactions: config.ops?.transactions ?? facets.transactions, + match: config.match, + }); + + runActions({ + makeDb: (services) => Database.toSystemDatabase(Database.create(config.plugin as never, { services })) as unknown as Db, + store: (db) => db.store, + fromState, + toState, + initial, + transitions: config.transitions, + actions: config.ops?.actions ?? facets.actions, + match: config.match, + }); + + if (config.computedPlugin) { + const computedFacets = config.computedPlugin as unknown as PluginFacets; + runComputeds({ + makeDb: () => Database.toSystemDatabase(Database.create(config.computedPlugin as never)) as unknown as Db, + store: (db) => db.store, + fromState, + toData, + initial, + derivations: config.transitions, + computeds: config.ops?.computeds ?? computedFacets.computed, + hydrate: config.hydrate, + match: config.match, + }); + } + + const samples = config.state.samples ?? []; + if (samples.length > 0) { + describe("projection round-trips (toState ∘ fromState ≡ identity)", () => { + samples.forEach((sample, index) => { + it(`sample ${index}`, () => { + const store = makeStore(); + fromState(store, sample); + // A full-state round-trip: the whole sample is the expectation (no delta), + // compared up to an id-bijection. + expectAfter(toState(store), {}, sample as object, store, config.match); + }); + }); + }); + } +} diff --git a/packages/data-testing/src/conformance/public.ts b/packages/data-testing/src/conformance/public.ts index 55a63866a..a1235903b 100644 --- a/packages/data-testing/src/conformance/public.ts +++ b/packages/data-testing/src/conformance/public.ts @@ -7,6 +7,9 @@ export { spec, type Spec } from "./spec.js"; export { implementation, type Implementation } from "./implementation.js"; export { checkSpec } from "./check-spec.js"; export { checkFeature } from "./check-feature.js"; +// Deprecated: the state-based `feature` manifest, still accepted by `checkSpec` and +// `checkFeature`. +export { feature, type FeatureSpec } from "./legacy-feature.js"; export type { StateMatches } from "./state-matches.js"; export type { FrameCases, SystemCases } from "./systems-types.js"; export { runSystems, type SystemRunConfig } from "./run-systems.js"; diff --git a/packages/data-testing/src/conformance/run-feature.ts b/packages/data-testing/src/conformance/run-feature.ts index 63e0e4b4c..5c27dc77c 100644 --- a/packages/data-testing/src/conformance/run-feature.ts +++ b/packages/data-testing/src/conformance/run-feature.ts @@ -1,7 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { describe, it } from "vitest"; import { Database, Store } from "@adobe/data/ecs"; -import type { Entity } from "@adobe/data/ecs"; +import type { ConcurrencyStrategyFactory, Entity } from "@adobe/data/ecs"; import type { MatchOptions } from "../match/match.js"; import { runActions } from "./run-actions.js"; import { runComputeds } from "./run-computeds.js"; @@ -44,6 +44,9 @@ export interface FeatureRunConfig { // Base service factories injected into every db (fakes for services whose factory // throws to require injection); per-case recording doubles override them. readonly services?: Readonly object>>; + // Ambient per-case context for actions (see `Implementation`). + readonly concurrency?: ConcurrencyStrategyFactory; + readonly seedContext?: (db: Db, before: State, args: unknown) => void; readonly match?: MatchOptions; // Override the ops discovered from the plugin facets. readonly ops?: { @@ -96,7 +99,10 @@ export function runFeature({ makeDb: (services) => Database.toSystemDatabase( - Database.create(config.plugin as never, { services: { ...baseServices(), ...services } }), + Database.create(config.plugin as never, { + services: { ...baseServices(), ...services }, + concurrency: config.concurrency, + }), ) as unknown as Db, store: (db) => db.store, fromState, @@ -104,6 +110,7 @@ export function runFeature>) => unknown }, - components?: CS, - resources?: RS, + components?: CS & NoReservedNames, + resources?: RS & NoReservedNames, archetypes?: A, indexes?: IX, computed?: CVF & PluginComputedFactories, RemoveIndex, RemoveIndex, {}, string, XP['actions'] & IP['actions'], AmbientPlugin, RemoveIndex, RemoveIndex>>, diff --git a/packages/data/src/ecs/database/database-schema/create-database-schema.ts b/packages/data/src/ecs/database/database-schema/create-database-schema.ts index 9c7b5b28a..7a407c6c9 100644 --- a/packages/data/src/ecs/database/database-schema/create-database-schema.ts +++ b/packages/data/src/ecs/database/database-schema/create-database-schema.ts @@ -34,8 +34,10 @@ export function createDatabaseFromSchema< ): DatabaseFromSchema { return Database.create( Database.Plugin.create({ - components: schema.components, - resources: schema.resources, + // Widened to the constraints: a generic `CS` can't be proven free of + // reserved names at compile time; the store still rejects them at runtime. + components: schema.components as ComponentSchemas, + resources: schema.resources as ResourceSchemas, archetypes: schema.archetypes as any, transactions: schema.transactions as any, }) diff --git a/packages/data/src/ecs/database/reserved-names.type-test.ts b/packages/data/src/ecs/database/reserved-names.type-test.ts new file mode 100644 index 000000000..fffe40f9a --- /dev/null +++ b/packages/data/src/ecs/database/reserved-names.type-test.ts @@ -0,0 +1,13 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { Database } from "./database.js"; + +const mode = { type: "number", default: 0 } as const; + +// POSITIVE — ordinary names compile. +export const ok = Database.Plugin.create({ components: { mode }, resources: { level: mode } }); + +// @ts-expect-error — `id` is reserved by the ECS +export const reservedComponent = Database.Plugin.create({ components: { id: mode } }); + +// @ts-expect-error — `nonShared` is reserved by the ECS +export const reservedResource = Database.Plugin.create({ resources: { nonShared: mode } }); diff --git a/packages/data/src/ecs/index.ts b/packages/data/src/ecs/index.ts index c6f3ac531..c332cad8b 100644 --- a/packages/data/src/ecs/index.ts +++ b/packages/data/src/ecs/index.ts @@ -12,7 +12,6 @@ export { type Components } from "./store/components.js"; export { type ResourceComponents } from "./store/resource-components.js"; export * from "./component-schemas.js"; export * from "./resource-schemas.js"; -export { Scope } from "./scope.js"; export * from "./undo-redo-service/index.js"; export * from "./persistence-service/index.js"; export { applyOperations } from "./database/transactional-store/apply-operations.js"; diff --git a/packages/data/src/ecs/reserved-names.ts b/packages/data/src/ecs/reserved-names.ts new file mode 100644 index 000000000..375a96e88 --- /dev/null +++ b/packages/data/src/ecs/reserved-names.ts @@ -0,0 +1,10 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { IdComponent } from "./required-components.js"; +import type { OptionalComponents } from "./optional-components.js"; + +/** + * Rejects a component or resource map that uses a name reserved by the ECS (`id`, + * `nonPersistent`, `nonShared`): each reserved key it holds must be `never`, so the + * declaration fails to compile instead of throwing when the store is created. + */ +export type NoReservedNames = { readonly [K in Extract]: never }; diff --git a/packages/data/src/ecs/store/public/create-store.test.ts b/packages/data/src/ecs/store/public/create-store.test.ts index 065997e2d..dab6904dd 100644 --- a/packages/data/src/ecs/store/public/create-store.test.ts +++ b/packages/data/src/ecs/store/public/create-store.test.ts @@ -14,6 +14,27 @@ describe("createStore", () => { createStore({ components: componentSchemas, resources: {}, archetypes: {} }) as any ); + describe("component/resource name clashes", () => { + it("warns once and lets the resource schema replace the component's", () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const component = { type: "number", default: 0 } as const; + const resource = { type: "number", default: 7, nonPersistent: true } as const; + const clash = "clashWarnsOnce"; + const first = createStore({ components: { [clash]: component }, resources: {}, archetypes: {} }); + first.extend({ components: {}, resources: { [clash]: resource }, archetypes: {} }); + expect(first.componentSchemas[clash]).toBe(resource); + const second = createStore({ components: { [clash]: component }, resources: {}, archetypes: {} }); + second.extend({ components: {}, resources: { [clash]: resource }, archetypes: {} }); + expect(warn.mock.calls.filter(([message]) => String(message).includes(clash))).toHaveLength(1); + warn.mockRestore(); + }); + + it("throws when a component redefines a resource's name with a different schema", () => { + const store = createStore({ components: {}, resources: { mode: { type: "number", default: 0 } }, archetypes: {} }); + expect(() => store.extend({ components: { mode: { type: "number", default: 1 } }, resources: {}, archetypes: {} })).toThrow(/must be identical/); + }); + }); + describe("reserved names", () => { it("throws when a schema defines a reserved component name", () => { for (const reserved of ["id", "nonPersistent", "nonShared"]) { @@ -317,14 +338,6 @@ describe("createStore", () => { // Store-specific resource tests describe("Resource functionality", () => { - const timeSchema = { - type: "object", - properties: { - delta: F32.schema, - elapsed: F32.schema, - } - } as const satisfies Schema; - it("should create store with resources", () => { const store = createStore({ components: { position: positionSchema }, resources: { time: { default: { delta: 0.016, elapsed: 0 } }, @@ -433,32 +446,13 @@ describe("createStore", () => { expect(store.resources.score).toBe(100); }); - it("should allow querying resources as components", () => { - const store = createStore({ components: { - position: positionSchema, - time: timeSchema - }, resources: { - time: { default: { delta: 0.016, elapsed: 0 } } - }, archetypes: {} }); - - // Resources should be queryable as components - const timeArchetypes = store.queryArchetypes(["time"]); - expect(timeArchetypes).toHaveLength(1); - expect(timeArchetypes[0].components.has("time")).toBe(true); - }); - it("should maintain resource singleton behavior", () => { const store = createStore({ components: { position: positionSchema, - time: timeSchema }, resources: { time: { default: { delta: 0.016, elapsed: 0 } } }, archetypes: {} }); - // Resources should be queryable as components - const timeArchetypes = store.queryArchetypes(["time"]); - expect(timeArchetypes).toHaveLength(1); - // Resources should maintain their values expect(store.resources.time).toEqual({ delta: 0.016, elapsed: 0 }); diff --git a/packages/data/src/ecs/store/public/create-store.ts b/packages/data/src/ecs/store/public/create-store.ts index 2e638f448..ba4872dce 100644 --- a/packages/data/src/ecs/store/public/create-store.ts +++ b/packages/data/src/ecs/store/public/create-store.ts @@ -26,6 +26,9 @@ import { DefaultFactoryKeys } from "../../default-factory-keys.js"; import { IndexDeclarations } from "../index-types.js"; import { MemoryAllocator } from "../../../cache/memory-allocator.js"; +// Names already reported by the component/resource clash warning (once per process). +const warnedClashes = new Set(); + export interface CreateStoreOptions { /** * Backing memory allocator for every numeric component column. Omit for the @@ -378,6 +381,13 @@ export function createStore< if (RESERVED_COMPONENT_NAMES.includes(name)) { throw new Error(`Resource name "${name}" is reserved by the ECS and cannot be defined.`); } + // Components and resources share one schema map, so a same-named resource + // replaces the component's schema there (its column takes on the resource's + // flags). Kept for compatibility; rename one of them. + if (name in componentSchemas && !warnedClashes.has(name)) { + warnedClashes.add(name); + console.warn(`@adobe/data: "${name}" is both a component and a resource; the resource schema replaces the component's. Rename one of them.`); + } if (name in resourceSchemas) { if (resourceSchemas[name as keyof typeof resourceSchemas] !== newResourceSchema) { throw new Error(`Resource schema for "${name}" must be identical when extending.`); diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index 2083e48e3..a11d9b4e9 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -1,13 +1,16 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. -import type { Schema as SchemaType, Layout, Conditional, JSONPath, JSONMergePatch, Parameter, PropertyMeta } from "./schema.js"; +import type { Schema as SchemaType, Layout, Conditional, JSONPath, JSONMergePatch, Parameter as ParameterType, PropertyMeta } from "./schema.js"; export type Schema = SchemaType; export * as Schema from "./public.js"; export type { FromSchemas } from "./from-schemas.js"; -export type { Layout, Conditional, JSONPath, JSONMergePatch, Parameter, PropertyMeta }; +export type Parameter = ParameterType; +export * as Parameter from "./parameter.js"; + +export type { Layout, Conditional, JSONPath, JSONMergePatch, PropertyMeta }; export type { getDynamicSchema } from "./dynamic/index.js"; export * from "./validation/index.js"; @@ -20,3 +23,4 @@ export { toVertexBufferLayout, toVertexBufferLayoutForType } from "./to-vertex-b export type { GPUVertexBufferLayout, GPUVertexAttributeDescriptor, GPUVertexFormat } from "./to-vertex-buffer-layout.js"; export * from "./fractional-index/fractional-index.js"; export * from "./guid/index.js"; +export { Scope } from "./scope.js"; diff --git a/packages/data/src/schema/parameter.test.ts b/packages/data/src/schema/parameter.test.ts new file mode 100644 index 000000000..0e3038888 --- /dev/null +++ b/packages/data/src/schema/parameter.test.ts @@ -0,0 +1,15 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import { describe, it, expect } from "vitest"; +import { Parameter } from "./index.js"; + +describe("Parameter.schemaOf", () => { + it("unwraps a named Parameter", () => { + const schema = { type: "number" } as const; + expect(Parameter.schemaOf({ name: "x", schema })).toBe(schema); + }); + + it("returns a deprecated bare Schema as is", () => { + const schema = { type: "string" } as const; + expect(Parameter.schemaOf(schema)).toBe(schema); + }); +}); diff --git a/packages/data/src/schema/parameter.ts b/packages/data/src/schema/parameter.ts new file mode 100644 index 000000000..1657335cc --- /dev/null +++ b/packages/data/src/schema/parameter.ts @@ -0,0 +1,9 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Parameter, Schema } from "./schema.js"; + +// A signature entry is a named `Parameter`, or (deprecated) the bare Schema of a +// positional argument. Only a `Parameter` carries `schema` (`Schema.schema?: never`). +export const isNamed = (parameter: Parameter | Schema): parameter is Parameter => parameter.schema !== undefined; + +// The argument's schema, whichever form the signature entry takes. +export const schemaOf = (parameter: Parameter | Schema): Schema => (isNamed(parameter) ? parameter.schema : parameter); diff --git a/packages/data/src/schema/parameter.type-test.ts b/packages/data/src/schema/parameter.type-test.ts new file mode 100644 index 000000000..6db04bd6d --- /dev/null +++ b/packages/data/src/schema/parameter.type-test.ts @@ -0,0 +1,22 @@ +// © 2026 Adobe. MIT License. See /LICENSE for details. +import type { Parameter, Schema } from "./schema.js"; + +declare const parameter: Parameter; + +// POSITIVE — the wrapped schema is a Schema. +export const inner: Schema = parameter.schema; + +// @ts-expect-error — a Parameter is not the schema it wraps +export const wrapper: Schema = parameter; + +import type { ToType } from "./to-type.js"; +import type { Assert } from "../types/assert.js"; +import type { Equal } from "../types/equal.js"; + +// POSITIVE — a signature may mix named Parameters and (deprecated) bare Schemas; both +// derive the same positional argument types. +type Mixed = ToType<{ + type: "function"; + signature: { parameters: [{ name: "a"; schema: { type: "number" } }, { type: "string" }] }; +}>; +export type _Mixed = Assert void>>; diff --git a/packages/data/src/schema/schema.ts b/packages/data/src/schema/schema.ts index cf496cea5..34b945361 100644 --- a/packages/data/src/schema/schema.ts +++ b/packages/data/src/schema/schema.ts @@ -27,6 +27,9 @@ export type Conditional = { 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 { + // Never set: it keeps a `Parameter` (`{ name, schema }`) from being mistaken for + // the schema it wraps. + readonly schema?: never; type?: keyof typeof schemaTypes; title?: string; description?: string; @@ -93,9 +96,11 @@ export interface Schema { * its type (`parameter.schema`). `parameter.description` documents the argument; * `parameter.schema.description` documents the type. Neither affects `ToType`, * which derives the call signature positionally from `parameter.schema`. + * A bare Schema entry is the deprecated positional form; read either with + * `Parameter.schemaOf`. */ signature?: { - readonly parameters?: readonly Parameter[]; + readonly parameters?: readonly (Parameter | Schema)[]; readonly returns?: Schema; /** * Invocation policy — who may call this function from an **untrusted diff --git a/packages/data/src/ecs/scope.ts b/packages/data/src/schema/scope.ts similarity index 84% rename from packages/data/src/ecs/scope.ts rename to packages/data/src/schema/scope.ts index 801112917..55c9169f9 100644 --- a/packages/data/src/ecs/scope.ts +++ b/packages/data/src/schema/scope.ts @@ -11,7 +11,8 @@ * * - **document** — shared + durable: no flags (the default). * - **settings** — local + durable: `nonShared`. - * - **presence** — shared + ephemeral: `nonPersistent`. + * - **presence** — shared + ephemeral: `nonPersistent`. Note: `@adobe/data-sync` + * does not yet replicate `nonPersistent` data, so presence is not synced today. * - **session** — local + ephemeral: both. * * On a component these flags scope the column. An entity's own quadrant comes from diff --git a/packages/data/src/schema/to-type.ts b/packages/data/src/schema/to-type.ts index 8f129efa6..860f80f8a 100644 --- a/packages/data/src/schema/to-type.ts +++ b/packages/data/src/schema/to-type.ts @@ -74,12 +74,13 @@ type FromSchemaFunction = // The signature's parameters tuple; absent ⇒ no args. type SignatureParams = - Sig extends { parameters: infer P } ? P extends readonly Parameter[] ? P : readonly [] : readonly []; + Sig extends { parameters: infer P } ? P extends readonly (Parameter | Schema)[] ? P : readonly [] : readonly []; -// Each parameter contributes its `schema` positionally; the parameter `name` and -// `description` are metadata for tooling and do not affect the derived signature. -type FromSchemaArgs

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

= { + -readonly [K in keyof P]: ToType; }; // Absent `returns` ⇒ void (a function that returns nothing meaningful). diff --git a/packages/data/src/schema/to-typescript.test.ts b/packages/data/src/schema/to-typescript.test.ts index 2a9cccb28..7b771ead3 100644 --- a/packages/data/src/schema/to-typescript.test.ts +++ b/packages/data/src/schema/to-typescript.test.ts @@ -631,6 +631,14 @@ describe("Schema.toTypeScript", () => { expect(Schema.toTypeScript(schema, "F")).toBe("type F = (x: number, y: number) => null;"); }); + it("renders a deprecated bare-Schema parameter positionally as argN", () => { + const schema = { + type: "function", + signature: { parameters: [{ name: "x", schema: { type: "number" } }, { type: "string" }] }, + } as const satisfies Schema; + expect(Schema.toTypeScript(schema, "F")).toBe("type F = (x: number, arg1: string) => void;"); + }); + it("breaks parameters onto their own lines with independent descriptions", () => { const vec2 = { title: "Vec2", diff --git a/packages/data/src/schema/to-typescript.ts b/packages/data/src/schema/to-typescript.ts index bf43a1c26..adb2c4750 100644 --- a/packages/data/src/schema/to-typescript.ts +++ b/packages/data/src/schema/to-typescript.ts @@ -1,6 +1,7 @@ // © 2026 Adobe. MIT License. See /LICENSE for details. import { Schema } from "./schema.js"; +import { isNamed, schemaOf } from "./parameter.js"; const indent = " "; @@ -115,7 +116,7 @@ function walk(schema: Schema, keyHint: string | undefined, multiplier: number, c case "function": if (schema.signature) { for (const parameter of schema.signature.parameters ?? []) { - walk(parameter.schema, undefined, multiplier, context); + walk(schemaOf(parameter), undefined, multiplier, context); } if (schema.signature.returns) walk(schema.signature.returns, undefined, multiplier, context); } @@ -335,7 +336,10 @@ function valueExpression(schema: Schema, depth: number, context: Context): strin function functionExpression(schema: Schema, depth: number, context: Context): string { const { signature } = schema; if (!signature) return "() => void"; - const parameters = signature.parameters ?? []; + // A bare Schema entry has no name; it renders positionally as `argN`. + const parameters = (signature.parameters ?? []).map((parameter, index) => + isNamed(parameter) ? parameter : { name: `arg${index}`, schema: parameter }, + ); const returns = signature.returns ? typeExpression(signature.returns, depth, context) : "void"; // With no argument docs, keep the compact single-line form; otherwise break each 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 30059531a..216d4916b 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 @@ -56,6 +56,18 @@ const _incompleteSchema = { // Positive: the schema matches the service exactly. type _CheckComplete = Assert>; +// The deprecated bare-Schema parameter form (including an untyped `{}` placeholder) validates too. +const _bareParameterSchema = { + type: "object", + properties: { + balance: { type: "observe", value: { type: "number" } }, + deposit: { type: "function", signature: { parameters: [{}], returns: { type: "promise", value: { type: "number" } } } }, + }, + required: ["balance", "deposit"], + additionalProperties: false, +} as const satisfies Schema; +type _CheckBareParameter = Assert>; + // Negative: an incomplete schema is not a complete description (missing `deposit`). // @ts-expect-error — schema omits the `deposit` member type _CheckIncompleteFails = Assert>; diff --git a/scripts/check-workspace-conventions.mjs b/scripts/check-workspace-conventions.mjs index 211caa93f..a2a215926 100644 --- a/scripts/check-workspace-conventions.mjs +++ b/scripts/check-workspace-conventions.mjs @@ -13,10 +13,14 @@ // 2. Publishing is governed solely by `private` + `pnpm -r publish`. A // per-package `publish-public` script would reintroduce identity-specific // publish logic, so it is forbidden. +// 3. In a feature-layered package, no runtime file imports the test tier: a +// feature's `spec/` or `ecs/conformance/`, `@adobe/data-testing`, or `vitest`. +// TS project references enforce this for data/services/ecs; this covers the +// ui/ and app/ files that share the non-composite test project. -import { readFileSync, readdirSync } from "node:fs"; +import { readFileSync, readdirSync, statSync } from "node:fs"; import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const packagesDir = join(root, "packages"); @@ -37,6 +41,35 @@ for (const name of readdirSync(packagesDir)) { } } +const isTestTier = (path) => + /\/(spec|ecs\/conformance)\//.test(path) || /\.(test|type-test|cases)\.tsx?$/.test(path); +const sourceFiles = (dir) => + readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) return entry === "node_modules" ? [] : sourceFiles(path); + return /\.tsx?$/.test(entry) ? [path] : []; + }); +const importRe = /(?:from|import)\s*\(?\s*["']([^"']+)["']/g; +for (const name of readdirSync(packagesDir)) { + const src = join(packagesDir, name, "src"); + let features = false; + try { + features = statSync(join(src, "features")).isDirectory(); + } catch { + continue; + } + if (!features) continue; + for (const file of sourceFiles(src)) { + if (isTestTier(file)) continue; + for (const [, spec] of readFileSync(file, "utf8").matchAll(importRe)) { + const target = spec.startsWith(".") ? resolve(dirname(file), spec) : spec; + if (spec === "@adobe/data-testing" || spec === "vitest" || (spec.startsWith(".") && isTestTier(target + "/"))) { + errors.push(`${relative(root, file)}: runtime file imports test-tier "${spec}"`); + } + } + } +} + if (errors.length > 0) { console.error("Workspace convention violations:\n " + errors.join("\n ")); process.exit(1);