@adobe/data-ai 0.10.19 → 0.10.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,380 +7,203 @@ paths:
7
7
 
8
8
  `State` is the whole feature as **one immutable object** — the pure, fully-tested
9
9
  source of truth. Each transition is a read→write **patch** over state; each
10
- derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`.
10
+ derivation a pure selector. The ECS implementation is proven equivalent to it by
11
+ conformance (`../services/main-service/conformance.md`). Reference:
12
+ `data-lit-todo`'s `data/state/`.
11
13
 
12
- The presence of this folder makes the feature **state-based** — its Functional
13
- State Specification is authoritative and the ECS conforms to it. A feature without
14
- `data/state/` is **ECS-based** (the ECS is the source of truth, no conformance) and
15
- this rule does not apply — see `../index.md`, Two modes.
14
+ The presence of this folder makes the feature **state-based**. A feature without
15
+ `data/state/` is **ECS-based** (no spec, no conformance) — see `../index.md`.
16
16
 
17
- ```ts
18
- // data/todo/todo.ts — an entity value type: plain readonly data, NO id.
19
- export type Todo = { readonly name: string; readonly complete: boolean; readonly order: number };
20
- export * as Todo from "./public.js";
21
-
22
- // data/todo/is.ts — a structural type guard, re-exported so it reads as `Todo.is`.
23
- // `"k" in v` narrows `v` so each field reads without a cast.
24
- export const is = (v: unknown): v is Todo =>
25
- typeof v === "object" && v !== null &&
26
- "name" in v && typeof v.name === "string" &&
27
- "complete" in v && typeof v.complete === "boolean" &&
28
- "order" in v && typeof v.order === "number";
17
+ ## The `State` shape
29
18
 
30
- // data/state/state.ts — singletons + one identity-keyed entity map.
19
+ ```ts
20
+ // data/state/state.ts
31
21
  export type State = {
32
22
  readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name
33
23
  readonly entities: ReadonlyMap<number, Todo>; // ALL entities, keyed by id; the value has no id
24
+ readonly selectedTodo: Entity; // a reference singleton (Entity is a branded number)
34
25
  };
35
26
  export * as State from "./public.js";
36
27
  ```
37
28
 
38
- Every **state-based** feature owns a `State`. An ECS-based feature has none.
39
-
40
- ## The standard `State` shape
29
+ - **Singletons** — every non-`entities` field → an ECS **resource** of the same name.
30
+ - **`entities`** — one `ReadonlyMap<number, EntityValue>` holding every entity, keyed
31
+ by a numeric id; the value type is the union of the feature's entity value types and
32
+ carries **no `id`** (identity is the key). Omit `entities` for a feature with no
33
+ entities (all singletons).
41
34
 
42
- A `State` has two kinds of field:
35
+ `State` may reuse plain ECS value types (the `Entity` id type, a `{ parent, order }`
36
+ bundle) but never ECS machinery (a store, a transaction, an index). Entity value types
37
+ are their own `data/<type>/` namespaces with a structural `is` guard (`../index.md`).
43
38
 
44
- - **Singletons** — every non-`entities` field (`displayCompleted`, a `score`, a
45
- `board`). Each becomes an ECS **resource** of the same name (see
46
- `../services/main-service/resources.md`).
47
- - **`entities`** — a single `ReadonlyMap<number, EntityValue>` holding *every* entity,
48
- keyed by a numeric id, where `EntityValue` is the union of the feature's entity value
49
- types (`Todo`, or `Bullet | Asteroid`). Omit `entities` entirely for a feature with
50
- no entities (`tictactoe`, `dashboard`) — it is then all singletons.
39
+ Two conventional exports drive conformance, re-exported through `public.js`:
40
+ - **`create(): State`** (`create.ts`) — the default; every case's `before`/`input` is a
41
+ delta over it.
42
+ - **`samples: readonly State[]`** (`samples.ts`) — representative full states the ecs
43
+ projection round-trips (`toState ∘ fromState ≡ identity`).
51
44
 
52
- This maps 1:1 to the ECS. `State` must not depend on ECS *machinery* (a store, a
53
- transaction, an index) — but it **may reuse ECS-related value types**: the key can be
54
- the `Entity` type (it is just a branded `number`), and a reference/hierarchy bundle can
55
- reuse a shared value type like `CoeditingRelations.OrderedChild` (`{ parent, order }`).
56
- Those are plain serializable data, not ECS behaviour, and reusing them keeps the spec
57
- and the ECS in lockstep — the same `Entity.schema` that types an id here is what
58
- conformance walks to compare the two up to an id-bijection (see `conformance.md`).
59
- Singletons ↔ resources; each `entities` value ↔ one entity whose **component set is the
60
- value's own keys**, so `fromState` inserts a value into the archetype named by
61
- `Object.keys(value)` and `toState` reads every entity back into the map. Identity lives
62
- in the key, never a field — see `../../data-modelling.md` (Entities are keyed, never
63
- id-bearing values).
45
+ ## Files: the pure function and its cases are SEPARATE
64
46
 
65
- ### Entity value types — structural, id-less, one `is` guard each
47
+ Each conformed function is two files:
66
48
 
67
- Each entity type is its own `data/<type>/` namespace (`data/index.md`): a plain
68
- readonly type with **no `id`** plus a structural `is` guard (`is.ts`, re-exported so it
69
- reads `Todo.is`). A **sub-archetype** is an intersection — `Bar = Foo & { baz }` — and
70
- its guard **composes the base guard**: `Bar.is` calls `Foo.is(v)` first, then checks the
71
- added fields (`"baz" in v && typeof v.baz === "boolean"`). So `Bar.is ⟹ Foo.is` by
72
- construction.
49
+ - **`<name>.ts`** — the pure function, a **single export**, with **zero test-tier
50
+ imports** (no `@adobe/data-testing`, no fakes). It depends only on `@adobe/data`,
51
+ other `data/` types, and — for a transition — the service interfaces it injects.
52
+ - **`<name>.cases.ts`** — the spec-owned cases as **inert data**, imported by the
53
+ manifest. It imports the case type with `import type` and never constructs a live
54
+ service.
73
55
 
74
- **Model structurally, like the ECS — no tags by default.** ECS systems match purely on
75
- structure (a mover runs on anything with `position` + `velocity`, regardless of
76
- "kind"), and `State` mirrors that:
56
+ This split is enforced by the package's project-reference wall (`tsconfig.build.json`
57
+ excludes `*.cases.ts`): a runtime file importing test-tier code is a **compile error**.
77
58
 
78
- - **A property name means one thing feature-wide.** No two entity types may declare the
79
- same property name with a different type/semantics — that is what lets every property
80
- collapse to exactly one ECS component and keeps structural matching sound.
81
- - **No marker / `kind` / tag field unless a genuine modelling need demands it.** Prefer
82
- discriminating on the presence of real components; add a tag only when structure alone
83
- cannot express the distinction.
84
-
85
- ### Querying entities — `State.getXEntities`, returning ids
86
-
87
- Entity queries are `state/` derivations that return entity **ids** (look the value up
88
- with `state.entities.get(id)`), mirroring the ECS `select`. Perf is irrelevant here
89
- (full-map scan + guard); the ECS realises them as archetype queries.
90
-
91
- - **Unordered** → `ReadonlySet<number>`: keys whose value passes the guard. It is a
92
- **superset** match — `getFooEntities` (via `Foo.is`) also includes `Bar` entities,
93
- just as `queryArchetypes(["…Foo's cols"])` returns the `Foo` *and* `Bar` archetypes.
94
- - **Ordered** → `ReadonlyArray<number>`: the matching ids sorted by an explicit `order`
95
- component, exactly like an ECS `select(cols, { order })`. Order is **never** carried
96
- by the `entities` map (it is identity-keyed / unordered); a meaningful order is a
97
- component on the value and the derivation returns the sorted ids.
59
+ ### The transform — `<name>.ts`
98
60
 
