@adobe/data-ai 0.9.91 → 0.9.93

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.
@@ -15,9 +15,10 @@ discriminated unions.
15
15
  ## Example
16
16
 
17
17
  ```ts
18
- // types/http-method/schema.ts
19
- export const schema = { type: "string", enum: ["GET", "POST", "PUT", "DELETE"] }
20
- as const satisfies Schema;
18
+ // types/http-method/http-method.ts — the hand-authored type owns the member identity
19
+ export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
20
+ // (a schema.ts, if/when a runtime boundary needs one, is written to match this
21
+ // type and pinned to it — see global/namespace.md and features/data/index.md)
21
22
 
22
23
  // In an unrelated request-counter plugin:
23
24
 
@@ -143,7 +143,7 @@ Synchronous, deterministic atomic mutations. Receive `store` and a payload.
143
143
 
144
144
  ### actions
145
145
 
146
- UI components that call actions must never consume returned values — see `.claude/rules/binding-element.md` (Actions are fire-and-forget).
146
+ UI components that call actions must never consume returned values — see `features/ui/binding-element.md` (Actions are fire-and-forget).
147
147
 
148
148
  **At most one transaction per action.** Multiple transactions in a single action corrupt the undo/redo stack.
149
149
 
@@ -125,7 +125,7 @@ exists *only* for locator inputs — never for state, never for derived
125
125
  values, never for parent-forwarded flags. The `P` in `DatabaseElement<P>` (and
126
126
  the `get plugin()` it returns) is the surface the element *consumes* — never the
127
127
  varying topmost-layer name (see `features/services/main-service/index.md`): `typeof
128
- FeatureDatabase.plugin` for a self-contained feature. **Exception:** an element
128
+ MainService.plugin` for a self-contained feature. **Exception:** an element
129
129
  meant to be *extended* — injected with various databases that build on this
130
130
  feature (a peer app that adds an agent, a p2p/presence build) — types on the
131
131
  minimal base layer it consumes (e.g. `ComputedDatabase.plugin`) so every
@@ -5,30 +5,75 @@ paths:
5
5
 
6
6
  # data/ — the data model
7
7
 
8
- The foundation layer: the feature's **data types**. A data type is a
9
- readonly, JSON-serializable value suitable for persistence and for
10
- communication over the wire — no functions, no handles, just plain data.
11
- It depends on nothing but `@adobe/data` and other `data/` declarations, and
12
- needs no knowledge of anything built on top of it.
8
+ The foundation layer: the feature's **data types** and the pure declarations
9
+ over them. A data type is a readonly, JSON-serializable value suitable for
10
+ persistence and for communication over the wire — no functions, no handles, just
11
+ plain data. A `data/` **type** depends on nothing but `@adobe/data` and other
12
+ `data/` types, and needs no knowledge of anything built on top of it. The
13
+ **transitions** over those types are less restricted: when one injects a service
14
+ it may import freely from `services/` — the service type and any utilities its
15
+ namespace exposes (see `state.md`). What it must **never** touch is `ui/`:
16
+ `data/` never depends on presentation — that isolation is strict and inviolable.
17
+ Only *services themselves* are never authored here; that is what makes this the
18
+ value-type layer.
13
19
 
14
20
  Each data type is its own namespace folder (see `global/namespace.md`), holding
15
- its schema, its derived type, and its pure synchronous helpers together:
21
+ its hand-authored type, its (optional, matching) schema, and its pure
22
+ synchronous helpers together:
16
23
 
17
24
  ```
18
25
  data/player-mark/
19
- player-mark.ts # type alias + `export * as PlayerMark from "./public.js"`
20
- schema.ts # `export const schema = { … } as const satisfies Schema`
26
+ player-mark.ts # HAND-AUTHORED type + `export * as PlayerMark from "./public.js"`
27
+ schema.ts # (optional / later) schema matching the type, pinned via Assert<Equal<…>>
21
28
  public.ts # re-exports schema + helpers
22
29
  is.ts values.ts opponent.ts … # one pure helper per file
23
30
  ```
24
31
 
