@adobe/data-ai 0.9.95 → 0.9.97

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.
@@ -36,6 +36,27 @@ function record(m: unknown) {
36
36
  }
37
37
  ```
38
38
 
39
+ ## Collection ordering is carried by the type
40
+
41
+ A collection's type states whether its order is meaningful — the model is the
42
+ single source of truth, not a downstream comparison flag:
43
+
44
+ - **`ReadonlyArray<T>`** — order is meaningful. A display list rendered in sequence,
45
+ a drag-reorderable list, a positional tuple (`Vec2 = readonly [number, number]`).
46
+ - **`ReadonlySet<T>`** — an unordered bag. Entities materialised in nondeterministic
47
+ order, a membership set. Use this for **identity-keyed** collections: the element
48
+ carries its own `id`, so a `ReadonlySet<Entity>` replaces any
49
+ `ReadonlyMap<id, Entity>`.
50
+ - **`ReadonlyMap<K, V>`** — a keyed lookup whose **keys are meaningful/deterministic**
51
+ (an enum, a name, a stable string). Not for identity keys (those are Sets).
52
+
53
+ These are first-class `Data` (see `features/data/index.md`) — serialize a
54
+ Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify`
55
+ cannot represent them), and `equals` compares them faithfully. Conformance mirrors
56
+ the semantics: `ReadonlyArray` compares positionally, `ReadonlySet` / `ReadonlyMap`
57
+ order-independently, and a numeric `id` is ignored (the ECS allocates it) — so there
58
+ is no separate "unordered" declaration when writing conformance cases.
59
+
39
60
  ## Shape of keyed collections
40
61
 
41
62
  - `Record<EnumKey, T>` — every key required at all times. Default lists
@@ -6,9 +6,10 @@ paths:
6
6
  # data/ — the data model
7
7
 
8
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
9
+ over them. A data type is a readonly, serializable value suitable for persistence
10
+ and for communication over the wire — no functions, no handles, just plain data:
11
+ JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob`
12
+ (serialize a Set/Map-bearing value with `Data.stringify` / `Data.parse`). A `data/` **type** depends on nothing but `@adobe/data` and other
12
13
  `data/` types, and needs no knowledge of anything built on top of it. The
13
14
  **transitions** over those types are less restricted: when one injects a service
14
15
  it may import freely from `services/` — the service type and any utilities its
@@ -9,14 +9,24 @@ paths:
9
9
  source of truth. Each transition is a read→write **patch** over state; each
10
10
  derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`.
11
11
 
12
+ The presence of this folder makes the feature **state-based** — its Functional
13
+ State Specification is authoritative and the ECS conforms to it. A feature without
14
+ `data/state/` is **ECS-based** (the ECS is the source of truth, no conformance) and
15
+ this rule does not apply — see `../index.md`, Two modes.
16
+
12
17
  ```ts
13
18
  // state.ts — the aggregate + the transition/derivation namespace.
14
19
  export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
15
20
  export * as State from "./public.js";
16
21
  ```
17
22
 
18
- Every feature with ECS resources/transactions owns a `State` (a scalar
19
- `{ playing: boolean }`, or `{}` when there is none).
23
+ Every **state-based** feature owns a `State` (a scalar `{ playing: boolean }`, or
24
+ `{}` when there is none). An ECS-based feature has no `State` at all.
25
+
26
+ **A `State` field's collection type carries its ordering** — see
27
+ `../../data-modelling.md` (Collection ordering is carried by the type). `todos`
28
+ above is a `ReadonlyArray` because todo order is a user-visible, reorderable fact;
29
+ an unordered entity bag (bullets, sprites) is a `ReadonlySet`.
20
30
 
21
31
  **`State` has a standard shape.** Two exports are conventional and drive
22
32
  conformance:
@@ -48,7 +58,7 @@ export const transitions = import.meta.glob<Record<string, unknown>>(
48
58
  It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain
49
59
  prod-safe values), `transitions` is the test-case graph — hanging it on `State`
50
60
  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
61
+ (fake services, matchers, the whole `@adobe/data-testing` module) into every
52
62
  production import of `State`, none of which tree-shakes off a live namespace
53
63
  re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test
54
64
  concern the tests import directly.
@@ -73,15 +83,17 @@ centrally by the shared driver's barrel-driven guard rather than by eyeballing
73
83
  one test file per transform — and genuine non-transition helpers still keep
74
84
  their own `*.test.ts` (see below).