99
61
  ```ts
100
- // data/state/get-todos.ts — an ordered membership derivation returning ids
101
- export const getTodos = (state: State): readonly number[] =>
102
- [...state.entities]
103
- .filter(([, v]) => Todo.is(v))
104
- .sort(([, a], [, b]) => a.order - b.order)
105
- .map(([id]) => id);
106
- ```
107
-
108
- **`State` has a standard shape.** Two exports are conventional and drive
109
- conformance:
110
-
111
- - **`create(): State`** (`data/state/create.ts`) — the default state. Every
112
- conformance case's `before` is a **delta over `State.create()`**, and both the
113
- pure `spec.test.ts` and the ecs `runFeature` seed from it.
114
- - **`samples: readonly State[]`** (`data/state/samples.ts`) — representative
115
- **full** states `runFeature` round-trips through the projection
116
- (`toState ∘ fromState ≡ identity`, see `conformance.md`).
117
-
118
- Both are re-exported through `public.js`, so `State.create()` / `State.samples`
119
- are namespace members. (A `cases` literal must still not touch `public.js` at
120
- load — import `create` from `./create.js` directly there; see below.)
121
-
122
- **The discovered transitions are a test-only sibling, NOT on the namespace.**
123
- `data/state/transitions.ts` exports one `import.meta.glob` of the folder — the
124
- `{ fn, cases }` modules — imported by *both* `spec.test.ts` and the ecs
125
- `conformance.test.ts` so the glob is authored once per feature:
62
+ // toggle-complete.ts
63
+ import type { Services } from "../../services/services.js";
64
+ import type { State } from "./state.js";
126
65
 
127
- ```ts
128
- // data/state/transitions.ts — test-only; both test entry points import it
129
- export const transitions = import.meta.glob<Record<string, unknown>>(
130
- ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"],
131
- { eager: true },
132
- );
66
+ // Reads entities, writes entities — an `{ entities }` patch — and logs `todoToggled`.
67
+ export const toggleComplete = (
68
+ state: Pick<State, "entities">,
69
+ { id, analytics }: { readonly id: number } & Pick<Services, "analytics">,
70
+ ): Pick<State, "entities"> => {
71
+ analytics.todoToggled();
72
+ const target = state.entities.get(id);
73
+ if (target === undefined) return { entities: state.entities };
74
+ return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) };
75
+ };
133
76
  ```
134
77
 
135
- It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain
136
- prod-safe values), `transitions` is the test-case graph — hanging it on `State`
137
- would drag `import.meta.glob` (a Vite-only construct) and every `cases` fixture
138
- (fake services, matchers, the whole `@adobe/data-testing` module) into every
139
- production import of `State`, none of which tree-shakes off a live namespace
140
- re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test
141
- concern the tests import directly.
142
-
143
- ## One file per transform: the function **and** its cases
144
-
145
- A transform file exports **exactly two things** — the function and its
146
- `cases` — nothing else (a private helper is fine; a second export is not, and the
147
- spec aggregator throws if it finds one). The cases are the spec-owned truth every
148
- conformance runner reuses; co-locating them removes the per-transform `.cases.ts`
149
- and `.test.ts`.
150
-
151
- **Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned
152
- fixtures the pure `spec.test.ts` **and** the ecs `runFeature` call both reuse
153
- (driving transaction / action / computed conformance) — the transform's contract
154
- expressed as data, not a per-file test — so they belong
155
- beside the thing they specify, and `Conformance.cases(fn, …)` binds them to the
156
- signature so they can't drift. Kept inert (no `describe`; one aggregator runs
157
- them) they also sidestep the double execution vitest triggers when a single file
158
- both exports cases and runs its own `describe`. Coverage is then enforced
159
- centrally by the shared driver's barrel-driven guard rather than by eyeballing
160
- one test file per transform — and genuine non-transition helpers still keep
161
- their own `*.test.ts` (see below).
162
-
163
- The case types, matchers, and runners all live in the separate
164
- **`@adobe/data-testing`** package (two namespaces, `Match` and `Conformance`) —
165
- kept out of `@adobe/data` itself so installing `@adobe/data` never pulls in a
166
- `vitest` peer dependency; add `@adobe/data-testing` as a devDependency alongside
167
- `vitest`, its optional peer dependency. Only one tiny per-feature file remains —
168
- a ~10-line alias, `conformance-case.ts`, that binds `State` once so
169
- transform/derivation files can write a one-parameter type:
78
+ - **Transition signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` —
79
+ a read→write patch. The parameter is the smallest `Pick<State,…>` it reads; the return
80
+ is only the fields it writes. No `<T> => T` generic, no `...state` spread — return the
81
+ patch and let the runner merge it. All non-state inputs go in the single `args` object;
82
+ a transition that takes neither args nor services takes only `(state)`.
83
+ - **Inject services with `Pick<Services, …>`** — never an inline `{ analytics: … }`. Data
84
+ args sit alongside via intersection: `{ readonly count: number } & Pick<Services, "a">`.
85
+ - **Guard no-ops by returning an empty/unchanged patch**, never by throwing. Reserve
86
+ `throw` for a genuine precondition violation a case names with `throws`.
87
+ - **Derivation signature** `(state) => value` — a pure selector over **two or more**
88
+ `State` fields. An entity query returns entity **ids** (`readonly number[]` /
89
+ `ReadonlySet<number>`); look values up in `entities`. Value math over a **single**
90
+ `data/<type>` lives on that type's namespace, not here.
91
+
92
+ ### The cases — `<name>.cases.ts`
170
93
 
171
94
  ```ts
172
- // data/state/conformance-case.ts — the only per-feature conformance declaration
173
- import { Conformance as ConformanceApi } from "@adobe/data-testing";
95
+ // toggle-complete.cases.ts
96
+ import { Entity } from "@adobe/data/ecs";
97
+ import type { Conformance } from "@adobe/data-testing";
174
98
  import type { State } from "./state.js";
