@adobe/data-ai 0.10.18 → 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.
- package/.claude/rules/features/data/state.md +161 -338
- package/.claude/rules/features/index.md +14 -12
- package/.claude/rules/features/services/index.md +27 -18
- package/.claude/rules/features/services/main-service/actions.md +9 -9
- package/.claude/rules/features/services/main-service/computed.md +10 -11
- package/.claude/rules/features/services/main-service/conformance.md +72 -221
- package/.claude/rules/features/services/main-service/transactions.md +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +3 -3
|
@@ -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.
|
|
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
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
- **`
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
+
Each conformed function is two files:
|
|
66
48
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
//
|
|
173
|
-
import {
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
export const
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
156
|
+
## `spec.ts` — the conformance manifest
|
|
318
157
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
`
|
|
334
|
-
**
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
380
|
-
`
|
|
381
|
-
|
|
382
|
-
`
|
|
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
|
-
|
|
36
|
-
and the `state`
|
|
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
|
|
133
|
-
|
|
134
|
-
`Conformance.
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`
|
|
139
|
-
conforming the implementation is "substitute the
|
|
140
|
-
expectations
|
|
141
|
-
with the spec as oracle. *How* to author each layer lives in the
|
|
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
|
-
##
|
|
65
|
-
|
|
66
|
-
A service is the seam consumers swap out under test
|
|
67
|
-
|
|
68
|
-
**
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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 `
|
|
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.
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`Match.assert`s `toState ≡ after` **and** checks the
|
|
63
|
-
case's `effects`.
|
|
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
|
|
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
|
|
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
|
|
27
|
-
inert `{ input, value }` data (no test-doubles)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
57
|
-
feature's single
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
`
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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 {
|
|
95
|
-
import {
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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 {
|
|
159
|
-
import {
|
|
160
|
-
|
|
161
|
-
Conformance.
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
`
|
|
168
|
-
|
|
169
|
-
`
|
|
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.
|
|
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`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/data-ai",
|
|
3
|
-
"version": "0.10.
|
|
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.
|
|
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.
|
|
37
|
+
"@adobe/data": "0.10.20"
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"scripts": {
|