25
- - The **type is derived from the schema** (`Schema.ToType<typeof schema>`),
26
- never hand-written alongside it.
27
- - The **schema is the single source of truth** for the shape; the derived
28
- type and every consumer flow from it.
32
+ - The **type is authored by hand** in `<type>.ts` — the readable source of
33
+ truth. Write it with the richest types available (`F32`, `Vec3`, unions,
34
+ branded aliases) so members carry their semantic meaning and get strong
35
+ IntelliSense / hover docs.
36
+ - The **schema is derived from the type, not the reverse.** It is written to
37
+ match the type and **pinned** to it with a compile-time assertion so the two
38
+ cannot drift (see below). A field typed as a branded primitive reuses that
39
+ type's schema — `F32` → `F32.schema`, `Vec3` → `Vec3.schema`.
40
+ - **Schemas are optional until a runtime boundary needs one.** A pure `data/`
41
+ type is complete with `<type>.ts` alone. The schema is added only when
42
+ persistence, the wire, or an ECS component/resource requires it — and is
43
+ typically written by the agent in a later phase (e.g. when implementing the
44
+ ECS), not by the human up front.
29
45
  - Helpers are synchronous and pure; each has a sibling `*.test.ts`.
30
- - Objects with only numeric 32 bit values/sub-structs should use Schema.fromStructProperties
31
- It guarantees a valid struct schema which will be stored in linear memory.
46
+ - Objects of only 32-bit numeric values / sub-structs: the schema uses
47
+ `Schema.fromStructProperties` (guarantees a valid struct schema, stored in
48
+ linear memory).
49
+
50
+ ## Pinning the schema to the type
51
+
52
+ The type is written first and by hand; the schema is written to match it and
53
+ is locked to it at compile time, so schema authoring (whenever it happens)
54
+ cannot silently drift from the type readers rely on.
55
+
56
+ ```ts
57
+ // player-mark/player-mark.ts — HAND-AUTHORED, the source of truth
58
+ export type PlayerMark = 'X' | 'O';
59
+ export * as PlayerMark from './public.js';
60
+
61
+ // player-mark/schema.ts — written to MATCH the type (often later, by the agent)
62
+ import { Schema } from '@adobe/data/schema';
63
+ import type { Assert, Equal } from '@adobe/data/types';
64
+ import type { PlayerMark } from './player-mark.js';
65
+
66
+ export const schema = { type: 'string', enum: ['X', 'O'] } as const satisfies Schema;
67
+
68
+ // Compile-time pin: fails to build if schema and type diverge.
69
+ type _Pin = Assert<Equal<Schema.ToType<typeof schema>, PlayerMark>>;
70
+ ```
71
+
72
+ The `Assert<Equal<…>>` line is the whole safety net: `Schema.ToType<typeof
73
+ schema>` must be exactly the hand-authored type (including readonly/optional),
74
+ or the file does not compile. `Schema.ToType` is used **only** inside the
75
+ assertion — never as the exported type. Consumers always import the
76
+ hand-authored `PlayerMark`.
32
77
 
33
78
  One folder is special: **`data/state/`** holds the feature's single `State`
34
79
  aggregate and its pure transforms/derivations (its own rule, `state.md`);
@@ -5,52 +5,212 @@ paths:
5
5
 
6
6
  # data/state/ — the State specification
7
7
 
8
- `State` is the whole feature modelled as **one immutable object** — the
9
- pure, fully-tested source of truth for the feature. Modelling everything as
10
- a single value keeps each transform a small, trivially-testable function of
11
- state: collections of entity sub-types plus scalar fields.
8
+ `State` is the whole feature as **one immutable object** — the pure, fully-tested
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/`.
12
11
 
13
- Every feature that has ECS resources or transactions owns a `State` — including
14
- a host whose aggregate is a single scalar (`{ playing: boolean }`) or even when there is no state in which case use `{}`.
15
12
  ```ts
16
- // state.ts — the aggregate, plus the namespace of transforms/derivations.
17
- export type State = {
18
- readonly todos: readonly Todo[]; // a collection of entity sub-types
19
- readonly displayCompleted: boolean; // a scalar field
20
- };
13
+ // state.ts — the aggregate + the transition/derivation namespace.
14
+ export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
21
15
  export * as State from "./public.js";