75
85
 
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:
86
+ The case types, matchers, and runners all live in the separate
87
+ **`@adobe/data-testing`** package (two namespaces, `Match` and `Conformance`) —
88
+ kept out of `@adobe/data` itself so installing `@adobe/data` never pulls in a
89
+ `vitest` peer dependency; add `@adobe/data-testing` as a devDependency alongside
90
+ `vitest`, its optional peer dependency. Only one tiny per-feature file remains —
91
+ a ~10-line alias, `conformance-case.ts`, that binds `State` once so
92
+ transform/derivation files can write a one-parameter type:
81
93
 
82
94
  ```ts
83
95
  // data/state/conformance-case.ts — the only per-feature conformance declaration
84
- import { Conformance as ConformanceApi } from "@adobe/data/testing";
96
+ import { Conformance as ConformanceApi } from "@adobe/data-testing";
85
97
  import type { State } from "./state.js";
86
98
  export type Conformance<F extends (...args: never[]) => unknown> = ConformanceApi.Cases<State, F>;
87
99
  export type Derivation<F extends (...args: never[]) => unknown> = ConformanceApi.DerivationCases<F>;
@@ -93,11 +105,12 @@ export const entity = ConformanceApi.entity;
93
105
 
94
106
  ```ts
95
107
  // create-todo.ts
96
- import { Match } from "@adobe/data/testing";
108
+ import { Match } from "@adobe/data-testing";
97
109
  import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
