@adobe/data-ai 0.9.96 → 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:
@@ -97,9 +107,10 @@ export const entity = ConformanceApi.entity;
97
107
  // create-todo.ts
98
108
  import { Match } from "@adobe/data-testing";
99
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
100
111
  export const createTodo = (
101
112
  state: Pick<State, "todos">,
102
- { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
113
+ { name, complete, analytics }: { name: string; complete?: boolean } & Pick<Services, "analytics">,
103
114
  ): Pick<State, "todos"> => {
104
115
  analytics.todoCreated({ name });
105
116
  return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
@@ -144,9 +155,12 @@ export const cases: Conformance<typeof createTodo> = [
144
155
  overrides the default wholesale.)
145
156
  - **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
146
157
  / `Match.anyString`, imported from `@adobe/data-testing` — there is **no**
147
- per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is
148
- `id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match
149
- by content, not by the value you don't control. `Match` is framework-agnostic and
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
150
164
  honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
151
165
  on the expected side too. When an id must **line up in two places** within one
152
166
  comparison — a `selectedId` that points at a specific todo, say — use
@@ -167,8 +181,9 @@ export const cases: Conformance<typeof createTodo> = [
167
181
  `value` case → derivation; otherwise a transition whose declared `effects` are
168
182
  also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
169
183
  takes) is what makes each case's `before`/`input` a delta over `State.create()`.
170
- Add `match` alongside it only when
171
- 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.
172
187
  There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
173
188
  `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
174
189
  shared driver owns comparison, effect recording, and name-based auto-pairing, and
@@ -179,13 +194,16 @@ export const cases: Conformance<typeof createTodo> = [
179
194
 
180
195
  ## Injected services and side effects
181
196
 
182
- A transform that needs an outside capability receives it as a **named parameter**
183
- in the args object, keyed by the service name minus its `-service` suffix
184
- (`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`);
185
- plain data args sit alongside. Import the service straight from `services/` (an
186
- ordinary import — layers split by kind of type, not dependency). The **same
187
- services appear on `db.services` for the matching action**, so the transition is
188
- 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.
189
207
 
190
208
  - A transition **is deterministic given its dependencies** — inject a fixed
191
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`
@@ -5,6 +5,11 @@ 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
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*
@@ -33,10 +38,13 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency):
33
38
  - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
34
39
  expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
35
40
  `Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are
36
- `{ unordered?: ReadonlySet<string>; tolerance?: number }` — arrays compare **in
37
- order** (default) unless a key is named in `unordered`, and numbers snap to
38
- `tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic:
39
- 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.
40
48
  - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
41
49
  `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
42
50
  marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
@@ -92,7 +100,7 @@ Conformance.runFeature({
92
100
  computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations
93
101
  projection,
94
102
  hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none
95
- 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
96
104
  ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet
97
105
  });
98
106
  ```
@@ -136,8 +144,9 @@ Conformance.runFeature({
136
144
  `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
137
145
  no `match`, no `ops` — moves are board-index addressed, so no `entity()`
138
146
  markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers.
139
- `data-lit-space-rock-game` adds `match: { unordered: … }` for its entity bags
140
- (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
141
150
  `systems.md`).
142
151
 
143
152
  ## The pure spec — `data/state/spec.test.ts`
@@ -189,24 +198,24 @@ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-ro
189
198
  `createInitial` transaction) gives the transition something to pair with. Do **not**
190
199
  reintroduce a per-item adapter to bridge a name mismatch — add the same-named op.
191
200
 
192
- ## Ordering, tolerance, `ref` — all via `match`
193
-
194
- `runFeature` threads `match?: MatchOptions` through to every comparison, so
195
- per-feature tuning is data, not code:
196
-
197
- - **Ordered by default.** `toState` reads a display-ordered collection in order —
198
- that is what verifies a reorder — and positional tuples (a `Vec2`) must stay in
199
- order.
200
- - **Entity bags compare as multisets.** A collection the ECS materialises with no
201
- display order (bullets, asteroids) is named in
202
- `match: { unordered: new Set(["bullets", "asteroids"]) }` so just those fields
203
- compare order-independently — never blanket-unordered (it would conflate
204
- `[100,180]` with `[180,100]`).
205
- - **Float noise** is absorbed by the default `tolerance` (`0.01`); raise it only
206
- when a case genuinely needs a looser grid.
207
- - **`Match.ref(label)`** on the expected side asserts id correspondence for a
208
- referential feature (an id that must line up in two places); `Match.anyNumber` /
209
- `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.
210
219
 
211
220
  ## Recording side effects — built in, no Proxy
212
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.96",
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.96",
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.96"
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.96"
37
+ "@adobe/data": "0.9.97"
38
38
  },
39
39
  "license": "MIT",
40
40
  "scripts": {