22
16
  ```
23
17
 
24
- ## Transforms — one per file, `(state, …args) => state`
25
-
26
- - **Pure.** No I/O, no framework, no mutation — return a new value.
27
- - **Narrow in, same shape out.** Write on the smallest slice the transform
28
- needs and keep the input generic over that slice so it lifts to
29
- full-state-in / full-state-out:
30
-
31
- ```ts
32
- export const playMove = <T extends Pick<State, "board" | "firstPlayer">>(
33
- state: T,
34
- input: PlayMoveArgs,
35
- ): T => { /* … return { ...state, board: … } */ };
36
- ```
37
-
38
- A whole-`State` transform (`restartGame(state: State): State`) is fine when
39
- it genuinely touches everything.
40
- - Args may be **narrowed or omitted** (`toggleDisplayCompleted(state)`).
41
- - Guard and **return `state` unchanged** on a no-op / illegal input rather
42
- than throwing — this keeps transforms idempotent under repeated application.
43
- - Each transform has a sibling `*.test.ts`; performance is irrelevant here,
44
- correctness is everything. Keep its cases in a sibling `<transform>.cases.ts`
45
- typed `ConformanceCase<Args>[]` (the shared `conformance-case.ts` type) —
46
- spec-owned truth the matching main-service conformance test imports unchanged (see
47
- `services/main-service/conformance.md`). Author `before`/`after` as full `State`
48
- (`{ ...State.create(), …overrides }`); the generic-slice signature lets them
49
- flow through. Tolerant full-`State` equality is the shared
50
- `expect-state-matches.ts`, alongside the cases here.
51
-
52
- ## Derivations — `(state) => value`
53
-
54
- Pure selectors (`visibleTodos(state)`). Sub-type math (a winner, a status)
55
- lives on the relevant `data/<type>` namespace; `state/` only composes over
56
- the whole aggregate.
18
+ Every feature with ECS resources/transactions owns a `State` (a scalar
19
+ `{ playing: boolean }`, or `{}` when there is none).
20
+
21
+ **`State` has a standard shape.** Two exports are conventional and drive
22
+ conformance:
23
+
24
+ - **`create(): State`** (`data/state/create.ts`) — the default state. Every
25
+ conformance case's `before` is a **delta over `State.create()`**, and both the
26
+ pure `spec.test.ts` and the ecs `runFeature` seed from it.
27
+ - **`samples: readonly State[]`** (`data/state/samples.ts`) — representative
28
+ **full** states `runFeature` round-trips through the projection
29
+ (`toState ∘ fromState ≡ identity`, see `conformance.md`).
30
+
31
+ Both are re-exported through `public.js`, so `State.create()` / `State.samples`
32
+ are namespace members. (A `cases` literal must still not touch `public.js` at
33
+ load — import `create` from `./create.js` directly there; see below.)
34
+
35
+ **The discovered transitions are a test-only sibling, NOT on the namespace.**
36
+ `data/state/transitions.ts` exports one `import.meta.glob` of the folder — the
37
+ `{ fn, cases }` modules — imported by *both* `spec.test.ts` and the ecs
38
+ `conformance.test.ts` so the glob is authored once per feature:
39
+
40
+ ```ts
41
+ // data/state/transitions.ts — test-only; both test entry points import it
42
+ export const transitions = import.meta.glob<Record<string, unknown>>(
43
+ ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"],
44
+ { eager: true },
45
+ );
46
+ ```
47
+
48
+ It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain
49
+ prod-safe values), `transitions` is the test-case graph — hanging it on `State`
50
+ would drag `import.meta.glob` (a Vite-only construct) and every `cases` fixture
51
+ (fake services, matchers, the whole `@adobe/data/testing` module) into every
52
+ production import of `State`, none of which tree-shakes off a live namespace
53
+ re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test
54
+ concern the tests import directly.
55
+
56
+ ## One file per transform: the function **and** its cases
57
+
58
+ A transform file exports **exactly two things** — the function and its
59
+ `cases` — nothing else (a private helper is fine; a second export is not, and the
60
+ spec aggregator throws if it finds one). The cases are the spec-owned truth every
61
+ conformance runner reuses; co-locating them removes the per-transform `.cases.ts`
62
+ and `.test.ts`.
63
+
64
+ **Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned
65
+ fixtures the pure `spec.test.ts` **and** the ecs `runFeature` call both reuse
66
+ (driving transaction / action / computed conformance) — the transform's contract
67
+ expressed as data, not a per-file test — so they belong
68
+ beside the thing they specify, and `Conformance<typeof fn>` binds them to the
69
+ signature so they can't drift. Kept inert (no `describe`; one aggregator runs
70
+ them) they also sidestep the double execution vitest triggers when a single file
71
+ both exports cases and runs its own `describe`. Coverage is then enforced
72
+ centrally by the shared driver's barrel-driven guard rather than by eyeballing
73
+ one test file per transform — and genuine non-transition helpers still keep
74
+ their own `*.test.ts` (see below).
75
+
76
+ The case types, matchers, and runners all live in the shared
77
+ **`@adobe/data/testing`** module (two namespaces, `Match` and `Conformance`;
78
+ `vitest` is an *optional* peer dependency, already satisfied here). Only one
79
+ tiny per-feature file remains — a ~10-line alias, `conformance-case.ts`, that
80
+ binds `State` once so transform/derivation files can write a one-parameter type:
81
+
82
+ ```ts
83
+ // data/state/conformance-case.ts — the only per-feature conformance declaration
84
+ import { Conformance as ConformanceApi } from "@adobe/data/testing";
85
+ import type { State } from "./state.js";
86
+ export type Conformance<F extends (...args: never[]) => unknown> = ConformanceApi.Cases<State, F>;
87
+ export type Derivation<F extends (...args: never[]) => unknown> = ConformanceApi.DerivationCases<F>;
88
+ export type Effects<Args> = ConformanceApi.Effects<Args>;
89
+ // The entity-reference marker for identity-addressed case args, re-exported so
90
+ // cases import it beside `Conformance`: `args: { id: entity(2) }`.
91
+ export const entity = ConformanceApi.entity;
92
+ ```
93
+
94
+ ```ts
95
+ // create-todo.ts
96
+ import { Match } from "@adobe/data/testing";
97
+ import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
98
+ export const createTodo = (
99
+ state: Pick<State, "todos">,
100
+ { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
101
+ ): Pick<State, "todos"> => {
102
+ analytics.todoCreated({ name });
103
+ return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
104
+ };
105
+
106
+ export const cases: Conformance<typeof createTodo> = [
107
+ { name: "appends the first todo",
108
+ before: {}, // empty delta — the default State.create()
109
+ args: { name: "a", analytics: AnalyticsService.createFake() },
110
+ after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field
111
+ effects: { analytics: [["todoCreated", { name: "a" }]] } },
112
+ ];
113
+ ```
114
+
115
+ - **Signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` — a
116
+ **read→write patch**. The parameter is the smallest `Pick<State,…>` the
117
+ transition **reads**; the return is *only the fields it **writes***. **No
118
+ `<T> => T` generic, no `...state` spread** in the return — return the patch and
119
+ let the runner merge it. **All non-state inputs go in the single `args` object**
120
+ (`Conformance<typeof fn>` reads `Parameters[1]`) — bundle a `dt`, an injected
121
+ service, etc. into it, never as a third positional. A transition that takes
122
+ **no** args omits `args` from each case entirely (the shared `Case` type makes
123
+ `args` optional exactly then). **Guard no-ops by returning an empty patch `{}`**
124
+ (or the unchanged slice), never throw.
125
+ - **A composer merges sub-patches explicitly.** A transition built from smaller
126
+ ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes
127
+ layer in; a transition that merely **delegates** to one sub-transition returns
128
+ that delegate's patch directly (no spread needed).
129
+ - **Co-located `cases` must not touch the feature's `public.js` barrel at module
130
+ load** — that barrel re-exports this very file, so calling `State.create()` (or
131
+ any barrel member) in a top-level `cases` literal dead-locks the import cycle.
132
+ Import the concrete helper directly (`import { create } from "./create.js"`) or
133
+ inline full-`State` literals.
134
+ - **`Conformance<typeof fn>`** (the alias above) derives the case `args` type from
135
+ the function's own signature — author it once, and cases can't drift from what
136
+ the function accepts. **`before` is a delta over `State.create()`** — list only
137
+ the fields this case sets differently from the default; **`after` is the writes
138
+ patch** — only the fields the transition changes. The runner seeds
139
+ `{ ...State.create(), ...before }` and compares against
140
+ `{ ...State.create(), ...before, ...after }`, so any field a case doesn't mention
141
+ is the default and stays unchanged. (A full `before`/`after` still works — it just
142
+ overrides the default wholesale.)
143
+ - **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
144
+ / `Match.anyString`, imported from `@adobe/data/testing` — there is **no**
145
+ per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is
146
+ `id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match
147
+ by content, not by the value you don't control. `Match` is framework-agnostic and
148
+ honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
149
+ on the expected side too. When an id must **line up in two places** within one
150
+ comparison — a `selectedId` that points at a specific todo, say — use
151
+ `Match.ref(label)`: it asserts id *correspondence* (a bijection up to renaming),
152
+ not a pinned value, so the two occurrences of the label must resolve to the same
153
+ actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a
154
+ case does not pin at all; `ref` for one that must be consistent across the case.
155
+ - **Entity-addressed cases use `entity(specId)`.** A transition that addresses an
156
+ entity by id writes it as `args: { id: entity(2) }` — `entity` imported from the
157
+ feature's `conformance-case.ts` (re-exported from `@adobe/data/testing`). It types
158
+ as the id it stands for (like `Match.anyNumber`), so it slots into the transform's
159
+ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS
160
+ runners resolve it to the seeded entity (see `conformance.md`).
161
+ - No per-transform test. The single **`spec.test.ts`** is one call —
162
+ `Conformance.runSpec({ state: State, transitions })` importing `transitions`
163
+ from the test-only `./transitions.js` (above) — that auto-discovers every module
164
+ exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a
165
+ `value` case → derivation; otherwise a transition whose declared `effects` are
166
+ also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
167
+ takes) is what makes each case's `before`/`input` a delta over `State.create()`.
168
+ Add `match` alongside it only when
169
+ the feature needs float tolerance or unordered collections (see `conformance.md`).
170
+ There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
171
+ `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
172
+ shared driver owns comparison, effect recording, and name-based auto-pairing, and
173
+ the `Effects` type-test now lives once in `@adobe/data/testing`. A genuine **non-transition helper** in
174
+ `state/` — a `create()` constructor, a single-field predicate — has no `cases`
175
+ and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
176
+ sibling `*.test.ts`** rather than deleting it and losing coverage.
177
+
178
+ ## Injected services and side effects
179
+
180
+ A transform that needs an outside capability receives it as a **named parameter**
181
+ in the args object, keyed by the service name minus its `-service` suffix
182
+ (`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`);
183
+ plain data args sit alongside. Import the service straight from `services/` (an
184
+ ordinary import — layers split by kind of type, not dependency). The **same
185
+ services appear on `db.services` for the matching action**, so the transition is
186
+ the complete spec of *both* the state change and the service calls.
187
+
188
+ - A transition **is deterministic given its dependencies** — inject a fixed
189
+ double and the output (and its calls) are fixed. An **async** dependency makes
190
+ the transition `Promise<State>`; keep sync the default.
191
+ - **Side effects are declared in the case's `effects`**, keyed by the service
192
+ arg, as `[methodName, ...args]` tuples — an `Array` asserts these calls in
193
+ order, a `Set` in any order. Only listed services are checked (a value-returning
194
+ read like `generateName` you don't list is ignored). See `conformance.md` for
195
+ how the recording double captures them.
196
+ - Tests inject **deterministic doubles** (never production) whose published
197
+ responses the case's `after`/`effects` are authored against — doubles live
198
+ adjacent to the interface (`features/services/index.md`).
199
+
200
+ ## Derivations — `(state) => value`, cases `{ input, value }`
201
+
202
+ Pure selectors that **compose the aggregate** — a value drawn from **two or more
203
+ `State` fields** (`visibleTodos` from `todos` + `displayCompleted`;
204
+ `currentPlayer` from `board` + `firstPlayer`). A value computed from a **single**
205
+ `State` field is that field's own type math and lives on its `data/<type>`
206
+ namespace (`winner`/`status` from `board` → `data/board-state`), tested there —
207
+ **not** in `state/`. A feature may therefore have zero `state/` derivations.
208
+
209
+ A `state/` derivation co-locates cases shaped `{ input, value }`, typed
210
+ `Derivation<typeof fn>` (input + value read from the signature). Take the **full
211
+ `State`** as the parameter (not a `Pick` slice like a transform) — so the case
212
+ `input` type is the full `State` and computed conformance can seed it via
213
+ `fromState`; `value` may use matchers. The same `spec.test.ts` runs them (dispatching on case shape),
214
+ and each ECS computed backing one is conformance-tested from the same cases
215
+ (`conformance.md`). Sub-type math (a winner, a status) lives on the
216
+ relevant `data/<type>` namespace; `state/` only composes over the aggregate.
@@ -26,9 +26,25 @@ derive a fast one that provably behaves the same.
26
26
 
27
27
  ## The layers
28
28
 
29
- `ui → services → data` — higher imports lower, never the reverse. `data/`
30
- depends on nothing but `@adobe/data` and other `data/` declarations. A feature
31
- creates only the layers it uses.
29
+ **The layers organize by the *kind of type* each folder holds, not by a strict
30
+ dependency wall.** `data/` holds **value types** (pure, serializable data) and
31
+ the pure declarations over them; `services/` holds **service types** (interfaces
32
+ **and** implementations); `ui/` holds presentation. The rule is about *what
33
+ lives where*: a service is never authored under `data/`, a value type is never
34
+ authored under `services/`.
35
+
36
+ **`ui/` isolation is strict and inviolable.** `ui/` sits at the top: neither
37
+ `data/` nor `services/` may ever import from `ui/`, no exceptions. Presentation
38
+ depends on the model; the model never depends on presentation.
39
+
40
+ Beneath that hard line, `data/` and `services/` are **not** strictly ordered.
41
+ Value types are fundamental — a `data/` **type** depends on nothing but
42
+ `@adobe/data` and other `data/` types. But the **transition functions** over
43
+ them may depend on `services/` **without restriction**: they import whatever they
44
+ need — service interfaces they inject, and any associated utilities the service
45
+ namespace exposes — as ordinary imports, not type-only. So the strict wall is
46
+ `ui/` above `data/` + `services/`; the `data/ ↔ services/` boundary is the loose
47
+ one. A feature creates only the layers it uses.
32
48
 
33
49
  | Layer | Role |
34
50
  |-------|------|
@@ -85,13 +101,16 @@ The tie between `data/` (spec) and `main-service` (implementation) is
85
101
  **conformance**, one property —
86
102
  `toState(apply(fromState(before), args)) ≡ transform(before, args)`: each
87
103
  main-service mutation, seeded and read back through a test-only store↔`State`
88
- projection, equals the pure `data/` transform it stands for. The projection and
89
- its runner live in `services/main-service/conformance/` (see
90
- `services/main-service/conformance.md`); the shared `{ before, args, after }`
91
- cases are spec-owned (`data/state/<transform>.cases.ts`), so conforming the
92
- implementation is "substitute the implementation, reuse the expectations." This
93
- lets `main-service` be largely mechanical and agent-generated, with the spec as
94
- oracle. *How* to author each layer lives in the per-folder rules below.
104
+ projection, equals the pure `data/` transform it stands for. The per-feature
105
+ projection lives in `services/main-service/conformance/`, and a **single
106
+ `Conformance.runFeature({...})` call** replays the shared cases against the ecs —
107
+ pairing each ECS op to its same-named transition automatically and round-tripping
108
+ the projection (see `services/main-service/conformance.md`);
109
+ the shared `{ before, args, after }` cases are spec-owned — co-located in each
110
+ `data/state/<transform>.ts`, which exports its function plus `cases` — so
111
+ conforming the implementation is "substitute the implementation, reuse the
112
+ expectations." This lets `main-service` be largely mechanical and agent-generated,
113
+ with the spec as oracle. *How* to author each layer lives in the per-folder rules below.
95
114
 
96
115
  ## Reference implementations
97
116
 
@@ -33,6 +33,25 @@ portability and lazy loading (`AsyncDataService.createLazy`).
33
33
  - Members are async only: `void | Promise<T> | AsyncGenerator<T> | Observe<T>`.
34
34
  - Provide `create*` factories.
35
35
 
36
+ ## Deterministic test doubles, adjacent to the contract
37
+
38
+ A service is the seam consumers swap out under test — `data/` transitions that
39
+ take it as an injected dependency (`data/state.md`), actions, systems. Ship a
40
+ **deterministic test double** alongside the interface, in the same namespace
41
+ folder and under the same `global/namespace.md` standard: `create-fake.ts` is a
42
+ **single export**, `createFake`, re-exported through `public.ts` so callers reach
43
+ it as `MyService.createFake` (mirroring the `create` / `factory` pair).
44
+
45
+ Because tests assert on exact `after` values, the double must be **deterministic
46
+ and its responses caller-controlled**: `createFake` takes the exact response — the
47
+ fixed value, or the ordered sequence each method returns — as a parameter, with a
48
+ small inline default. A conformance case then **injects the responses it needs**
49
+ (`createFake(["random task"])`, `createFake([4])`) and authors its `after` /
50
+ `effects` against those values it supplied — nothing is read from a shared
51
+ published constant (that would be a second export, and it makes the assertion
52
+ guess at a value it doesn't own). The injected schedule is exactly what makes the
53
+ double a dependable oracle: the test controls both the input and the expectation.
54
+
36
55
  ## Where the I/O types live
37
56
 
38
57
  Most input/output types belong to a single service — declare them **on that
@@ -10,23 +10,57 @@ argument and pure `data/` args. Actions orchestrate anything *outside* a
10
10
  single transaction — awaiting a `services/` port, sequencing calls, deriving
11
11
  timing — and then commit the result through a transaction.
12
12
 
13
+ **Every *app-facing, transaction-backed* transition has a corresponding
14
+ same-named action** — the async realization the UI drives. It reads the same
15
+ services the transition injects from `db.services`, so it reproduces both the
16
+ transition's state change (through a transaction) and its side effects. It may
17
+ reuse another transition's transaction (`createRandomTodo` reuses `createTodo`) —
18
+ there need not be a same-named transaction; transactions are the looser layer.
19
+
20
+ **Per-frame / system transitions are exempt.** In a real-time feature the `step*`
21
+ / physics / collision transitions are realized by the **systems** tick loop, not
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
24
+ (and skip it too when the realization needs more than one transaction — e.g. a
25
+ `newGame` that both sets bounds and resets is conformed via its transaction).
26
+
13
27
  ```ts
14
28
  import type { ServiceDatabase } from "../../service-database/service-database.js";
15
29
 
16
- export const addRandomTodo = async (db: ServiceDatabase) => {
17
- const name = await db.services.nameGenerator.generateName(); // await a services/ port
18
- db.transactions.createTodo({ name }); // then exactly one commit
30
+ export const addRandomTodo = async (service: ServiceDatabase) => {
31
+ const name = await service.services.nameGenerator.generateName(); // await a services/ port
32
+ service.transactions.createTodo({ name }); // then exactly one commit
19
33
  };
20
34
  ```
21
35
 
22
- - Type the `db` parameter on the lowest database layer exposing what the
36
+ - Type the `service` parameter on the lowest database layer exposing what the
23
37
  action touches — usually `ServiceDatabase` (services **and**
24
38
  transactions). Never the action layer itself (that would be a cycle).
25
39
  - **Call at most one transaction** per action, so undo/redo stays one step
26
40
  per operation.
27
41
  - **Fire-and-forget.** An action's return value is not consumed; results flow
28
- back through observables. `db.services.*` calls are `void` or
42
+ back through observables. `service.services.*` calls are `void` or
29
43
  awaited-internally, never surfaced to the caller.
30
44
  - Do the outside-world work here: await/sequence `services/` calls, and if a
31
45
  slow call needs timing, compute it here around the call.
46
+ - **Read current state synchronously from the store** (`db.resources` / `db.read`
47
+ / `db.select`, then a pure `data/` helper) — never from a cached `computed`.
48
+ Reactive computeds refresh only on a committed transaction, so an imperative
49
+ read of one can hand back a stale shared cache (and it's the UI's layer, not
50
+ the action's). This also keeps the action correct under the conformance seed.
51
+ - **Conformance** is the action surface of the feature's single
52
+ `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls
53
+ actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
54
+ transition. **The action is the primary seam** — it reads injected services from
55
+ `db.services`, so the case's service args become recording overrides (the runner
56
+ builds `Database.toSystemDatabase(Database.create(plugin, { services }))`); it
57
+ splits the case `args` into services and plain input, runs the action, then
58
+ `Match.assert`s `toState ≡ after` **and** checks the recorded calls against the
59
+ case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin
60
+ **same-named** action gives a transaction-only or renamed transition something to
61
+ pair with (todo's `reorderTodo`). A streaming/capability action with no transition
62
+ is skipped. **A per-transition action kept out of the facet** (to bound the
63
+ plugin's type) is discovered via `runFeature`'s `ops.actions` glob —
64
+ `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }`
65
+ (p2p negotiation). That is the *only* reason to pass `ops` (see `conformance.md`).
32
66
  - An `index.ts` barrel feeds the `actions` plugin facet.
@@ -7,20 +7,71 @@ paths:
7
7
 
8
8
  One derived value per file: a `cached` function of a database layer that
9
9
  returns an `Observe` of state projected through pure `data/` helpers.
10
- Derivation logic itself lives in `data/`; a computed only wires a db
10
+ Derivation logic itself lives in `data/`; a computed only wires a service
11
11
  observable to it.
12
12
 
13
+ **Reuse tested pure logic; let performance pick the wiring.** Prefer wiring the
14
+ pure **`data/<type>` helper** (or composing indexes) directly — the tictactoe
15
+ computeds delegate their observable straight to
16
+ `BoardState.deriveStatus`/`getWinner`/`currentPlayer` — because a helper reads
17
+ exactly the resource/entities it needs, and both the computed and the matching
18
+ `state/` derivation call it, so they agree by construction.
19
+
20
+ You **may** also import a pure `state/` derivation `(state) => value` into a
21
+ computed where performance is adequate: a small-N or resource/scalar composition,
22
+ not a hot per-entity or large-N path. Reusing a tested derivation is good — the
23
+ only cost is that it takes the **whole `State`**, so the computed must observe the
24
+ full-state projection and re-runs on *any* field change (fine for a small feature,
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.
33
+
13
34
  ```ts
14
35
  import { cached } from "@adobe/data/cache";
15
36
  import { Observe } from "@adobe/data/observe";
16
37
  import { BoardState } from "../../../data/board-state/board-state.js";
17
38
  import type { IndexDatabase } from "../../index-database/index-database.js";
18
39
 
19
- export const status = cached((db: IndexDatabase) =>
20
- Observe.withFilter(db.observe.resources.board, BoardState.deriveStatus),
40
+ export const status = cached((service: IndexDatabase) =>
41
+ Observe.withFilter(service.observe.resources.board, BoardState.deriveStatus),
21
42
  );
22
43
  ```
23
44
 
24
45
  Type the parameter on the lowest database layer that exposes what it
25
46
  reads. An `index.ts` barrel re-exports every computed; `computed-database.ts`
26
47
  registers it under the `computed` facet.
48
+
49
+ **Conform a computed to its `data/state` derivation** whenever one exists. The
50
+ derivation co-locates `{ input, value }` cases (`Derivation<typeof fn>`), and the
51
+ feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
52
+ call conforms computeds: it pulls them off **`computedPlugin.computed`**, pairs each
53
+ to its **same-named** derivation, seeds the store from `input`, reads the computed's
54
+ synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`).
55
+ There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase`
56
+ layer** — the runner builds computed conformance from that layer, not the assembled
57
+ `MainService`, so a `withCache` above can't hand back a stale pre-seed value (a
58
+ direct `fromState` seed emits no transaction to invalidate it). Comparison is
59
+ identity by default; a computed that emits an **entity-id list** names itself in
60
+ `hydrate: [...]` (todo's `visibleTodos`) so the runner projects each id through
61
+ `toData`. A computed with **no `state/` derivation is skipped** —
62
+ single-`data/<type>` math is covered by that helper's own test. A feature with no
63
+ `state/` derivation omits `computedPlugin` entirely.
64
+
65
+ **What needs conformance is proportional to wiring logic.** A computed that
66
+ composes/branches over the aggregate *is* a `state/` derivation (composes ≥2
67
+ fields) and is conformed here. A computed that **trivially applies one
68
+ `data/<type>` helper to one field** (`winner` ← `BoardState.getWinner(board)`) is
69
+ already covered by its parts — the field by `toState` conformance, the math by
70
+ that helper's unit test — so an added conformance would just re-invoke the same
71
+ helper on both sides (tautological); the helper's unit test is sufficient. Two
72
+ exceptions: (1) to pin a particular non-obvious wiring, promote it to a thin
73
+ `state/` derivation `(state) => Type.helper(state.field)` and the runner conforms
74
+ it — no new mechanism; (2) if a computed folds **entities into a value with no
75
+ intermediate `State` field**, that projection *is* unconformed — promote the
76
+ intermediate to a `State` field so `toState` covers it, making it a normal
77
+ `state/` derivation.