110
+ import type { Services } from "../../services/services.js"; // the feature's service map
98
111
  export const createTodo = (
99
112
  state: Pick<State, "todos">,
100
- { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
113
+ { name, complete, analytics }: { name: string; complete?: boolean } & Pick<Services, "analytics">,
101
114
  ): Pick<State, "todos"> => {
102
115
  analytics.todoCreated({ name });
103
116
  return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
@@ -141,10 +154,13 @@ export const cases: Conformance<typeof createTodo> = [
141
154
  is the default and stays unchanged. (A full `before`/`after` still works — it just
142
155
  overrides the default wholesale.)
143
156
  - **`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
157
+ / `Match.anyString`, imported from `@adobe/data-testing` — there is **no**
158
+ per-feature `matchers.ts` anymore. An entity's own numeric `id` is **ignored by
159
+ default** (the ECS allocates it from its own id-space), so a case simply **omits
160
+ `id`** and the entity's content still compares — no `id: Match.anyNumber` needed.
161
+ (Reach for `id: Match.anyNumber` only when the type makes `id` required and a
162
+ literal would otherwise pin it.) Match by content, not by the value you don't
163
+ control. `Match` is framework-agnostic and
148
164
  honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
149
165
  on the expected side too. When an id must **line up in two places** within one
150
166
  comparison — a `selectedId` that points at a specific todo, say — use
@@ -154,7 +170,7 @@ export const cases: Conformance<typeof createTodo> = [
154
170
  case does not pin at all; `ref` for one that must be consistent across the case.
155
171
  - **Entity-addressed cases use `entity(specId)`.** A transition that addresses an
156
172
  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
173
+ feature's `conformance-case.ts` (re-exported from `@adobe/data-testing`). It types
158
174
  as the id it stands for (like `Match.anyNumber`), so it slots into the transform's
159
175
  own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS
160
176
  runners resolve it to the seeded entity (see `conformance.md`).
@@ -165,25 +181,29 @@ export const cases: Conformance<typeof createTodo> = [
165
181
  `value` case → derivation; otherwise a transition whose declared `effects` are
166
182
  also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
167
183
  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`).
184
+ Add `match` alongside it only when the feature needs float `tolerance` (see
185
+ `conformance.md`); unordered collections are modeled as `ReadonlySet` /
186
+ `ReadonlyMap` on `State` (below), not declared as a match option.
170
187
  There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
171
188
  `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
172
189
  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
190
+ the `Effects` type-test now lives once in `@adobe/data-testing`. A genuine **non-transition helper** in
174
191
  `state/` — a `create()` constructor, a single-field predicate — has no `cases`
175
192
  and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
176
193
  sibling `*.test.ts`** rather than deleting it and losing coverage.
177
194
 
178
195
  ## Injected services and side effects
179
196
 
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.
197
+ A transform that needs an outside capability injects it with **`Pick<Services,
198
+ …>`** — never an ad-hoc inline type. `Services` is the feature's service map
199
+ (`services/services.ts`, see `../services/index.md`), keyed by the service name
200
+ minus its `-service` suffix (`analytics`, `nameGenerator`), so the key and its type
201
+ come from **one** place and can't drift per-transition. Plain data args sit
202
+ alongside via intersection: `{ readonly count: number } & Pick<Services, "analytics">`
203
+ (or `Pick<Services, "a" | "b">` when a transition takes only services). The **same
204
+ services appear on `db.services` for the matching action** — pinned to `Services` in
205
+ the ecs `service-database` — so the transition is the complete spec of *both* the
206
+ state change and the service calls.
187
207
 
188
208
  - A transition **is deterministic given its dependencies** — inject a fixed
189
209
  double and the output (and its calls) are fixed. An **async** dependency makes
@@ -24,6 +24,34 @@ spec, it can be generated and kept honest by AI rules, with the conformance
24
24
  tests as the safety net. Net result: write a slow-but-verifiable app, then
25
25
  derive a fast one that provably behaves the same.
26
26
 
27
+ ## Two modes: state-based vs. ECS-based
28
+
29
+ The "built twice" structure above is the **state-based** mode. Every feature is in
30
+ one of two modes, discriminated by **the presence of `data/state/`**:
31
+
32
+ - **State-based** — a **Functional State Specification (FSS)** is the source of
33
+ truth: the pure `data/State` aggregate with its transitions and derivations, and
34
+ the ECS is a conformance-verified implementation of it. Adds `data/state/` (State,
35
+ transitions, co-located `cases`, `spec.test.ts`), `services/main-service/conformance/`,
36
+ and the `state` projection computed. **This is how every new feature is authored.**
37
+ - **ECS-based** — the **ECS is the source of truth**, authored directly:
38
+ **no `data/state/`**, no FSS, no conformance. This is a **legacy** shape — features
39
+ written before the state-based approach existed. New features are **never** authored
40
+ this way; ECS-based exists only to describe and maintain those older features.
41
+
42
+ **Discriminator:** `data/state/` present → state-based; absent → ECS-based. The
43
+ `data/<type>` value folders (the serializable types backing components/resources,
44
+ the wire, and persistence) exist in **both** modes — only the aggregate `State`
45
+ spec and the conformance layer are mode-specific. `data/` never disappears; the
46
+ *aggregate spec* does.
47
+
48
+ **Testing follows the mode.** A state-based feature is verified by conformance: the
49
+ shared `cases` drive both the pure spec and the ECS, so a transaction or action with
50
+ a same-named transition needs no test of its own. An **ECS-based feature has no
51
+ conformance cases, so every transaction and every action carries its own unit
52
+ test** — the direct substitute for the conformance oracle (see `services/main-service/transactions.md`
53
+ and `actions.md`).
54
+
27
55
  ## The layers
28
56
 
29
57
  **The layers organize by the *kind of type* each folder holds, not by a strict
@@ -20,6 +20,33 @@ Everything a feature exposes as a service lives here. Two kinds:
20
20
  The rest of this rule governs the capability contracts; `main-service` follows
21
21
  its own subtree.
22
22
 
23
+ ## `services/services.ts` — the injectable service map
24
+
25
+ The folder root exports one `Services` type: the feature's capability services
26
+ keyed by short name (the `-service` suffix dropped), the single source of truth for
27
+ service injection.
28
+
29
+ ```ts
30
+ // services/services.ts
31
+ import type { AnalyticsService } from "./analytics-service/analytics-service.js";
32
+ import type { NameGeneratorService } from "./name-generator-service/name-generator-service.js";
33
+ export type Services = {
34
+ readonly analytics: AnalyticsService;
35
+ readonly nameGenerator: NameGeneratorService;
36
+ };
37
+ ```
38
+
39
+ - **Transitions inject with `Pick<Services, …>`** (`data/state.md`), never a
40
+ re-declared inline `{ analytics: AnalyticsService }` — so the key/type live in one
41
+ place.
42
+ - **The ecs `service-database` is pinned to it.** After its `ServiceDatabase` type,
43
+ a drift-guard asserts the resolved services match the map, so `db.services` (what
44
+ actions call) and `Services` (what transitions inject) can't diverge:
45
+ `type _Pin = Assert<Equal<ServiceDatabase["services"], Services>>`.
46
+ - **Inherited services**, when a peer feature builds on another, intersect the
47
+ parent map: `export type Services = MainServices & { readonly baz: BazService }`.
48
+ - A feature with no capability services needs no `services.ts`.
49
+
23
50
  ## Capability contracts
24
51
 
25
52
  Each is a namespace folder (`global/namespace.md`); the export and folder both carry the
@@ -48,6 +48,10 @@ export const addRandomTodo = async (service: ServiceDatabase) => {
48
48
  Reactive computeds refresh only on a committed transaction, so an imperative
49
49
  read of one can hand back a stale shared cache (and it's the UI's layer, not
50
50
  the action's). This also keeps the action correct under the conformance seed.
51
+ - **ECS-based feature (no `data/state/`):** no conformance cases exist, so **every
52
+ action carries its own unit test** — build a db, run the action with fake
53
+ `services`, and assert the committed state and the recorded service calls. The
54
+ bullet below applies only to **state-based** features (see `../../index.md`, Two modes).
51
55
  - **Conformance** is the action surface of the feature's single
52
56
  `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls
53
57
  actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
@@ -25,7 +25,7 @@ full-state projection and re-runs on *any* field change (fine for a small featur
25
25
  wasteful on a large or hot one, where you hand-wire the minimal resource/index
26
26
  reads instead). A derivation takes **no services**, so its co-located `cases` are
27
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
28
+ build (they reference the `@adobe/data-testing` matchers only, and that module is
29
29
  `sideEffects: false`) — the one hazard is a `cases` literal touching the
30
30
  `public.js` barrel at load, which `state.md` already forbids. **Performance is the
31
31
  first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal
@@ -5,8 +5,13 @@ paths:
5
5
 
6
6
  # services/main-service/conformance/ — keeping the ECS honest against the spec
7
7
 
8
+ **State-based features only.** This folder exists only when the feature has a
9
+ `data/state/` spec to conform to. An **ECS-based** feature (no `data/state/`) omits
10
+ this folder entirely and unit-tests its transactions/actions directly instead (see
11
+ `../../index.md`, Two modes, and `transactions.md` / `actions.md`).
12
+
8
13
  Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state`
9
- cases are the shared truth; the shared **`@adobe/data/testing`** runner replays
14
+ cases are the shared truth; the shared **`@adobe/data-testing`** runner replays
10
15
  them against the ECS. This folder holds only the *feature-specific projection*
11
16
  (`projection.ts`) plus one `conformance.test.ts` that makes a **single**
12
17
  `Conformance.runFeature({...})` call. Reference: `data-lit-tictactoe`'s
@@ -23,19 +28,23 @@ a normal runtime API — the collision is only in the name.) Likewise
23
28
  `Match.matches` / `Match.assert` are comparison helpers for `*.test.ts` only —
24
29
  never a runtime branch.
25
30
 
26
- ## What `@adobe/data/testing` gives you
31
+ ## What `@adobe/data-testing` gives you
27
32
 
28
33
  Two namespaces, imported only from `*.test.ts` (the module is
29
- `sideEffects: false`, and `vitest` is an optional peer dependency, satisfied
30
- here):
34
+ `sideEffects: false`; add it as a devDependency alongside `vitest`, its
35
+ optional peer dependency — kept as a separate package from `@adobe/data` so
36
+ installing `@adobe/data` never pulls in a `vitest` peer dependency):
31
37
 
32
38
  - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
33
39
  expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
34
40
  `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are
35
- `{ unordered?: ReadonlySet<string>; tolerance?: number }` — arrays compare **in
36
- order** (default) unless a key is named in `unordered`, and numbers snap to
37
- `tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic:
38
- it honors any asymmetric matcher, so vitest's `expect.any(...)` interops.
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. A numeric `id` a case
45
+ does not mention is **ignored** (the ECS allocates it), so entity content compares
46
+ without pinning ids. Framework-agnostic: it honors any asymmetric matcher, so
47
+ vitest's `expect.any(...)` interops.
39
48
  - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
40
49
  `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
41
50
  marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
@@ -91,7 +100,7 @@ Conformance.runFeature({
91
100
  computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations
92
101
  projection,
93
102
  hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none
94
- match: { unordered: new Set(["bullets", "asteroids"]) }, // entity bags / tolerance; omit if none
103
+ match: { tolerance: 0.1 }, // float grid only; omit if 0.01 is fine
95
104
  ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet
96
105
  });
97
106
  ```
@@ -135,8 +144,9 @@ Conformance.runFeature({
135
144
  `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
136
145
  no `match`, no `ops` — moves are board-index addressed, so no `entity()`
137
146
  markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers.
138
- `data-lit-space-rock-game` adds `match: { unordered: … }` for its entity bags
139
- (its per-frame transitions are conformed by the systems tick loop, not here — see
147
+ `data-lit-space-rock-game` models its entity bags (`bullets`, `asteroids`) as
148
+ `ReadonlySet` on `State`, so they compare order-independently by type — no `match`
149
+ option (its per-frame transitions are conformed by the systems tick loop, not here — see
140
150
  `systems.md`).
141
151
 
142
152
  ## The pure spec — `data/state/spec.test.ts`
@@ -188,24 +198,24 @@ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-ro
188
198
  `createInitial` transaction) gives the transition something to pair with. Do **not**
189
199
  reintroduce a per-item adapter to bridge a name mismatch — add the same-named op.
190
200
 
191
- ## Ordering, tolerance, `ref` — all via `match`
192
-
193
- `runFeature` threads `match?: MatchOptions` through to every comparison, so
194
- per-feature tuning is data, not code:
195
-
196
- - **Ordered by default.** `toState` reads a display-ordered collection in order —
197
- that is what verifies a reorder — and positional tuples (a `Vec2`) must stay in
198
- order.
199
- - **Entity bags compare as multisets.** A collection the ECS materialises with no
200
- display order (bullets, asteroids) is named in
201
- `match: { unordered: new Set(["bullets", "asteroids"]) }` so just those fields
202
- compare order-independently — never blanket-unordered (it would conflate
203
- `[100,180]` with `[180,100]`).
204
- - **Float noise** is absorbed by the default `tolerance` (`0.01`); raise it only
205
- when a case genuinely needs a looser grid.
206
- - **`Match.ref(label)`** on the expected side asserts id correspondence for a
207
- referential feature (an id that must line up in two places); `Match.anyNumber` /
208
- `anyString` leave an id a case doesn't pin fully open.
201
+ ## Ordering, tolerance, `ref`
202
+
203
+ Ordering is carried by the value's **type**, not a match option — `ReadonlyArray`
204
+ positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its
205
+ rationale live in `../../../data-modelling.md`). What's specific to writing
206
+ conformance cases:
207
+
208
+ - **A numeric `id` is ignored unless a case pins it.** The ECS allocates entity ids
209
+ from its own space, so a case omits `id` and the entity's content still compares.
210
+ Pin it only to assert a reference (below).
211
+ - **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through
212
+ `match?: { tolerance }`; raise it only when a case needs a looser grid.
213
+ - **`Match.ref(label)`** on the expected side asserts id *correspondence* for a
214
+ referential feature — a reference that must line up with the entity it points at
215
+ (a `selectedId` → a specific todo). Put `ref("t")` on both the reference **and**
216
+ that entity's `id`; the labels form a bijection over the actual ids.
217
+ `Match.anyNumber` / `anyString` leave an id a case doesn't pin fully open. `ref`
218
+ correspondence holds even across a `ReadonlySet` boundary.
209
219
 
210
220
  ## Recording side effects — built in, no Proxy
211
221
 
@@ -32,6 +32,11 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => {
32
32
  result — read the touched slice, call the `data/` transform, write the diff.
33
33
  - Keep transaction files **single-export** (the `transactions/` barrel is
34
34
  `export *`-ed into the plugin facet, so a second export would pollute it).
35
+ - **ECS-based feature (no `data/state/`):** there are no conformance cases, so
36
+ **every transaction carries its own unit test** — `Store.create(plugin)`, run the
37
+ transaction, assert the resulting resources/entities/archetypes. That test is the
38
+ direct substitute for the conformance oracle (see `../../index.md`, Two modes). The
39
+ bullet below applies only to **state-based** features.
35
40
  - **Conformance is wired once, centrally, and auto-paired** — not per-file. The
36
41
  feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
37
42
  call conforms transactions: it pulls them off **`plugin.transactions`** (the
@@ -8,7 +8,7 @@ paths:
8
8
 
9
9
  Asynchronous data services. Live in the `services/` layer. Adhere to the namespace rule for type and function organization.
10
10
 
11
- **Data** = readonly JSON values or Blobs.
11
+ **Data** = readonly JSON values, `ReadonlySet`, `ReadonlyMap`, or Blobs.
12
12
 
13
13
  ---
14
14
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adobe-data-ai",
3
- "version": "0.9.95",
3
+ "version": "0.9.97",
4
4
  "description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
5
5
  "author": {
6
6
  "name": "Adobe"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-ai",
3
- "version": "0.9.95",
3
+ "version": "0.9.97",
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.9.95"
32
+ "@adobe/data": "0.9.97"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.3",
36
36
  "vitest": "^1.6.0",
37
- "@adobe/data": "0.9.95"
37
+ "@adobe/data": "0.9.97"
38
38
  },
39
39
  "license": "MIT",
40
40
  "scripts": {