175
- // `Conformance.cases(fn, [options,] ...cases)` declares a transform's cases — the
176
- // case types (args, before/after) come from `fn`. A derivation authors `Derivation<typeof fn>`.
177
- export const Conformance = { cases: ConformanceApi.casesBuilder<State>() };
178
- export type Derivation<F extends (...args: never[]) => unknown> = ConformanceApi.DerivationCases<F>;
179
- export type Effects<Args> = ConformanceApi.Effects<Args>;
99
+ import type { toggleComplete } from "./toggle-complete.js";
100
+
101
+ export const cases: Conformance.SpecCases<State, typeof toggleComplete> = {
102
+ args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args
103
+ cases: [
104
+ { name: "marks an incomplete todo complete",
105
+ before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) },
106
+ args: { id: 1 }, // DATA only — services are never here
107
+ after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) },
108
+ effects: { analytics: [["todoToggled"]] } }, // service calls to assert
109
+ ],
110
+ };
180
111
  ```
181
112
 
182
- ```ts
183
- // create-todo.ts
184
- import { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
185
- import type { Services } from "../../services/services.js"; // the feature's service map
186
- export const createTodo = (
187
- state: Pick<State, "entities">,
188
- { name, analytics }: { name: string } & Pick<Services, "analytics">,
189
- ): Pick<State, "entities"> => {
190
- analytics.todoCreated({ name });
191
- // The spec mints the id (the map key) and the order; the value carries neither an id
192
- // nor anything the ECS allocates — identity is the key.
193
- const id = Math.max(0, ...state.entities.keys()) + 1;
194
- const order = state.entities.size;
195
- return { entities: new Map(state.entities).set(id, { name, complete: false, order }) };
196
- };
113
+ - Annotate `Conformance.SpecCases<State, typeof fn>` for a transition,
114
+ `Conformance.SpecDerivations<typeof fn>` for a derivation. That annotation is the
115
+ transition/derivation discriminator — nothing is inferred from the signature.
116
+ - **`before` is a delta over `State.create()`**; **`after` is the writes patch** (only
117
+ the fields the transition changes). A derivation case is `{ name, input, value }`.
118
+ - **`args` is data only.** Injected services are stripped from the case's `args` type;
119
+ the runner synthesizes each service's recording double from the manifest's `services`
120
+ templates. A transition with only services takes no `args`.
121
+ - **Service returns are scheduled as data in `responses`**, keyed by service then method;
122
+ only value-returning methods take a schedule (a per-call FIFO — exhausting it throws).
123
+ **Service calls to assert go in `effects`** as `[method, ...args]` tuples (`Array` =
124
+ ordered, `Set` = any order); a value-returning read you don't assert is simply omitted.
125
+ The case thus **owns both the input and the expectation** — the double invents nothing.
126
+ ```ts
127
+ // create-random-todo.cases.ts (a value-returning service)
128
+ responses: { nameGenerator: { generateName: ["random task"] },
129
+ analytics: { randomTodoRequested: [{ startedAt: 0 }] } },
130
+ effects: { analytics: [["randomTodoRequested"],
131
+ ["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }]] },
132
+ ```
133
+ - **Entity ids are plain spec-id numbers everywhere** — map keys in `before`/`after`/
134
+ `input`, and reference fields hold the id they point at (`{ selectedTodo: 2 }`). The
135
+ ecs mints its own ids, so conformance compares up to an id-bijection. An
136
+ entity-addressed transition adds the `args` schema (above) marking each `Entity.schema`
137
+ field; the case then writes the id plain (`args: { id: 2 }`). For a minted scalar a
138
+ case won't pin (a timestamp), use `Match.anyNumber` / `Match.anyString`.
139
+ - **A `throws` case** declares `throws: true` (any error) or `throws: "substring"`
140
+ instead of `after`/`effects`/`responses` — the two are mutually exclusive.
141
+
142
+ ## `transforms.ts` — the conformed-function barrel
197
143
 
198
- export const cases = /*@__PURE__*/ Conformance.cases(createTodo,
199
- { name: "adds the first todo",
200
- before: {}, // empty delta — the default State.create() (empty entities)
201
- args: { name: "a", analytics: AnalyticsService.createFake() },
202
- // The map key is a PLAIN spec-id you choose (1). The ECS mints its own id, so
203
- // conformance compares up to an id-bijection — the key need only be distinct.
204
- // The value is id-less so content compares directly. (Maps compare entry-wise /
205
- // order-independently — see conformance.md.)
206
- after: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) },
207
- effects: { analytics: [["todoCreated", { name: "a" }]] } },
208
- );
144
+ ```ts
145
+ // transforms.ts — exactly the conformed transforms and derivations, nothing else
146
+ export { createTodo } from "./create-todo.js";
147
+ export { toggleComplete } from "./toggle-complete.js";
148
+ export { visibleTodos } from "./visible-todos.js";
149
+ // …
209
150
  ```
210
151
 
211
- - **Signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` — a
212
- **read→write patch**. The parameter is the smallest `Pick<State,…>` the
213
- transition **reads**; the return is *only the fields it **writes***. **No
214
- `<T> => T` generic, no `...state` spread** in the return — return the patch and
215
- let the runner merge it. **All non-state inputs go in the single `args` object**
216
- (the case's `args` type is read from `fn`'s `Parameters[1]`) — bundle a `dt`, an
217
- injected service, etc. into it, never as a third positional. A transition that takes
218
- **no** args omits `args` from each case entirely (the shared `Case` type makes
219
- `args` optional exactly then). **Guard no-ops by returning an empty patch `{}`**
220
- (or the unchanged slice), never throw for an ordinary "nothing to do here"
221
- condition. Reserve `throw` for a genuine precondition violation the case wants
222
- to name (see "A case that expects a throw" below) — the rare exception, not the
223
- default guard style.
224
- - **A composer merges sub-patches explicitly.** A transition built from smaller
225
- ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes
226
- layer in; a transition that merely **delegates** to one sub-transition returns
227
- that delegate's patch directly (no spread needed).
228
- - **Co-located `cases` must not touch the feature's `public.js` barrel at module
229
- load** — that barrel re-exports this very file, so calling `State.create()` (or
230
- any barrel member) in a top-level `cases` literal dead-locks the import cycle.
231
- Import the concrete helper directly (`import { create } from "./create.js"`) or
232
- inline full-`State` literals.
233
- - **Always write the call as `/*@__PURE__*/ Conformance.cases(fn, …)`** (same for
234
- `Conformance.derivations`) — the annotation is not optional style, it is what
235
- makes the co-location safe. `@adobe/data-testing`'s `sideEffects: false` only
236
- lets a bundler drop an unused *import* of the package; `Conformance.cases(fn,
237
- …)` is still an ordinary function call happening in *this* file, and an
238
- unannotated call is conservatively kept even when the `cases` export it
239
- initializes is never read — because the transition function `fn` is legitimately
240
- used elsewhere, this module is very much reachable, so the unused `cases` const
241
- is the only thing tree-shaking has to remove, and it can't without the hint.
242
- Verified against this repo's actual Vite/Rollup production build: a transition
243
- whose `cases` call lacked the annotation shipped the full case-fixture data
244
- (and the builder's inlined logic) into the bundle; adding `/*@__PURE__*/`
245
- removed it completely while leaving the transition function itself intact. A
246
- linter/codemod catching a bare `Conformance.cases(`/`Conformance.derivations(`
247
- call (missing the leading comment) in a `data/state/*.ts` file would catch this
248
- by construction — worth adding if these calls keep getting hand-authored.
249
- - **`Conformance.cases(fn, ...cases)`** (the builder above) derives each case's
250
- types from `fn`'s own signature — pass the transition function and cases can't drift
251
- from what it accepts. **`before` is a delta over `State.create()`** — list only
252
- the fields this case sets differently from the default; **`after` is the writes
253
- patch** — only the fields the transition changes. The runner seeds
254
- `{ ...State.create(), ...before }` and compares against
255
- `{ ...State.create(), ...before, ...after }`, so any field a case doesn't mention
256
- is the default and stays unchanged. (A full `before`/`after` still works — it just
257
- overrides the default wholesale.)
258
- - **`after` uses PLAIN spec-ids everywhere — no hand-authored key matchers.** An
259
- entity's identity is the `entities` map **key**, not a value field, so entity content
260
- compares directly. The ECS mints its **own** ids (a different set from the spec's), so
261
- conformance compares the two **up to an id-bijection**: the runner reads the store's
262
- schemas, finds every entity reference (a map key, and any field whose schema is
263
- `Entity.schema` — recursing into a bundle like `{ parent, order }`), and matches the
264
- case's spec-ids to the ECS's ids one-to-one. So a case author writes ordinary numbers
265
- — `[[1, value], [2, value]]` — and **a reference field just holds the id it points at**
266
- (`{ asset: 2 }`, `placement: { parent: 1, … }`). The bijection makes a key and every
267
- field that names it line up on one actual id automatically — this is what killed the
268
- old `Match.ref` / `Match.refMap` key-labelling (no more distinct-label bookkeeping,
269
- and cross-references between entities finally compare correctly). For a **minted scalar**
270
- a case does not want to pin — a generated timestamp, a random value — still use
271
- `Match.anyNumber` / `Match.anyString` (imported from `@adobe/data-testing`); `Match` is
272
- framework-agnostic, so vitest's `expect.stringContaining(...)` interops on the expected
273
- side too. (The id-correspondence solver is now internal — the runner generates it from
274
- the schema-marked spec-ids; there is no case-facing `ref`/`refMap` matcher anymore.)
275
- - **Entity-addressed cases pass an `args` schema in the builder options.** State
276
- references are detected from the store's schemas; an **arg** has none, so a transition
277
- that addresses an entity by id gives `Conformance.cases` a leading options object whose
278
- `args` schema marks the reference fields:
279
- `Conformance.cases(fn, { args: { type: "object", properties: { id: Entity.schema } } }, …cases)`.
280
- The case then writes the id **plain** — `args: { id: 2 }` — and the runner resolves each
281
- `Entity.schema`-marked field to the seeded entity (via the `fromState` seed map) on the
282
- ECS side, while the pure side reads the plain spec-id directly. Describe **only** the
283
- reference fields (services and non-id args are omitted); the args type must be assignable
284
- to the schema's `Schema.ToType`, so the schema can't drift from the signature (the builder
285
- fails to type-check otherwise). This is the args-side analog of the `Entity.schema` mark
286
- the runner reads on state.
287
- - **The `entities` map key convention across a case, in one place:**
288
- - **`before`** (the seed): **plain spec-id numbers** (`new Map([[1, …], [2, …]])`).
289
- `fromState` seeds from these and returns the `spec-id → entity` map, so a case's
290
- `args: { id: 1 }` resolves to the entity seeded for `1`.
291
- - **`after`** (the expectation, compared against the ECS): **plain spec-id numbers**,
292
- distinct per entry, and reference fields hold the spec-id they point at. The runner
293
- refifies them and compares up to an id-bijection, so the ECS's own ids need not match.
294
- You may reuse the same spec-ids as `before` for entities that persist (it reads
295
- clearly), but any distinct numbers work — the comparison is by content + reference
296
- structure, never by the literal id.
297
- - **`samples`** (round-tripped `toState ∘ fromState`): same as `after` — plain spec-id
298
- keys, compared up to a bijection against the ECS-minted ids.
299
- - No per-transform test. The single **`spec.test.ts`** is one call —
300
- `Conformance.runSpec({ state: State, transitions })` importing `transitions`
301
- from the test-only `./transitions.js` (above) — that auto-discovers every module
302
- exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a
303
- `value` case → derivation; otherwise a transition whose declared `effects` are
304
- also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
305
- takes) is what makes each case's `before`/`input` a delta over `State.create()`.
306
- Add `match` alongside it only when the feature needs float `tolerance` (see
307
- `conformance.md`); unordered collections are modeled as `ReadonlySet` /
308
- `ReadonlyMap` on `State` (below), not declared as a match option.
309
- There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
310
- `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
311
- shared driver owns comparison, effect recording, and name-based auto-pairing, and
312
- the `Effects` type-test now lives once in `@adobe/data-testing`. A genuine **non-transition helper** in
313
- `state/` — a `create()` constructor, a single-field predicate — has no `cases`
314
- and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
315
- sibling `*.test.ts`** rather than deleting it and losing coverage.
152
+ Its keys are the conformance surface. A genuine **non-transition helper** (`appendTodo`,
153
+ `create`, `samples`) is *not* listed here and carries no cases; it keeps its own
154
+ `*.test.ts`.
316
155
 
317
- ### A case that expects a throw
156
+ ## `spec.ts` — the conformance manifest
318
157
 
319
- A case is normally `{ name, before, args?, after, effects? }`. When a transition
320
- must reject its input outright — not the "guard no-op, return `{}`" case above,
321
- but a genuine precondition violation the spec wants to name — declare `throws`
322
- instead of `after`/`effects`:
158
+ The single object both `spec.test.ts` and the ecs `conformance.test.ts` import. It is a
159
+ **test-tier** file (excluded from the runtime build) — the one place the feature touches
160
+ `@adobe/data-testing`.
323
161
 
324
162
  ```ts
325
- export const cases = Conformance.cases(withdrawFunds,
326
- { name: "rejects a withdrawal over the balance",
327
- before: { balance: 10 },
328
- args: { amount: 11 },
329
- throws: "insufficient funds" }, // substring the thrown Error.message must contain
330
- );
163
+ // spec.ts
164
+ import { Conformance } from "@adobe/data-testing";
165
+ import { State } from "./state.js";
166
+ import * as transforms from "./transforms.js";
167
+ import { MainService } from "../../services/main-service/main-service.js";
168
+ import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js";
169
+ import { projection } from "../../services/main-service/conformance/projection.js";
170
+ import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js";
171
+ import { cases as createTodo } from "./create-todo.cases.js";
172
+ import { cases as toggleComplete } from "./toggle-complete.cases.js";
173
+ import { cases as visibleTodos } from "./visible-todos.cases.js";
174
+ // …one import per conformed fn
175
+
176
+ export const spec = Conformance.feature({
177
+ state: State,
178
+ fns: transforms,
179
+ plugin: MainService.plugin,
180
+ computedPlugin: ComputedDatabase.plugin, // required iff the feature has a derivation
181
+ projection,
182
+ hydrate: ["visibleTodos"], // EXACTLY the entity-id-list derivations
183
+ services: { analytics, nameGenerator }, // required iff a transition injects a service
184
+ cases: { createTodo, toggleComplete, visibleTodos, /* …one per fn */ },
185
+ });
331
186
  ```
332
187
 
333
- `throws: true` accepts any thrown error; a string narrows to one whose message
334
- **contains** it. A case can declare `after`/`effects` **or** `throws`, never
335
- both — the shared `Case` type makes them mutually exclusive, and there is
336
- nothing to compare against `after` once a call never returns a patch. The same
337
- case runs against the pure transform (`runSpec`) **and** the paired ecs
338
- transaction/action (`runFeature`/`runTransactions`/`runActions`), so a `throws`
339
- case proves both sides reject the same input the same way. Reach for this only
340
- when throwing is the transition's actual contract — most invalid input should
341
- still no-op per the rule above.
342
-
343
- ## Injected services and side effects
344
-
345
- A transform that needs an outside capability injects it with **`Pick<Services,
346
- …>`** — never an ad-hoc inline type. `Services` is the feature's service map
347
- (`services/services.ts`, see `../services/index.md`), keyed by the service name
348
- minus its `-service` suffix (`analytics`, `nameGenerator`), so the key and its type
349
- come from **one** place and can't drift per-transition. Plain data args sit
350
- alongside via intersection: `{ readonly count: number } & Pick<Services, "analytics">`
351
- (or `Pick<Services, "a" | "b">` when a transition takes only services). The **same
352
- services appear on `db.services` for the matching action** — pinned to `Services` in
353
- the ecs `service-database` — so the transition is the complete spec of *both* the
354
- state change and the service calls.
188
+ `Conformance.feature` enforces four guards at **compile time**:
189
+ - **`cases` coverage** — exactly one case module per `fns` entry (missing or extra → error).
190
+ - **`hydrate` exactness** — exactly the derivations that return a collection of the
191
+ projection's entity value (its ecs computed emits ids); miss one or add a non-hydrating
192
+ name → error.
193
+ - **`services` completeness** — exactly the injected-service keys the transitions declare,
194
+ each a `() => Service` template; absent for a service-free feature.
195
+ - **`computedPlugin` presence** — required when any case module is a derivation.
355
196
 
356
- - A transition **is deterministic given its dependencies** — inject a fixed
357
- double and the output (and its calls) are fixed. An **async** dependency makes
358
- the transition `Promise<State>`; keep sync the default.
359
- - **Side effects are declared in the case's `effects`**, keyed by the service
360
- arg, as `[methodName, ...args]` tuples — an `Array` asserts these calls in
361
- order, a `Set` in any order. Only listed services are checked (a value-returning
362
- read like `generateName` you don't list is ignored). See `conformance.md` for
363
- how the recording double captures them.
364
- - Tests inject **deterministic doubles** (never production) whose published
365
- responses the case's `after`/`effects` are authored against — doubles live
366
- adjacent to the interface (`features/services/index.md`).
197
+ ## `spec.test.ts` — the pure spec
367
198
 
368
- ## Derivations — `(state) => value`, cases `{ input, value }`
369
-
370
- Pure selectors that **compose the aggregate** — a value drawn from **two or more
371
- `State` fields** (`visibleTodos` from `entities` + `displayCompleted`;
372
- `currentPlayer` from `board` + `firstPlayer`). An entity query like `visibleTodos`
373
- returns entity **ids** (`ReadonlyArray<number>` / `ReadonlySet<number>`), never the
374
- values (look those up in `entities`). A value computed from a **single**
375
- `State` field is that field's own type math and lives on its `data/<type>`
376
- namespace (`winner`/`status` from `board` → `data/board-state`), tested there —
377
- **not** in `state/`. A feature may therefore have zero `state/` derivations.
199
+ ```ts
200
+ // spec.test.ts
201
+ import { Conformance } from "@adobe/data-testing";
202
+ import { spec } from "./spec.js";
203
+ Conformance.checkSpec(spec);
204
+ ```
378
205
 
379
- A `state/` derivation co-locates cases shaped `{ input, value }`, typed
380
- `Derivation<typeof fn>` (input + value read from the signature). Take the **full
381
- `State`** as the parameter (not a `Pick` slice like a transform) — so the case
382
- `input` type is the full `State` and computed conformance can seed it via
383
- `fromState`; `value` may use matchers. The same `spec.test.ts` runs them (dispatching on case shape),
384
- and each ECS computed backing one is conformance-tested from the same cases
385
- (`conformance.md`). Sub-type math (a winner, a status) lives on the
386
- relevant `data/<type>` namespace; `state/` only composes over the aggregate.
206
+ One call verifies every transform and derivation against its cases (seeding `before`
207
+ over `State.create()`, synthesizing service doubles from `services` + `responses`,
208
+ asserting `after`/`value`/`effects`/`throws`). The ecs side is one matching call in
209
+ `conformance.test.ts` (`../services/main-service/conformance.md`).
@@ -32,8 +32,9 @@ one of two modes, discriminated by **the presence of `data/state/`**:
32
32
  - **State-based** — a **Functional State Specification (FSS)** is the source of
33
33
  truth: the pure `data/State` aggregate with its transitions and derivations, and
34
34
  the ECS is a conformance-verified implementation of it. Adds `data/state/` (State,
35
- transitions, co-located `cases`, `spec.test.ts`), `services/main-service/conformance/`,
36
- and the `state` projection computed. **This is how every new feature is authored.**
35
+ the pure transforms with sibling `*.cases.ts`, the `transforms.ts` barrel, the `spec.ts`
36
+ manifest, `spec.test.ts`), `services/main-service/conformance/`, and the `state`
37
+ projection computed. **This is how every new feature is authored.**
37
38
  - **ECS-based** — the **ECS is the source of truth**, authored directly:
38
39
  **no `data/state/`**, no FSS, no conformance. This is a **legacy** shape — features
39
40
  written before the state-based approach existed. New features are **never** authored
@@ -129,16 +130,17 @@ The tie between `data/` (spec) and `main-service` (implementation) is
129
130
  **conformance**, one property —
130
131
  `toState(apply(fromState(before), args)) ≡ transform(before, args)`: each
131
132
  main-service mutation, seeded and read back through a test-only store↔`State`
132
- projection, equals the pure `data/` transform it stands for. The per-feature
133
- projection lives in `services/main-service/conformance/`, and a **single
134
- `Conformance.runFeature({...})` call** replays the shared cases against the ecs —
135
- pairing each ECS op to its same-named transition automatically and round-tripping
136
- the projection (see `services/main-service/conformance.md`);
137
- the shared `{ before, args, after }` cases are spec-owned — co-located in each
138
- `data/state/<transform>.ts`, which exports its function plus `cases` — so
139
- conforming the implementation is "substitute the implementation, reuse the
140
- expectations." This lets `main-service` be largely mechanical and agent-generated,
141
- with the spec as oracle. *How* to author each layer lives in the per-folder rules below.
133
+ projection, equals the pure `data/` transform it stands for. The spec-owned cases
134
+ are authored as inert `data/state/*.cases.ts` and gathered by the `data/state/spec.ts`
135
+ **manifest**, which both the pure `spec.test.ts` (`Conformance.checkSpec(spec)`) and the
136
+ ecs `services/main-service/conformance/conformance.test.ts`
137
+ (`Conformance.checkFeature(spec)`) import. `checkFeature` pairs each ECS op to its
138
+ same-named transition, replays the shared cases, and round-trips the projection (see
139
+ `services/main-service/conformance.md`); the manifest's compile-time guards keep the two
140
+ calls the whole conformance surface. So conforming the implementation is "substitute the
141
+ implementation, reuse the expectations" — `main-service` is largely mechanical and
142
+ agent-generated, with the spec as oracle. *How* to author each layer lives in the
143
+ per-folder rules below.
142
144
 
143
145
  ## Reference implementations
144
146
 
@@ -61,24 +61,33 @@ portability and lazy loading (`AsyncDataService.createLazy`).
61
61
  - Members are async only: `void | Promise<T> | AsyncGenerator<T> | Observe<T>`.
62
62
  - Provide `create*` factories.
63
63
 
64
- ## Deterministic test doubles, adjacent to the contract
65
-
66
- A service is the seam consumers swap out under test — `data/` transitions that
67
- take it as an injected dependency (`data/state.md`), actions, systems. Ship a
68
- **deterministic test double** alongside the interface, in the same namespace
69
- folder and under the same `global/namespace.md` standard: `create-fake.ts` is a
70
- **single export**, `createFake`, re-exported through `public.ts` so callers reach
71
- it as `MyService.createFake` (mirroring the `create` / `factory` pair).
72
-
73
- Because tests assert on exact `after` values, the double must be **deterministic
74
- and its responses caller-controlled**: `createFake` takes the exact response — the
75
- fixed value, or the ordered sequence each method returns — as a parameter, with a
76
- small inline default. A conformance case then **injects the responses it needs**
77
- (`createFake(["random task"])`, `createFake([4])`) and authors its `after` /
78
- `effects` against those values it supplied — nothing is read from a shared
79
- published constant (that would be a second export, and it makes the assertion
80
- guess at a value it doesn't own). The injected schedule is exactly what makes the
81
- double a dependable oracle: the test controls both the input and the expectation.
64
+ ## Test doubles are a test-tier `*.fake.ts` — never in the production barrel
65
+
66
+ A service is the seam consumers swap out under test. Ship a **shape-only recording
67
+ template** alongside the interface, in the same namespace folder: `<name>.fake.ts`, a
68
+ **single export** `createFake` that returns the service with each method present but
69
+ inert (void methods do nothing; value methods return a placeholder). It is **NOT**
70
+ re-exported through `public.ts` — so `MyService.createFake` is never reachable from
71
+ runtime code — and it imports the contract with `import type` only. The conformance
72
+ manifest (`data/state.md`) imports it directly; nothing else does.
73
+
74
+ ```ts
75
+ // analytics-service/analytics.fake.ts — test tier, not on the namespace
76
+ import type { AnalyticsService } from "./analytics-service.js";
77
+ export const createFake = (): AnalyticsService => ({
78
+ serviceName: "analytics",
79
+ todoToggled: () => {}, // void method: inert
80
+ randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), // value method: placeholder
81
+ // …one entry per method
82
+ });
83
+ ```
84
+
85
+ The template supplies only the service's **shape** (the runner calls it once to
86
+ enumerate methods — no Proxy). It invents **no return values**: a conformance case
87
+ schedules each value-returning method's returns in its `responses` and asserts the
88
+ calls in its `effects` (`data/state.md`), so the case owns both the input and the
89
+ expectation. A `*.fake.ts` therefore takes no response parameter and hardcodes nothing
90
+ a case asserts.
82
91
 
83
92
  ## Where the I/O types live
84
93
 
@@ -20,7 +20,7 @@ there need not be a same-named transaction; transactions are the looser layer.
20
20
  **Per-frame / system transitions are exempt.** In a real-time feature the `step*`
21
21
  / physics / collision transitions are realized by the **systems** tick loop, not
22
22
  by an action, and are conformed by the tick-loop test (`systems.md`), not the
23
- action surface of `runFeature`. Give an action only to transitions a user/UI invokes directly
23
+ action surface of `checkFeature`. Give an action only to transitions a user/UI invokes directly
24
24
  (and skip it too when the realization needs more than one transaction — e.g. a
25
25
  `newGame` that both sets bounds and resets is conformed via its transaction).
26
26
 
@@ -53,18 +53,18 @@ export const addRandomTodo = async (service: ServiceDatabase) => {
53
53
  `services`, and assert the committed state and the recorded service calls. The
54
54
  bullet below applies only to **state-based** features (see `../../index.md`, Two modes).
55
55
  - **Conformance** is the action surface of the feature's single
56
- `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls
56
+ `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call. It pulls
57
57
  actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
58
58
  transition. **The action is the primary seam** — it reads injected services from
59
- `db.services`, so the case's service args become recording overrides (the runner
60
- builds `Database.toSystemDatabase(Database.create(plugin, { services }))`); it
61
- splits the case `args` into services and plain input, runs the action, then
62
- `Match.assert`s `toState ≡ after` **and** checks the recorded calls against the
63
- case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin
59
+ `db.services`, so the runner synthesizes each recording double from the manifest's
60
+ `services` templates + the case's `responses` and builds
61
+ `Database.toSystemDatabase(Database.create(plugin, { services }))`; it runs the action
62
+ with the case's data `args`, then `Match.assert`s `toState ≡ after` **and** checks the
63
+ recorded calls against the case's `effects`. No per-op wiring, no coverage guard. A thin
64
64
  **same-named** action gives a transaction-only or renamed transition something to
65
65
  pair with (todo's `reorderTodo`). A streaming/capability action with no transition
66
66
  is skipped. **A per-transition action kept out of the facet** (to bound the
67
- plugin's type) is discovered via `runFeature`'s `ops.actions` glob —
67
+ plugin's type) is supplied via the manifest's `ops.actions` glob —
68
68
  `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }`
69
- (p2p negotiation). That is the *only* reason to pass `ops` (see `conformance.md`).
69
+ (p2p negotiation). That is the *only* reason to set `ops` (see `conformance.md`).
70
70
  - An `index.ts` barrel feeds the `actions` plugin facet.
@@ -23,13 +23,11 @@ not a hot per-entity or large-N path. Reusing a tested derivation is good — th
23
23
  only cost is that it takes the **whole `State`**, so the computed must observe the
24
24
  full-state projection and re-runs on *any* field change (fine for a small feature,
25
25
  wasteful on a large or hot one, where you hand-wire the minimal resource/index
26
- reads instead). A derivation takes **no services**, so its co-located `cases` are
27
- inert `{ input, value }` data (no test-doubles) that tree-shake out of the app
28
- build (they reference the `@adobe/data-testing` matchers only, and that module is
29
- `sideEffects: false`) — the one hazard is a `cases` literal touching the
30
- `public.js` barrel at load, which `state.md` already forbids. **Performance is the
31
- first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal
32
- reads where it does.
26
+ reads instead). A derivation takes **no services**, so its sibling `*.cases.ts` is
27
+ inert `{ input, value }` data (no test-doubles); like every `*.cases.ts` it is
28
+ test-tier and excluded from the runtime build by the project-reference wall, so it
29
+ never reaches the app bundle. **Performance is the first-class constraint**: reuse
30
+ freely where it doesn't matter, hand-wire minimal reads where it does.
33
31
 
34
32
  ```ts
35
33
  import { cached } from "@adobe/data/cache";
@@ -53,10 +51,11 @@ only when the count changes, allocating no entity array. Prefer it over
53
51
  `db.observe.select(...)` mapped through `.length`.
54
52
 
55
53
  **Conform a computed to its `data/state` derivation** whenever one exists. The
56
- derivation co-locates `{ input, value }` cases (`Derivation<typeof fn>`), and the
57
- feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
58
- call conforms computeds: it pulls them off **`computedPlugin.computed`**, pairs each
59
- to its **same-named** derivation, seeds the store from `input`, reads the computed's
54
+ derivation's sibling `*.cases.ts` holds `{ input, value }` cases
55
+ (`Conformance.SpecDerivations<typeof fn>`), and the feature's single
56
+ `conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call conforms
57
+ computeds: it pulls them off **`computedPlugin.computed`**, pairs each to its
58
+ **same-named** derivation, seeds the store from `input`, reads the computed's
60
59
  synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`).
61
60
  There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase`
62
61
  layer** — the runner builds computed conformance from that layer, not the assembled
@@ -5,240 +5,91 @@ paths:
5
5
 
6
6
  # services/main-service/conformance/ — keeping the ECS honest against the spec
7
7
 
8
- **State-based features only.** This folder exists only when the feature has a
9
- `data/state/` spec to conform to. An **ECS-based** feature (no `data/state/`) omits
10
- this folder entirely and unit-tests its transactions/actions directly instead (see
11
- `../../index.md`, Two modes, and `transactions.md` / `actions.md`).
12
-
13
- Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state`
14
- cases are the shared truth; the shared **`@adobe/data-testing`** runner replays
15
- them against the ECS. This folder holds only the *feature-specific projection*
16
- (`projection.ts`) plus one `conformance.test.ts` that makes a **single**
17
- `Conformance.runFeature({...})` call. Reference: `data-lit-tictactoe`'s
18
- `conformance/` (the zero-config example) and `data-lit-todo` (entity markers +
19
- `hydrate`); `data-lit-space-rock-game` for the entity-bag / real-time variant.
20
-
21
- **The projection helpers (`fromState`, `toState`, `toData(store, entity)`) and
22
- `Match.matches` / `Match.assert` are test-only — never a production/runtime branch.**
8
+ **State-based features only** (a feature with a `data/state/` spec). Test-only: imported
9
+ only by `*.test.ts`, in no facet barrel. The `data/state` cases (authored per
10
+ `../../../data/state.md`, gathered by the `spec.ts` manifest) are the shared truth; the
11
+ `@adobe/data-testing` runner replays them against the ECS. This folder holds only the
12
+ **feature-specific projection** (`projection.ts`) plus one `conformance.test.ts`.
13
+ Reference: `data-lit-tictactoe` (minimal), `data-lit-todo` (entities + `hydrate`).
14
+
15
+ **The projection helpers and `Match` are test-only — never a runtime branch.**
23
16
  `fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through
24
- observables/indexes and writes through transactions. (The conformance
25
- `toData(store, entity)` reader is unrelated to the library's runtime `db.toData()`
26
- store-serialization method — same name only.)
27
-
28
- ## What `@adobe/data-testing` gives you
29
-
30
- Two namespaces, imported only from `*.test.ts` (the module is
31
- `sideEffects: false`; add it as a devDependency alongside `vitest`, its
32
- optional peer dependency — kept as a separate package from `@adobe/data` so
33
- installing `@adobe/data` never pulls in a `vitest` peer dependency):
34
-
35
- - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
36
- expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
37
- `Match.anyNumber` / `Match.anyString` for minted scalars a case does not pin.
38
- **Entity ids need no matcher**: a case writes plain spec-ids for map keys and
39
- reference fields, and the runner compares up to an id-bijection (below), so `samples`
40
- and `after` are ordinary `new Map([[1, value], …])` — no key labelling. Options are
41
- `{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb
42
- F32↔f64 / trig noise. **Ordering is carried by the value's type**: a
43
- `ReadonlyArray` compares **in order**, a `ReadonlySet` / `ReadonlyMap`
44
- **order-independently** — there is no `unordered` option. Entity identity is the
45
- key of `State.entities` (a `ReadonlyMap<number, …>`), resolved to the allocated ECS
46
- entity via `resolver` (below); entity *values* carry no `id`, so there is no id to
47
- ignore when comparing content. Framework-agnostic: it honors any asymmetric matcher,
48
- so vitest's `expect.any(...)` interops.
49
- - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
50
- `DerivationCases`, `Effects`, `ServiceCall`), the `casesBuilder` (a feature's
51
- `Conformance.cases`), `assertState` for custom harnesses, the whole-feature driver **`runFeature`**, the
52
- pure-spec driver **`runSpec`**, and the lower-level per-surface drivers
53
- `runTransactions` / `runActions` / `runComputeds` (internals of `runFeature`,
54
- exported for the escape hatch below). Auto-pairing (transition ⇄ op by name),
55
- effect recording, and id resolution are built **into** the drivers — no
56
- per-feature helper writes them. The `Effects` type-test also lives here (once),
57
- so there is no per-feature `conformance-case.type-test.ts` to author.
58
-
59
- ## Projection (store ⇄ State) — the one per-feature piece
60
-
61
- `projection.ts` is the only feature-specific file, and it is a **single
62
- aggregated export** — the three test-only helpers in one file, one `export`:
17
+ observables/indexes and writes through transactions.
18
+
19
+ ## `projection.ts` — the store ⇄ State mapping (the one per-feature piece)
20
+
21
+ A single aggregated export — three test-only helpers, one `export`:
63
22
 
64
23
  ```ts
65
- // conformance/projection.ts
66
24
  export const projection = { fromState, toState, toData };
67
25
  ```
68
26
 
69
- - `fromState(store, state)` seeds a store to a `State` (clear tail→head, insert
70
- entities, set resources). It **returns the `id → entity` map**
71
- (`ReadonlyMap<Id, Entity>`) it built while seeding — the ECS assigns ids from
72
- its own id-space, so the runner turns this map into a `resolve` via
73
- `Conformance.resolver`; **no feature writes id resolution by hand**. A feature
74
- whose transactions are index-addressed or singleton returns `void`, and any id
75
- then resolves to `Entity.none`.
27
+ - `fromState(store, state)` seeds a store to a `State` (clear, insert entities, set
28
+ resources) and **returns the `id → entity` map** it built (`ReadonlyMap<Id, Entity>`).
29
+ The ecs mints its own ids, so the runner turns this map into the resolver that maps a
30
+ case's spec-id to the seeded entity — no feature writes id resolution by hand. Return
31
+ `void` for an index-addressed / singleton feature (ids then resolve to `Entity.none`).
76
32
  - `toState(store)` reads the whole store back, built on `toData`.
77
- - `toData(store, entity)` reads one entity as its `data/` value — the single
78
- place the ECS↔data mapping lives; the runner reuses it to hydrate id-list
79
- computed outputs. **Present only when the feature has entities** (omit for a
80
- scalar / resource-only feature).
81
-
82
- This replaces the old separate `create-store.ts` / `from-state.ts` /
83
- `to-state.ts` / `to-data.ts`. **`create-store` is gone** — `runFeature` builds
84
- every store/db itself with `Store.create(plugin)` / `Database.create(plugin)`.
85
-
86
- ## ECS conformance is ONE call — `Conformance.runFeature`
33
+ - `toData(store, entity)` reads one entity as its `data/` value — the single place the
34
+ ecs↔data mapping lives. Its **return type** is what tells conformance which derivations
35
+ are entity-list computeds. Present only when the feature has entities.
87
36
 
88
- `conformance/conformance.test.ts` is a single call that conforms the whole
89
- feature — transaction + action + computed conformance **plus** the projection
90
- round-trip. No per-surface test file, no `define` callback, no `conforms(...)`
91
- adapters, no coverage guard.
37
+ ## `conformance.test.ts` — one call
92
38
 
93
39
  ```ts
94
- import { State } from "../../../data/state/state.js";
95
- import { transitions } from "../../../data/state/transitions.js";
96
-
97
- Conformance.runFeature({
98
- state: State,
99
- transitions,
100
- plugin: MainService.plugin,
101
- computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations
102
- projection,
103
- hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none
104
- match: { tolerance: 0.1 }, // float grid only; omit if 0.01 is fine
105
- ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet
106
- });
40
+ import { Conformance } from "@adobe/data-testing";
41
+ import { spec } from "../../../data/state/spec.js";
42
+ Conformance.checkFeature(spec);
107
43
  ```
108
44
 
109
- - **`state`** is the `State` namespace itself — the runner calls `State.create()`
110
- for the default each case's `before` deltas over, and round-trips `State.samples`
111
- through the projection.
112
- - **`transitions`** is imported from the feature's test-only
113
- `data/state/transitions.ts` (the single `import.meta.glob` of the `{ fn, cases }`
114
- source, shared with `spec.test.ts` — see `data/state.md`). Never re-glob it here.
115
- - **Ops come off the plugin facets.** The runner reads
116
- `plugin.transactions` / `plugin.actions` / `computedPlugin.computed` and builds
117
- the stores/dbs itself (`Store.create(plugin)` for transactions,
118
- `Database.create(plugin)` for actions, `Database.create(computedPlugin)` for
119
- computeds). Each op pairs to its **same-named** transition/derivation; auto-pairing
120
- can't forget an item, so there is **no coverage guard** — an op with **no
121
- same-named transition** is infrastructure or system-dispatched and is simply
122
- **skipped** (a streaming action with no transition is skipped too).
123
- - **`ops`** overrides the facet-discovered ops per surface — use it **only** when
124
- an op isn't registered in the plugin facet: a per-transition action kept out of
125
- the facet (to bound the plugin's type) is discovered via an
126
- `ops.actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true })`
127
- glob (p2p negotiation). Ordinary features omit `ops` entirely.
128
- - **`computedPlugin`** is the `ComputedDatabase` **layer** plugin — build computed
129
- conformance from this layer, **not** the assembled `MainService`: a behaviour
130
- layer above may `withCache` a pre-seed value that a direct `fromState` seed emits
131
- no transaction to invalidate; the computed layer keeps the seed authoritative.
132
- Omit it when the feature has no `state/` derivations (a computed with no
133
- derivation is skipped anyway, and single-`data/<type>` math is covered by that
134
- helper's own test).
135
- - **`hydrate`** names the computeds that emit an **entity-id list** (todo's
136
- `visibleTodos`) so the runner maps each id through `toData` into the value shape
137
- the derivation yields. Comparison is identity otherwise. Omit if none.
138
- - **`match`** threads `MatchOptions` through every comparison (see below). Omit if
139
- none.
140
- - **Projection round-trip.** When `State.samples` is non-empty, the runner adds a
141
- `toState ∘ fromState ≡ identity` test per sample — proving the pair round-trips
142
- faithfully so a symmetric bug in `fromState`/`toState` can't cancel out and mask
143
- a real ECS defect. No separate `projection.test.ts`.
144
-
145
- `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
146
- no `match`, no `ops` — moves are board-index addressed, so no `entity()`
147
- markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers.
148
- `data-lit-space-rock-game` holds its `bullet` / `asteroid` entities in the single
149
- `entities: ReadonlyMap<number, Bullet | Asteroid>`, keyed by id, so they compare
150
- order-independently by the map — no `match` option (its per-frame transitions are
151
- conformed by the systems tick loop, not here — see `systems.md`).
152
-
153
- ## The pure spec — `data/state/spec.test.ts`
154
-
155
- Lives in `data/state/`, not here. One call, importing the same `transitions`:
45
+ `checkFeature` replays the manifest's shared cases against the ECS: it pulls the
46
+ transactions/actions off `spec.plugin`, the computeds off `spec.computedPlugin`, seeds
47
+ each case's `before` over `State.create()`, synthesizes each service's recording double
48
+ from `spec.services` + the case's `responses`, round-trips `State.samples` through
49
+ `spec.projection`, and hydrates the id-emitting computeds named in `spec.hydrate` through
50
+ `toData`. It shares `spec` with `spec.test.ts` — same cases, substituted implementation.
51
+
52
+ Everything `checkFeature` needs lives on the manifest (`../../../data/state.md`):
53
+ `plugin`, `computedPlugin`, `projection`, `hydrate`, `services`, `state`, `fns`, `cases`.
54
+ The manifest's compile-time guards mean the two calls (`checkSpec` + `checkFeature`) are
55
+ the whole conformance surface — no per-op wiring, no coverage guard to hand-maintain.
56
+
57
+ - **Name-pairing.** Each ecs op conforms to its **same-named** transition/derivation. An
58
+ op with no same-named transition (infrastructure like `setInput`, a streaming action,
59
+ a richer UI op) is skipped — so when the real op is renamed (`dragTodo`), add a thin
60
+ **same-named** op (`reorderTodo`) for the transition to pair with; never a per-item
61
+ adapter.
62
+ - **Projection round-trip.** When `State.samples` is non-empty the runner adds a
63
+ `toState ∘ fromState ≡ identity` test per sample.
64
+ - **Comparison.** Ordering is carried by the value's type — `ReadonlyArray` positional,
65
+ `ReadonlySet`/`ReadonlyMap` order-independent. Entity ids compare up to an id-bijection
66
+ from the schema-marked spec-ids (no matcher). Float noise is absorbed by
67
+ `match?: { tolerance }` (default `0.01`). A minted scalar uses `Match.anyNumber` /
68
+ `Match.anyString`.
69
+
70
+ ## The escape hatch — per-surface `seedContext` / custom concurrency
71
+
72
+ A feature that needs **ambient per-case context differing per surface** — a user-scoped
73
+ `userId` seeded before the raw transaction *and* independently before the action dispatch
74
+ (**p2p presence**) — cannot use `checkFeature` (it has no place to thread two different
75
+ `seedContext`s or a custom concurrency). It drives the lower-level runners directly,
76
+ feeding them the manifest's cases via `Conformance.adaptCases`:
156
77
 
157
78
  ```ts
158
- import { State } from "./state.js";
159
- import { transitions } from "./transitions.js";
160
-
161
- Conformance.runSpec({ state: State, transitions });
79
+ import { Conformance } from "@adobe/data-testing";
80
+ import { spec } from "../../../data/state/spec.js";
81
+
82
+ Conformance.runTransactions({
83
+ createStore, fromState, toState,
84
+ transitions: Conformance.adaptCases(spec.fns, spec.cases, spec.services, false),
85
+ transactions,
86
+ seedContext: (store, _before, args) => seedUserId(store, (args as { mark: string }).mark),
87
+ });
162
88
  ```
163
89
 
164
- It discovers every file exporting `cases`, enforces the two-exports rule, and
165
- dispatches on case shape (transition → state + effects, derivation →
166
- `fn(input) ≡ value`), seeding each case's `before`/`input` as a delta over
167
- `State.create()`. On the ECS side it resolves the reference args a case's `args`
168
- schema marks; the pure side reads them plain. A transition case may declare
169
- `throws` instead of `after`/`effects` to assert the call rejects its input —
170
- see `../../data/state.md` ("A case that expects a throw"). `runSpec`,
171
- `runTransactions`, and `runActions` all honor it identically, so the same case
172
- proves the pure transform and its paired ecs op fail the same way.
173
-
174
- ## The exception — a per-surface `userId`, via the lower-level runners
175
-
176
- A feature that needs **ambient per-case context differing per surface** — a
177
- user-scoped `userId` that must be seeded before the raw transaction *and*
178
- independently before the action dispatch (**p2p presence**) — does **not** use
179
- `runFeature`. It calls `Conformance.runTransactions` / `runActions` (still
180
- exported) directly, each with its own `seedContext` (and any per-surface
181
- concurrency), because the seam differs between the transaction store and the
182
- action db. `runFeature` has no place to thread two different `seedContext`s, so
183
- this feature drops to the lower level. This is the escape hatch — ordinary
184
- features never touch these drivers directly.
185
-
186
- ## Identity — the `args` schema
187
-
188
- An entity-addressed transition declares an `args` schema in the builder options and
189
- writes the id **plain**: `Conformance.cases(fn, { args: { type: "object", properties:
190
- { id: Entity.schema } } }, { …, args: { id: 2 }, … })`. The runner
191
- finds each `Entity.schema`-marked arg field and resolves the plain spec-id to the
192
- **seeded entity** via the id→entity map `fromState` returns (turned into a `resolve`
193
- by `Conformance.resolver` — no feature writes `resolve` by hand); the pure side reads
194
- the spec-id as-is. Two conventions make the wiring vanish: the ECS op takes the entity
195
- **under the transition's own arg key** (`{ id }`, same-shape args, no reshape), and
196
- `fromState` returns the `ReadonlyMap<Id, Entity>` id→entity map (or `void` for an
197
- index-addressed / singleton feature, whose ids then resolve to `Entity.none`).
198
- `data-lit-todo` is the reference. Describe only the reference fields — services and
199
- non-id args are omitted, and the args type must be assignable to the schema's
200
- `Schema.ToType` so it cannot drift from the signature.
201
-
202
- ## Name-parity — add a same-named op, never a per-item adapter
203
-
204
- Every app transition is realized by a **same-named** transaction and/or action. When
205
- the real UI op is richer or renamed — todo's `dragTodo`, space-rock's `newGame` — that
206
- op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-rock's
207
- `createInitial` transaction) gives the transition something to pair with. Do **not**
208
- reintroduce a per-item adapter to bridge a name mismatch — add the same-named op.
209
-
210
- ## Ordering, tolerance, the id-bijection
211
-
212
- Ordering is carried by the value's **type**, not a match option — `ReadonlyArray`
213
- positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its
214
- rationale live in `../../../data-modelling.md`). What's specific to writing
215
- conformance cases:
216
-
217
- - **The spec and the ECS mint different id sets, so comparison is up to an
218
- id-bijection — automatically.** The runner reads the store's `componentSchemas`
219
- (which also carry the resource schemas) and finds every entity reference: an
220
- `entities` map **key** (an id by construction) and any field whose schema is
221
- `Entity.schema` (`schema.entity === true`), recursing into bundled object schemas so
222
- a `placement: { parent, order }` reference is found. It replaces each spec-id there
223
- with a correspondence variable and solves a one-to-one matching against the ECS's
224
- ids. So a case **writes plain spec-ids** for keys and reference fields, and a key and
225
- every field naming it line up on one actual id with no author bookkeeping — this is
226
- what retired `Match.ref` / `Match.refMap` for keys, and it is what makes a
227
- cross-reference between entities (an `asset`, a `parent`) compare correctly at all.
228
- - **Entity values carry no `id`** — identity is the key — so entity content compares
229
- directly, with nothing to omit.
230
- - **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through
231
- `match?: { tolerance }`; raise it only when a case needs a looser grid.
232
- - **A minted scalar** a case does not pin — a timestamp, a random draw — uses
233
- `Match.anyNumber` / `Match.anyString`. Entity-id correspondence needs no matcher: the
234
- runner derives it from the schema-marked spec-ids (the solver is internal).
235
-
236
- ## Recording side effects — built in, no Proxy
237
-
238
- Effect recording lives in the drivers: they enumerate a plain-object service's own
239
- methods and closure-wrap each to record `[method, ...args]` calls, then delegate
240
- (no `Proxy`, per the repo rule). `runSpec` records the case's injected service
241
- `args`; the action surface records the `db.services` overrides. A case's `effects`
242
- asserts each **declared** service's calls exactly (`Array` = ordered, `Set` = any
243
- order); undeclared services (value-returning reads like `generateName`) are
244
- ignored.
90
+ `adaptCases(fns, cases, services, injectServices)` turns the manifest into the discovery
91
+ map the runners consume — `injectServices: false` for the transaction store (a
92
+ transaction never receives services), `true` for the action db. `runActions` /
93
+ `runComputeds` take the same shape and their own `makeDb` / `seedContext` / concurrency.
94
+ This is the **only** sanctioned use of the lower runners; ordinary features use
95
+ `checkFeature`.
@@ -38,7 +38,7 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => {
38
38
  direct substitute for the conformance oracle (see `../../index.md`, Two modes). The
39
39
  bullet below applies only to **state-based** features.
40
40
  - **Conformance is wired once, centrally, and auto-paired** — not per-file. The
41
- feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
41
+ feature's single `conformance/conformance.test.ts` `Conformance.checkFeature(spec)`
42
42
  call conforms transactions: it pulls them off **`plugin.transactions`** (the
43
43
  registered facet), pairs each to the **same-named** `data/state` transition, and
44
44
  conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after`
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adobe-data-ai",
3
- "version": "0.10.19",
3
+ "version": "0.10.20",
4
4
  "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
5
5
  "author": {
6
6
  "name": "Adobe"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-ai",
3
- "version": "0.10.19",
3
+ "version": "0.10.20",
4
4
  "description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).",
5
5
  "type": "module",
6
6
  "private": false,
@@ -29,12 +29,12 @@
29
29
  "skills"
30
30
  ],
31
31
  "peerDependencies": {
32
- "@adobe/data": "0.10.19"
32
+ "@adobe/data": "0.10.20"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.3",
36
36
  "vitest": "^1.6.0",
37
- "@adobe/data": "0.10.19"
37
+ "@adobe/data": "0.10.20"
38
38
  },
39
39
  "license": "MIT",
40
40
  "scripts": {