@adobe/data-ai 0.9.92 → 0.9.94

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.
@@ -5,11 +5,17 @@ paths:
5
5
 
6
6
  # data/ — the data model
7
7
 
8
- The foundation layer: the feature's **data types**. A data type is a
9
- readonly, JSON-serializable value suitable for persistence and for
10
- communication over the wire — no functions, no handles, just plain data.
11
- It depends on nothing but `@adobe/data` and other `data/` declarations, and
12
- needs no knowledge of anything built on top of it.
8
+ The foundation layer: the feature's **data types** and the pure declarations
9
+ over them. A data type is a readonly, JSON-serializable value suitable for
10
+ persistence and for communication over the wire — no functions, no handles, just
11
+ plain data. A `data/` **type** depends on nothing but `@adobe/data` and other
12
+ `data/` types, and needs no knowledge of anything built on top of it. The
13
+ **transitions** over those types are less restricted: when one injects a service
14
+ it may import freely from `services/` — the service type and any utilities its
15
+ namespace exposes (see `state.md`). What it must **never** touch is `ui/`:
16
+ `data/` never depends on presentation — that isolation is strict and inviolable.
17
+ Only *services themselves* are never authored here; that is what makes this the
18
+ value-type layer.
13
19
 
14
20
  Each data type is its own namespace folder (see `global/namespace.md`), holding
15
21
  its hand-authored type, its (optional, matching) schema, and its pure
@@ -5,55 +5,212 @@ paths:
5
5
 
6
6
  # data/state/ — the State specification
7
7
 
8
- `State` is the whole feature modelled as **one immutable object** — the
9
- pure, fully-tested source of truth for the feature. Modelling everything as
10
- a single value keeps each transform a small, trivially-testable function of
11
- state: collections of entity sub-types plus scalar fields.
8
+ `State` is the whole feature as **one immutable object** — the pure, fully-tested
9
+ source of truth. Each transition is a read→write **patch** over state; each
10
+ derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`.
12
11
 
13
- Every feature that has ECS resources or transactions owns a `State` — including
14
- a host whose aggregate is a single scalar (`{ playing: boolean }`) or even when there is no state in which case use `{}`.
15
12
  ```ts
16
- // state.ts — the aggregate, plus the namespace of transforms/derivations.
17
- export type State = {
18
- readonly todos: readonly Todo[]; // a collection of entity sub-types
19
- readonly displayCompleted: boolean; // a scalar field
20
- };
13
+ // state.ts — the aggregate + the transition/derivation namespace.
14
+ export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
21
15
  export * as State from "./public.js";
22
16
  ```
23
17
 
24
- ## Transforms — one per file, `(state, …args) => state`
25
-
26
- - **Pure.** No I/O, no framework, no mutation — return a new value.
27
- - **Narrow in, same shape out.** Write on the smallest slice the transform
28
- needs and keep the input generic over that slice so it lifts to
29
- full-state-in / full-state-out:
30
-
31
- ```ts
32
- export const playMove = <T extends Pick<State, "board" | "firstPlayer">>(
33
- state: T,
34
- input: PlayMoveArgs,
35
- ): T => { /* … return { ...state, board: … } */ };
36
- ```
37
-
38
- A whole-`State` transform (`restartGame(state: State): State`) is fine when
39
- it genuinely touches everything.
40
- - Args may be **narrowed or omitted** (`toggleDisplayCompleted(state)`).
41
- - Guard and **return `state` unchanged** on a no-op / illegal input rather
42
- than throwing — this keeps transforms idempotent under repeated application.
43
- - Each transform has a sibling `*.test.ts`; performance is irrelevant here,
44
- correctness is everything. The test file **exports** its cases —
45
- `export const cases: ConformanceCase<Args>[]` (the shared `conformance-case.ts`
46
- type) — right alongside the `describe`/`it` that exercise them. This is
47
- spec-owned truth the matching main-service conformance test imports **from the
48
- `<transform>.test.js` file** unchanged (see `services/main-service/conformance.md`).
49
- Keeping the cases in the test file rather than a separate `<transform>.cases.ts`
50
- removes a file per transform — less folder clutter, same reuse. Author
51
- `before`/`after` as full `State` (`{ ...State.create(), …overrides }`); the
52
- generic-slice signature lets them flow through. Tolerant full-`State` equality is
53
- the shared `expect-state-matches.ts`.
54
-
55
- ## Derivations — `(state) => value`
56
-
57
- Pure selectors (`visibleTodos(state)`). Sub-type math (a winner, a status)
58
- lives on the relevant `data/<type>` namespace; `state/` only composes over
59
- the whole aggregate.
18
+ Every feature with ECS resources/transactions owns a `State` (a scalar
19
+ `{ playing: boolean }`, or `{}` when there is none).
20
+
21
+ **`State` has a standard shape.** Two exports are conventional and drive
22
+ conformance:
23
+
24
+ - **`create(): State`** (`data/state/create.ts`) — the default state. Every
25
+ conformance case's `before` is a **delta over `State.create()`**, and both the
26
+ pure `spec.test.ts` and the ecs `runFeature` seed from it.
27
+ - **`samples: readonly State[]`** (`data/state/samples.ts`) — representative
28
+ **full** states `runFeature` round-trips through the projection
29
+ (`toState ∘ fromState ≡ identity`, see `conformance.md`).
30
+
31
+ Both are re-exported through `public.js`, so `State.create()` / `State.samples`
32
+ are namespace members. (A `cases` literal must still not touch `public.js` at
33
+ load — import `create` from `./create.js` directly there; see below.)
34
+
35
+ **The discovered transitions are a test-only sibling, NOT on the namespace.**
36
+ `data/state/transitions.ts` exports one `import.meta.glob` of the folder — the
37
+ `{ fn, cases }` modules — imported by *both* `spec.test.ts` and the ecs
38
+ `conformance.test.ts` so the glob is authored once per feature:
39
+
40
+ ```ts
41
+ // data/state/transitions.ts — test-only; both test entry points import it
42
+ export const transitions = import.meta.glob<Record<string, unknown>>(
43
+ ["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"],
44
+ { eager: true },
45
+ );
46
+ ```
47
+
48
+ It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain
49
+ prod-safe values), `transitions` is the test-case graph — hanging it on `State`
50
+ would drag `import.meta.glob` (a Vite-only construct) and every `cases` fixture
51
+ (fake services, matchers, the whole `@adobe/data/testing` module) into every
52
+ production import of `State`, none of which tree-shakes off a live namespace
53
+ re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test
54
+ concern the tests import directly.
55
+
56
+ ## One file per transform: the function **and** its cases
57
+
58
+ A transform file exports **exactly two things** — the function and its
59
+ `cases` — nothing else (a private helper is fine; a second export is not, and the
60
+ spec aggregator throws if it finds one). The cases are the spec-owned truth every
61
+ conformance runner reuses; co-locating them removes the per-transform `.cases.ts`
62
+ and `.test.ts`.
63
+
64
+ **Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned
65
+ fixtures the pure `spec.test.ts` **and** the ecs `runFeature` call both reuse
66
+ (driving transaction / action / computed conformance) — the transform's contract
67
+ expressed as data, not a per-file test — so they belong
68
+ beside the thing they specify, and `Conformance<typeof fn>` binds them to the
69
+ signature so they can't drift. Kept inert (no `describe`; one aggregator runs
70
+ them) they also sidestep the double execution vitest triggers when a single file
71
+ both exports cases and runs its own `describe`. Coverage is then enforced
72
+ centrally by the shared driver's barrel-driven guard rather than by eyeballing
73
+ one test file per transform — and genuine non-transition helpers still keep
74
+ their own `*.test.ts` (see below).
75
+
76
+ The case types, matchers, and runners all live in the shared
77
+ **`@adobe/data/testing`** module (two namespaces, `Match` and `Conformance`;
78
+ `vitest` is an *optional* peer dependency, already satisfied here). Only one
79
+ tiny per-feature file remains — a ~10-line alias, `conformance-case.ts`, that
80
+ binds `State` once so transform/derivation files can write a one-parameter type:
81
+
82
+ ```ts
83
+ // data/state/conformance-case.ts — the only per-feature conformance declaration
84
+ import { Conformance as ConformanceApi } from "@adobe/data/testing";
85
+ import type { State } from "./state.js";
86
+ export type Conformance<F extends (...args: never[]) => unknown> = ConformanceApi.Cases<State, F>;
87
+ export type Derivation<F extends (...args: never[]) => unknown> = ConformanceApi.DerivationCases<F>;
88
+ export type Effects<Args> = ConformanceApi.Effects<Args>;
89
+ // The entity-reference marker for identity-addressed case args, re-exported so
90
+ // cases import it beside `Conformance`: `args: { id: entity(2) }`.
91
+ export const entity = ConformanceApi.entity;
92
+ ```
93
+
94
+ ```ts
95
+ // create-todo.ts
96
+ import { Match } from "@adobe/data/testing";
97
+ import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
98
+ export const createTodo = (
99
+ state: Pick<State, "todos">,
100
+ { name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
101
+ ): Pick<State, "todos"> => {
102
+ analytics.todoCreated({ name });
103
+ return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
104
+ };
105
+
106
+ export const cases: Conformance<typeof createTodo> = [
107
+ { name: "appends the first todo",
108
+ before: {}, // empty delta — the default State.create()
109
+ args: { name: "a", analytics: AnalyticsService.createFake() },
110
+ after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field
111
+ effects: { analytics: [["todoCreated", { name: "a" }]] } },
112
+ ];
113
+ ```
114
+
115
+ - **Signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` — a
116
+ **read→write patch**. The parameter is the smallest `Pick<State,…>` the
117
+ transition **reads**; the return is *only the fields it **writes***. **No
118
+ `<T> => T` generic, no `...state` spread** in the return — return the patch and
119
+ let the runner merge it. **All non-state inputs go in the single `args` object**
120
+ (`Conformance<typeof fn>` reads `Parameters[1]`) — bundle a `dt`, an injected
121
+ service, etc. into it, never as a third positional. A transition that takes
122
+ **no** args omits `args` from each case entirely (the shared `Case` type makes
123
+ `args` optional exactly then). **Guard no-ops by returning an empty patch `{}`**
124
+ (or the unchanged slice), never throw.
125
+ - **A composer merges sub-patches explicitly.** A transition built from smaller
126
+ ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes
127
+ layer in; a transition that merely **delegates** to one sub-transition returns
128
+ that delegate's patch directly (no spread needed).
129
+ - **Co-located `cases` must not touch the feature's `public.js` barrel at module
130
+ load** — that barrel re-exports this very file, so calling `State.create()` (or
131
+ any barrel member) in a top-level `cases` literal dead-locks the import cycle.
132
+ Import the concrete helper directly (`import { create } from "./create.js"`) or
133
+ inline full-`State` literals.
134
+ - **`Conformance<typeof fn>`** (the alias above) derives the case `args` type from
135
+ the function's own signature — author it once, and cases can't drift from what
136
+ the function accepts. **`before` is a delta over `State.create()`** — list only
137
+ the fields this case sets differently from the default; **`after` is the writes
138
+ patch** — only the fields the transition changes. The runner seeds
139
+ `{ ...State.create(), ...before }` and compares against
140
+ `{ ...State.create(), ...before, ...after }`, so any field a case doesn't mention
141
+ is the default and stays unchanged. (A full `before`/`after` still works — it just
142
+ overrides the default wholesale.)
143
+ - **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
144
+ / `Match.anyString`, imported from `@adobe/data/testing` — there is **no**
145
+ per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is
146
+ `id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match
147
+ by content, not by the value you don't control. `Match` is framework-agnostic and
148
+ honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
149
+ on the expected side too. When an id must **line up in two places** within one
150
+ comparison — a `selectedId` that points at a specific todo, say — use
151
+ `Match.ref(label)`: it asserts id *correspondence* (a bijection up to renaming),
152
+ not a pinned value, so the two occurrences of the label must resolve to the same
153
+ actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a
154
+ case does not pin at all; `ref` for one that must be consistent across the case.
155
+ - **Entity-addressed cases use `entity(specId)`.** A transition that addresses an
156
+ entity by id writes it as `args: { id: entity(2) }` — `entity` imported from the
157
+ feature's `conformance-case.ts` (re-exported from `@adobe/data/testing`). It types
158
+ as the id it stands for (like `Match.anyNumber`), so it slots into the transform's
159
+ own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS
160
+ runners resolve it to the seeded entity (see `conformance.md`).
161
+ - No per-transform test. The single **`spec.test.ts`** is one call —
162
+ `Conformance.runSpec({ state: State, transitions })` importing `transitions`
163
+ from the test-only `./transitions.js` (above) — that auto-discovers every module
164
+ exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a
165
+ `value` case → derivation; otherwise a transition whose declared `effects` are
166
+ also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
167
+ takes) is what makes each case's `before`/`input` a delta over `State.create()`.
168
+ Add `match` alongside it only when
169
+ the feature needs float tolerance or unordered collections (see `conformance.md`).
170
+ There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
171
+ `expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
172
+ shared driver owns comparison, effect recording, and name-based auto-pairing, and
173
+ the `Effects` type-test now lives once in `@adobe/data/testing`. A genuine **non-transition helper** in
174
+ `state/` — a `create()` constructor, a single-field predicate — has no `cases`
175
+ and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
176
+ sibling `*.test.ts`** rather than deleting it and losing coverage.
177
+
178
+ ## Injected services and side effects
179
+
180
+ A transform that needs an outside capability receives it as a **named parameter**
181
+ in the args object, keyed by the service name minus its `-service` suffix
182
+ (`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`);
183
+ plain data args sit alongside. Import the service straight from `services/` (an
184
+ ordinary import — layers split by kind of type, not dependency). The **same
185
+ services appear on `db.services` for the matching action**, so the transition is
186
+ the complete spec of *both* the state change and the service calls.
187
+
188
+ - A transition **is deterministic given its dependencies** — inject a fixed
189
+ double and the output (and its calls) are fixed. An **async** dependency makes
190
+ the transition `Promise<State>`; keep sync the default.
191
+ - **Side effects are declared in the case's `effects`**, keyed by the service
192
+ arg, as `[methodName, ...args]` tuples — an `Array` asserts these calls in
193
+ order, a `Set` in any order. Only listed services are checked (a value-returning
194
+ read like `generateName` you don't list is ignored). See `conformance.md` for
195
+ how the recording double captures them.
196
+ - Tests inject **deterministic doubles** (never production) whose published
197
+ responses the case's `after`/`effects` are authored against — doubles live
198
+ adjacent to the interface (`features/services/index.md`).
199
+
200
+ ## Derivations — `(state) => value`, cases `{ input, value }`
201
+
202
+ Pure selectors that **compose the aggregate** — a value drawn from **two or more
203
+ `State` fields** (`visibleTodos` from `todos` + `displayCompleted`;
204
+ `currentPlayer` from `board` + `firstPlayer`). A value computed from a **single**
205
+ `State` field is that field's own type math and lives on its `data/<type>`
206
+ namespace (`winner`/`status` from `board` → `data/board-state`), tested there —
207
+ **not** in `state/`. A feature may therefore have zero `state/` derivations.
208
+
209
+ A `state/` derivation co-locates cases shaped `{ input, value }`, typed
210
+ `Derivation<typeof fn>` (input + value read from the signature). Take the **full
211
+ `State`** as the parameter (not a `Pick` slice like a transform) — so the case
212
+ `input` type is the full `State` and computed conformance can seed it via
213
+ `fromState`; `value` may use matchers. The same `spec.test.ts` runs them (dispatching on case shape),
214
+ and each ECS computed backing one is conformance-tested from the same cases
215
+ (`conformance.md`). Sub-type math (a winner, a status) lives on the
216
+ relevant `data/<type>` namespace; `state/` only composes over the aggregate.
@@ -26,9 +26,25 @@ derive a fast one that provably behaves the same.
26
26
 
27
27
  ## The layers
28
28
 
29
- `ui → services → data` — higher imports lower, never the reverse. `data/`
30
- depends on nothing but `@adobe/data` and other `data/` declarations. A feature
31
- creates only the layers it uses.
29
+ **The layers organize by the *kind of type* each folder holds, not by a strict
30
+ dependency wall.** `data/` holds **value types** (pure, serializable data) and
31
+ the pure declarations over them; `services/` holds **service types** (interfaces
32
+ **and** implementations); `ui/` holds presentation. The rule is about *what
33
+ lives where*: a service is never authored under `data/`, a value type is never
34
+ authored under `services/`.
35
+
36
+ **`ui/` isolation is strict and inviolable.** `ui/` sits at the top: neither
37
+ `data/` nor `services/` may ever import from `ui/`, no exceptions. Presentation
38
+ depends on the model; the model never depends on presentation.
39
+
40
+ Beneath that hard line, `data/` and `services/` are **not** strictly ordered.
41
+ Value types are fundamental — a `data/` **type** depends on nothing but
42
+ `@adobe/data` and other `data/` types. But the **transition functions** over
43
+ them may depend on `services/` **without restriction**: they import whatever they
44
+ need — service interfaces they inject, and any associated utilities the service
45
+ namespace exposes — as ordinary imports, not type-only. So the strict wall is
46
+ `ui/` above `data/` + `services/`; the `data/ ↔ services/` boundary is the loose
47
+ one. A feature creates only the layers it uses.
32
48
 
33
49
  | Layer | Role |
34
50
  |-------|------|
@@ -85,13 +101,16 @@ The tie between `data/` (spec) and `main-service` (implementation) is
85
101
  **conformance**, one property —
86
102
  `toState(apply(fromState(before), args)) ≡ transform(before, args)`: each
87
103
  main-service mutation, seeded and read back through a test-only store↔`State`
88
- projection, equals the pure `data/` transform it stands for. The projection and
89
- its runner live in `services/main-service/conformance/` (see
90
- `services/main-service/conformance.md`); the shared `{ before, args, after }`
91
- cases are spec-owned (exported from `data/state/<transform>.test.ts`), so conforming the
92
- implementation is "substitute the implementation, reuse the expectations." This
93
- lets `main-service` be largely mechanical and agent-generated, with the spec as
94
- oracle. *How* to author each layer lives in the per-folder rules below.
104
+ projection, equals the pure `data/` transform it stands for. The per-feature
105
+ projection lives in `services/main-service/conformance/`, and a **single
106
+ `Conformance.runFeature({...})` call** replays the shared cases against the ecs —
107
+ pairing each ECS op to its same-named transition automatically and round-tripping
108
+ the projection (see `services/main-service/conformance.md`);
109
+ the shared `{ before, args, after }` cases are spec-owned — co-located in each
110
+ `data/state/<transform>.ts`, which exports its function plus `cases` — so
111
+ conforming the implementation is "substitute the implementation, reuse the
112
+ expectations." This lets `main-service` be largely mechanical and agent-generated,
113
+ with the spec as oracle. *How* to author each layer lives in the per-folder rules below.
95
114
 
96
115
  ## Reference implementations
97
116
 
@@ -33,6 +33,25 @@ portability and lazy loading (`AsyncDataService.createLazy`).
33
33
  - Members are async only: `void | Promise<T> | AsyncGenerator<T> | Observe<T>`.
34
34
  - Provide `create*` factories.
35
35
 
36
+ ## Deterministic test doubles, adjacent to the contract
37
+
38
+ A service is the seam consumers swap out under test — `data/` transitions that
39
+ take it as an injected dependency (`data/state.md`), actions, systems. Ship a
40
+ **deterministic test double** alongside the interface, in the same namespace
41
+ folder and under the same `global/namespace.md` standard: `create-fake.ts` is a
42
+ **single export**, `createFake`, re-exported through `public.ts` so callers reach
43
+ it as `MyService.createFake` (mirroring the `create` / `factory` pair).
44
+
45
+ Because tests assert on exact `after` values, the double must be **deterministic
46
+ and its responses caller-controlled**: `createFake` takes the exact response — the
47
+ fixed value, or the ordered sequence each method returns — as a parameter, with a
48
+ small inline default. A conformance case then **injects the responses it needs**
49
+ (`createFake(["random task"])`, `createFake([4])`) and authors its `after` /
50
+ `effects` against those values it supplied — nothing is read from a shared
51
+ published constant (that would be a second export, and it makes the assertion
52
+ guess at a value it doesn't own). The injected schedule is exactly what makes the
53
+ double a dependable oracle: the test controls both the input and the expectation.
54
+
36
55
  ## Where the I/O types live
37
56
 
38
57
  Most input/output types belong to a single service — declare them **on that
@@ -10,6 +10,20 @@ argument and pure `data/` args. Actions orchestrate anything *outside* a
10
10
  single transaction — awaiting a `services/` port, sequencing calls, deriving
11
11
  timing — and then commit the result through a transaction.
12
12
 
13
+ **Every *app-facing, transaction-backed* transition has a corresponding
14
+ same-named action** — the async realization the UI drives. It reads the same
15
+ services the transition injects from `db.services`, so it reproduces both the
16
+ transition's state change (through a transaction) and its side effects. It may
17
+ reuse another transition's transaction (`createRandomTodo` reuses `createTodo`) —
18
+ there need not be a same-named transaction; transactions are the looser layer.
19
+
20
+ **Per-frame / system transitions are exempt.** In a real-time feature the `step*`
21
+ / physics / collision transitions are realized by the **systems** tick loop, not
22
+ by an action, and are conformed by the tick-loop test (`systems.md`), not the
23
+ action surface of `runFeature`. Give an action only to transitions a user/UI invokes directly
24
+ (and skip it too when the realization needs more than one transaction — e.g. a
25
+ `newGame` that both sets bounds and resets is conformed via its transaction).
26
+
13
27
  ```ts
14
28
  import type { ServiceDatabase } from "../../service-database/service-database.js";
15
29
 
@@ -29,4 +43,24 @@ export const addRandomTodo = async (service: ServiceDatabase) => {
29
43
  awaited-internally, never surfaced to the caller.
30
44
  - Do the outside-world work here: await/sequence `services/` calls, and if a
31
45
  slow call needs timing, compute it here around the call.
46
+ - **Read current state synchronously from the store** (`db.resources` / `db.read`
47
+ / `db.select`, then a pure `data/` helper) — never from a cached `computed`.
48
+ Reactive computeds refresh only on a committed transaction, so an imperative
49
+ read of one can hand back a stale shared cache (and it's the UI's layer, not
50
+ the action's). This also keeps the action correct under the conformance seed.
51
+ - **Conformance** is the action surface of the feature's single
52
+ `conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls
53
+ actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
54
+ transition. **The action is the primary seam** — it reads injected services from
55
+ `db.services`, so the case's service args become recording overrides (the runner
56
+ builds `Database.toSystemDatabase(Database.create(plugin, { services }))`); it
57
+ splits the case `args` into services and plain input, runs the action, then
58
+ `Match.assert`s `toState ≡ after` **and** checks the recorded calls against the
59
+ case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin
60
+ **same-named** action gives a transaction-only or renamed transition something to
61
+ pair with (todo's `reorderTodo`). A streaming/capability action with no transition
62
+ is skipped. **A per-transition action kept out of the facet** (to bound the
63
+ plugin's type) is discovered via `runFeature`'s `ops.actions` glob —
64
+ `ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }`
65
+ (p2p negotiation). That is the *only* reason to pass `ops` (see `conformance.md`).
32
66
  - An `index.ts` barrel feeds the `actions` plugin facet.
@@ -10,6 +10,27 @@ returns an `Observe` of state projected through pure `data/` helpers.
10
10
  Derivation logic itself lives in `data/`; a computed only wires a service
11
11
  observable to it.
12
12
 
13
+ **Reuse tested pure logic; let performance pick the wiring.** Prefer wiring the
14
+ pure **`data/<type>` helper** (or composing indexes) directly — the tictactoe
15
+ computeds delegate their observable straight to
16
+ `BoardState.deriveStatus`/`getWinner`/`currentPlayer` — because a helper reads
17
+ exactly the resource/entities it needs, and both the computed and the matching
18
+ `state/` derivation call it, so they agree by construction.
19
+
20
+ You **may** also import a pure `state/` derivation `(state) => value` into a
21
+ computed where performance is adequate: a small-N or resource/scalar composition,
22
+ not a hot per-entity or large-N path. Reusing a tested derivation is good — the
23
+ only cost is that it takes the **whole `State`**, so the computed must observe the
24
+ full-state projection and re-runs on *any* field change (fine for a small feature,
25
+ wasteful on a large or hot one, where you hand-wire the minimal resource/index
26
+ reads instead). A derivation takes **no services**, so its co-located `cases` are
27
+ inert `{ input, value }` data (no test-doubles) that tree-shake out of the app
28
+ build (they reference the `@adobe/data/testing` matchers only, and that module is
29
+ `sideEffects: false`) — the one hazard is a `cases` literal touching the
30
+ `public.js` barrel at load, which `state.md` already forbids. **Performance is the
31
+ first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal
32
+ reads where it does.
33
+
13
34
  ```ts
14
35
  import { cached } from "@adobe/data/cache";
15
36
  import { Observe } from "@adobe/data/observe";
@@ -24,3 +45,33 @@ export const status = cached((service: IndexDatabase) =>
24
45
  Type the parameter on the lowest database layer that exposes what it
25
46
  reads. An `index.ts` barrel re-exports every computed; `computed-database.ts`
26
47
  registers it under the `computed` facet.
48
+
49
+ **Conform a computed to its `data/state` derivation** whenever one exists. The
50
+ derivation co-locates `{ input, value }` cases (`Derivation<typeof fn>`), and the
51
+ feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
52
+ call conforms computeds: it pulls them off **`computedPlugin.computed`**, pairs each
53
+ to its **same-named** derivation, seeds the store from `input`, reads the computed's
54
+ synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`).
55
+ There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase`
56
+ layer** — the runner builds computed conformance from that layer, not the assembled
57
+ `MainService`, so a `withCache` above can't hand back a stale pre-seed value (a
58
+ direct `fromState` seed emits no transaction to invalidate it). Comparison is
59
+ identity by default; a computed that emits an **entity-id list** names itself in
60
+ `hydrate: [...]` (todo's `visibleTodos`) so the runner projects each id through
61
+ `toData`. A computed with **no `state/` derivation is skipped** —
62
+ single-`data/<type>` math is covered by that helper's own test. A feature with no
63
+ `state/` derivation omits `computedPlugin` entirely.
64
+
65
+ **What needs conformance is proportional to wiring logic.** A computed that
66
+ composes/branches over the aggregate *is* a `state/` derivation (composes ≥2
67
+ fields) and is conformed here. A computed that **trivially applies one
68
+ `data/<type>` helper to one field** (`winner` ← `BoardState.getWinner(board)`) is
69
+ already covered by its parts — the field by `toState` conformance, the math by
70
+ that helper's unit test — so an added conformance would just re-invoke the same
71
+ helper on both sides (tautological); the helper's unit test is sufficient. Two
72
+ exceptions: (1) to pin a particular non-obvious wiring, promote it to a thin
73
+ `state/` derivation `(state) => Type.helper(state.field)` and the runner conforms
74
+ it — no new mechanism; (2) if a computed folds **entities into a value with no
75
+ intermediate `State` field**, that projection *is* unconformed — promote the
76
+ intermediate to a `State` field so `toState` covers it, making it a normal
77
+ `state/` derivation.
@@ -3,70 +3,216 @@ paths:
3
3
  - '**/features/*/services/main-service/**/conformance/**/*.ts'
4
4
  ---
5
5
 
6
- # services/main-service/conformance/ — spec↔implementation projection (test-only)
7
-
8
- The bridge that keeps the ecs implementation honest against the `data/` spec.
9
- **Test-only**: imported solely by `*.test.ts` and in no facet barrel, so it never
10
- enters the runtime bundle (test-support may import the test framework — it is not
11
- a runtime declaration). **Never call `fromState` / `toState` (or a shared
12
- clear-all + reinsert helper used only for that projection) from systems,
13
- transactions, or UI** — that is a full-store rewrite, not an O(1) ECS update.
14
- **Feature-level**: one projection per feature, reused by every transaction test
15
- and the system tick-loop test — don't nest it under `transaction-database/`.
16
-
17
- The property, per `{ before, args, after }` case:
18
- `toState(apply(fromState(before), args)) ≡ spec(before, args)`.
19
-
20
- Conformance test-support splits by concern along the layer boundary:
21
-
22
- - **Store-side, here in `services/main-service/conformance/`:**
23
- - `from-state.ts` → `fromState(store: CoreDatabase.Store, state: State): void`
24
- seeds the store (clear tail→head → insert entities → set resources) to a `State`.
25
- - `to-state.ts` → `toState(store): State` reads the store back — each kind via
26
- its full named-archetype component set, so the shapes never alias.
27
- - `expect-conforms.ts` → **one export, `expectConforms({ cases, spec, apply })`**.
28
- Per case it asserts `spec(before, args) ≡ after` (keeps the case honest), then
29
- `fromState(before)` → `apply(store, args)` → `toState ≡ after`.
30
- - **Spec-side, in `data/state/`** (State values, no store — so both the `data/`
31
- transform tests and this runner import them without a layer violation):
32
- `conformance-case.ts` (`ConformanceCase<Args>`), the cases **exported from each
33
- `<transform>.test.ts`** (`import { cases } from "…/<transform>.test.js"` — no
34
- separate `.cases.ts` file), and `expect-state-matches.ts` (State equality).
35
-
36
- ## No cast — build on `Store.create`, not a `Database`
37
-
38
- A **transaction is `(store, args) => void`**, so transaction conformance needs no
39
- `Database`: `Store.create(<schema-layer>.plugin)` returns a cast-free writable
40
- `CoreDatabase.Store` — pass the plugin directly, `Store.create` reads its schema
41
- facets. Source it from the **lowest layer that declares all the schema** —
42
- `IndexDatabase`, or `CoreDatabase` if the feature has no indexes — **not**
43
- `MainService`: the store needs only schema, and the behaviour layers
44
- (transactions / computed / systems) add none.
45
- `fromState`/`toState`/`apply` all operate on it, and `apply` calls the **raw
46
- transaction function** directly:
6
+ # services/main-service/conformance/ — keeping the ECS honest against the spec
7
+
8
+ 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
10
+ them against the ECS. This folder holds only the *feature-specific projection*
11
+ (`projection.ts`) plus one `conformance.test.ts` that makes a **single**
12
+ `Conformance.runFeature({...})` call. Reference: `data-lit-tictactoe`'s
13
+ `conformance/` (the zero-config example) and `data-lit-todo` (entity markers +
14
+ `hydrate`); `data-lit-space-rock-game` for the entity-bag / real-time variant.
15
+
16
+ **The projection helpers — `fromState`, `toState`, and the
17
+ `toData(store, entity)` reader — are strictly for conformance tests and MUST
18
+ NEVER run in production code, ever.** `fromState`/`toState` rewrite the whole
19
+ store out-of-band; runtime code reads through observables/indexes and writes
20
+ through transactions. (This conformance `toData(store, entity)` reader is
21
+ unrelated to the library's `db.toData()` store-serialization method, which *is*
22
+ a normal runtime API — the collision is only in the name.) Likewise
23
+ `Match.matches` / `Match.assert` are comparison helpers for `*.test.ts` only —
24
+ never a runtime branch.
25
+
26
+ ## What `@adobe/data/testing` gives you
27
+
28
+ Two namespaces, imported only from `*.test.ts` (the module is
29
+ `sideEffects: false`, and `vitest` is an optional peer dependency, satisfied
30
+ here):
31
+
32
+ - **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
33
+ expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
34
+ `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.
39
+ - **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
40
+ `DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
41
+ marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
42
+ pure-spec driver **`runSpec`**, and the lower-level per-surface drivers
43
+ `runTransactions` / `runActions` / `runComputeds` (internals of `runFeature`,
44
+ exported for the escape hatch below). Auto-pairing (transition ⇄ op by name),
45
+ effect recording, and id resolution are built **into** the drivers — no
46
+ per-feature helper writes them. The `Effects` type-test also lives here (once),
47
+ so there is no per-feature `conformance-case.type-test.ts` to author.
48
+
49
+ ## Projection (store ⇄ State) — the one per-feature piece
50
+
51
+ `projection.ts` is the only feature-specific file, and it is a **single
52
+ aggregated export** — the three test-only helpers in one file, one `export`:
53
+
54
+ ```ts
55
+ // conformance/projection.ts
56
+ export const projection = { fromState, toState, toData };
57
+ ```
58
+
59
+ - `fromState(store, state)` seeds a store to a `State` (clear tail→head, insert
60
+ entities, set resources). It **returns the `id → entity` map**
61
+ (`ReadonlyMap<Id, Entity>`) it built while seeding — the ECS assigns ids from
62
+ its own id-space, so the runner turns this map into a `resolve` via
63
+ `Conformance.resolver`; **no feature writes id resolution by hand**. A feature
64
+ whose transactions are index-addressed or singleton returns `void`, and any id
65
+ then resolves to `Entity.none`.
66
+ - `toState(store)` reads the whole store back, built on `toData`.
67
+ - `toData(store, entity)` reads one entity as its `data/` value — the single
68
+ place the ECS↔data mapping lives; the runner reuses it to hydrate id-list
69
+ computed outputs. **Present only when the feature has entities** (omit for a
70
+ scalar / resource-only feature).
71
+
72
+ This replaces the old separate `create-store.ts` / `from-state.ts` /
73
+ `to-state.ts` / `to-data.ts`. **`create-store` is gone** — `runFeature` builds
74
+ every store/db itself with `Store.create(plugin)` / `Database.create(plugin)`.
75
+
76
+ ## ECS conformance is ONE call — `Conformance.runFeature`
77
+
78
+ `conformance/conformance.test.ts` is a single call that conforms the whole
79
+ feature — transaction + action + computed conformance **plus** the projection
80
+ round-trip. No per-surface test file, no `define` callback, no `conforms(...)`
81
+ adapters, no coverage guard.
82
+
83
+ ```ts
84
+ import { State } from "../../../data/state/state.js";
85
+ import { transitions } from "../../../data/state/transitions.js";
86
+
87
+ Conformance.runFeature({
88
+ state: State,
89
+ transitions,
90
+ plugin: MainService.plugin,
91
+ computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations
92
+ projection,
93
+ hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none
94
+ match: { unordered: new Set(["bullets", "asteroids"]) }, // entity bags / tolerance; omit if none
95
+ ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet
96
+ });
97
+ ```
98
+
99
+ - **`state`** is the `State` namespace itself — the runner calls `State.create()`
100
+ for the default each case's `before` deltas over, and round-trips `State.samples`
101
+ through the projection.
102
+ - **`transitions`** is imported from the feature's test-only
103
+ `data/state/transitions.ts` (the single `import.meta.glob` of the `{ fn, cases }`
104
+ source, shared with `spec.test.ts` — see `data/state.md`). Never re-glob it here.
105
+ - **Ops come off the plugin facets.** The runner reads
106
+ `plugin.transactions` / `plugin.actions` / `computedPlugin.computed` and builds
107
+ the stores/dbs itself (`Store.create(plugin)` for transactions,
108
+ `Database.create(plugin)` for actions, `Database.create(computedPlugin)` for
109
+ computeds). Each op pairs to its **same-named** transition/derivation; auto-pairing
110
+ can't forget an item, so there is **no coverage guard** — an op with **no
111
+ same-named transition** is infrastructure or system-dispatched and is simply
112
+ **skipped** (a streaming action with no transition is skipped too).
113
+ - **`ops`** overrides the facet-discovered ops per surface — use it **only** when
114
+ an op isn't registered in the plugin facet: a per-transition action kept out of
115
+ the facet (to bound the plugin's type) is discovered via an
116
+ `ops.actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true })`
117
+ glob (p2p negotiation). Ordinary features omit `ops` entirely.
118
+ - **`computedPlugin`** is the `ComputedDatabase` **layer** plugin — build computed
119
+ conformance from this layer, **not** the assembled `MainService`: a behaviour
120
+ layer above may `withCache` a pre-seed value that a direct `fromState` seed emits
121
+ no transaction to invalidate; the computed layer keeps the seed authoritative.
122
+ Omit it when the feature has no `state/` derivations (a computed with no
123
+ derivation is skipped anyway, and single-`data/<type>` math is covered by that
124
+ helper's own test).
125
+ - **`hydrate`** names the computeds that emit an **entity-id list** (todo's
126
+ `visibleTodos`) so the runner maps each id through `toData` into the value shape
127
+ the derivation yields. Comparison is identity otherwise. Omit if none.
128
+ - **`match`** threads `MatchOptions` through every comparison (see below). Omit if
129
+ none.
130
+ - **Projection round-trip.** When `State.samples` is non-empty, the runner adds a
131
+ `toState ∘ fromState ≡ identity` test per sample — proving the pair round-trips
132
+ faithfully so a symmetric bug in `fromState`/`toState` can't cancel out and mask
133
+ a real ECS defect. No separate `projection.test.ts`.
134
+
135
+ `data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
136
+ no `match`, no `ops` — moves are board-index addressed, so no `entity()`
137
+ 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
140
+ `systems.md`).
141
+
142
+ ## The pure spec — `data/state/spec.test.ts`
143
+
144
+ Lives in `data/state/`, not here. One call, importing the same `transitions`:
47
145
 
48
146
  ```ts
49
- expectConforms({ cases, spec: State.hitTarget, apply: (store, args) => hitTarget(store, args) });
147
+ import { State } from "./state.js";
148
+ import { transitions } from "./transitions.js";
149
+
150
+ Conformance.runSpec({ state: State, transitions });
50
151
  ```
51
152
 
52
- A mutation addressed by **entity id** resolves its entities from the seeded store
53
- inside `apply` (the shared cases stay spec-shaped). Reads never need a cast —
54
- `Database extends ReadonlyStore`.
55
-
56
- **Systems** are the one exception: they run via `db.system.functions` and reach
57
- the store through the db. Get that writable view with the library lens —
58
- `Database.toSystemDatabase(Database.create(plugin))` (the widening twin of
59
- `UIService.restrict`, cast-free) — then `fromState(db.store, before)` → drive one
60
- frame → `toState(db.store)`. Test **selection/detection** logic (which entities
61
- interact) separately, with seeded edge-case geometries.
62
-
63
- ## State equality — ordering vs precision, kept separate
64
-
65
- `expect-state-matches.ts` compares with **`equalsUnordered`** from `@adobe/data`
66
- (arrays as multisets — archetype hole-fill reorders rows — objects key-order
67
- independent). Absorb float noise — F32↔f64 storage rounding **and** trig epsilon
68
- (a quadrant `cos`/`sin` yields ~3e-15 where a case authors `0`) — by quantizing
69
- every number on both sides onto a small grid (`Math.round(Math.fround(n)*k)/k`)
70
- before comparing. Ordering and precision stay separate concerns. **Guard the
71
- projection itself** with one `fromState → toState` identity test on
72
- representative states.
153
+ It discovers every file exporting `cases`, enforces the two-exports rule, and
154
+ dispatches on case shape (transition → state + effects, derivation →
155
+ `fn(input) ≡ value`), seeding each case's `before`/`input` as a delta over
156
+ `State.create()`. Unwraps any `entity(specId)` arg marker to its plain data-id for
157
+ the pure side.
158
+
159
+ ## The exception — a per-surface `userId`, via the lower-level runners
160
+
161
+ A feature that needs **ambient per-case context differing per surface** — a
162
+ user-scoped `userId` that must be seeded before the raw transaction *and*
163
+ independently before the action dispatch (**p2p presence**) — does **not** use
164
+ `runFeature`. It calls `Conformance.runTransactions` / `runActions` (still
165
+ exported) directly, each with its own `seedContext` (and any per-surface
166
+ concurrency), because the seam differs between the transaction store and the
167
+ action db. `runFeature` has no place to thread two different `seedContext`s, so
168
+ this feature drops to the lower level. This is the escape hatch — ordinary
169
+ features never touch these drivers directly.
170
+
171
+ ## Identity — the `entity(specId)` marker
172
+
173
+ An entity-addressed transition writes its addressed id as `args: { id: entity(2) }`
174
+ — import `entity`, re-exported from the feature's `data/state/conformance-case.ts`.
175
+ `runSpec` unwraps it to the plain data-id; the ECS runner resolves it to the
176
+ **seeded entity** via the id→entity map `fromState` returns (turned into a `resolve`
177
+ by `Conformance.resolver` — no feature writes `resolve` by hand). Two conventions
178
+ make the wiring vanish: the ECS op takes the entity **under the transition's own arg
179
+ key** (`{ id }`, same-shape args, no reshape), and `fromState` returns the
180
+ `ReadonlyMap<Id, Entity>` id→entity map (or `void` for an index-addressed / singleton
181
+ feature, whose ids then resolve to `Entity.none`). `data-lit-todo` is the reference.
182
+
183
+ ## Name-parity — add a same-named op, never a per-item adapter
184
+
185
+ Every app transition is realized by a **same-named** transaction and/or action. When
186
+ the real UI op is richer or renamed — todo's `dragTodo`, space-rock's `newGame` — that
187
+ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-rock's
188
+ `createInitial` transaction) gives the transition something to pair with. Do **not**
189
+ reintroduce a per-item adapter to bridge a name mismatch — add the same-named op.
190
+
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.
209
+
210
+ ## Recording side effects — built in, no Proxy
211
+
212
+ Effect recording lives in the drivers: they enumerate a plain-object service's own
213
+ methods and closure-wrap each to record `[method, ...args]` calls, then delegate
214
+ (no `Proxy`, per the repo rule). `runSpec` records the case's injected service
215
+ `args`; the action surface records the `db.services` overrides. A case's `effects`
216
+ asserts each **declared** service's calls exactly (`Array` = ordered, `Set` = any
217
+ order); undeclared services (value-returning reads like `generateName`) are
218
+ ignored.
@@ -30,12 +30,21 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => {
30
30
  than throwing.
31
31
  - Decisions come from pure `data/` helpers; the transaction only applies the
32
32
  result — read the touched slice, call the `data/` transform, write the diff.
33
- - **Conform every transaction to its `data/` transform** in its sibling
34
- `*.test.ts`, via `expectConforms` over the spec's shared cases (see
35
- `services/main-service/conformance.md`): seed `fromState(before)`, dispatch, assert `toState ≡
36
- after`, covering every branch and edge case. A transaction taking **entity
37
- ids** resolves them from the seeded store in the `apply` closure; one with no
38
- `data/` analogue (`setBounds`, `setInput`) gets a direct resource assertion.
33
+ - Keep transaction files **single-export** (the `transactions/` barrel is
34
+ `export *`-ed into the plugin facet, so a second export would pollute it).
35
+ - **Conformance is wired once, centrally, and auto-paired** — not per-file. The
36
+ feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
37
+ call conforms transactions: it pulls them off **`plugin.transactions`** (the
38
+ registered facet), pairs each to the **same-named** `data/state` transition, and
39
+ conforms it — seed `fromState(before)`, apply, `Match.assert` `toState ≡ after`
40
+ (state only; service effects are asserted through the action). There is **no**
41
+ `define`/`conforms` adapter and **no** `covers` guard. A transaction taking
42
+ **entity ids** takes them under the transition's own arg key (`{ id }`); the runner
43
+ resolves each `entity(specId)` marker via the id→entity map `fromState` returns. A
44
+ transaction with **no same-named transition** is infrastructure (`setInput`,
45
+ `setBounds`) or the drag UI op (`dragTodo`) or system-dispatched — it is simply
46
+ **skipped**, no guard needed. Don't add a per-item adapter for a renamed op; add a
47
+ thin same-named transaction instead (see `conformance.md`).
39
48
  - An `index.ts` barrel feeds the `transactions` plugin facet — so it must
40
49
  re-export **only** the mutations. A read/query helper shared by several
41
50
  transactions (`readShip`, `readBoard` — a `(t) => value` function) may live
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adobe-data-ai",
3
- "version": "0.9.92",
3
+ "version": "0.9.94",
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/README.md CHANGED
@@ -11,46 +11,95 @@ It installs two things:
11
11
  - **skills** — `build-*` commands that scaffold features and applications.
12
12
  - **rules** — the path-scoped conventions those skills follow while building.
13
13
 
14
- > **For the installing agent:** find the block below that matches your host
15
- > (Claude Code or Cursor/other), run its commands, and stop. Each install owns a
16
- > namespaced folder and is a clean wipe-and-recopy, so re-running is also how you
17
- > **update** — it never touches files you authored.
14
+ The **rules** are the part that evolves often, so `install` pins the package and
15
+ re-copies them automatically on every install: bump the version and they refresh —
16
+ no manual step, no committed diff, no hand-editing.
18
17
 
19
- ## Install — Claude Code
18
+ ## Quick start
20
19
 
21
- Skills load as a marketplace plugin; rules install into the project. Run these
22
- three from a shell (agent-runnable):
20
+ From your project folder root:
21
+
22
+ **1. Install** — one command:
23
23
 
24
24
  ```
25
- claude plugin marketplace add adobe/data --scope project
26
- claude plugin install adobe-data-ai@adobe-data-skills --scope project
27
25
  npx @adobe/data-ai@latest install
28
26
  ```
29
27
 
30
- In an interactive Claude session you can instead use the slash-command form of
31
- the first two — `/plugin marketplace add adobe/data` then
32
- `/plugin install adobe-data-ai@adobe-data-skills` — but an agent driving a shell
33
- should use the `claude plugin …` commands above (slash commands aren't
34
- shell-runnable).
28
+ *(Claude Code only:* also add the skills plugin once — see
29
+ [Claude Code](#claude-code--add-the-skills-plugin-one-time). Cursor/Codex need
30
+ nothing extra; `install` copies their skills.*)*
31
+
32
+ **2. Restart your AI agent** so it loads the newly installed skills and rules.
33
+
34
+ **3. Create an application** — ask your agent:
35
+
36
+ ```
37
+ /build-application <a description of the app you want>
38
+ ```
39
+
40
+ That's the whole flow. Everything below explains what `install` does and the
41
+ other build skills available.
42
+
43
+ ## What `install` does
44
+
45
+ `install` does two things: it **copies the bundle now** (rules →
46
+ `.claude/rules/adobe-data-ai/`, and for Cursor/Codex skills →
47
+ `.agents/skills/adobe-data-ai/`), and it **wires the repo so future installs
48
+ self-update**, editing your `package.json` and `.gitignore` to:
35
49
 
36
- - Skills → the `adobe-data-ai` plugin. **Update:** `claude plugin update adobe-data-ai`.
37
- - Rules → `.claude/rules/adobe-data-ai/`. **Update:** re-run `npx @adobe/data-ai@latest install`.
50
+ 1. pin `@adobe/data-ai` in `devDependencies` at the **exact version it just
51
+ resolved** (never a range, so a later install can't silently pull an
52
+ unreviewed release);
53
+ 2. add `data-ai install` to **your own** `postinstall` script (chaining if one
54
+ exists) — it must be *your* script: pnpm does not run a dependency's lifecycle
55
+ scripts, so a `postinstall` shipped inside the package would silently not fire;
56
+ 3. gitignore the managed bundle folders.
38
57
 
39
- ## Install — Cursor (and Codex / other `.agents` agents)
58
+ The copied files are regenerated artifacts — never committed, never edited in
59
+ place. The wiring is idempotent, so re-running is harmless; with `--global` or in a
60
+ directory with no `package.json`, `install` just copies (no wiring).
40
61
 
41
- One command installs both:
62
+ After that first run, sync your lockfile with a normal install (`pnpm install`) so
63
+ the pinned dev-dependency is recorded; from then on every install refreshes the
64
+ bundle via the postinstall.
65
+
66
+ **Why `@latest` for the first command but a pinned version afterward?** The one
67
+ command you type is a one-time, human-initiated bootstrap, so `@latest` is fine
68
+ (the same posture as `npm create …@latest`). The part that *repeats* — every
69
+ teammate's and CI's install — runs from the **exact version `install` pinned into
70
+ your `devDependencies`**, never a moving tag, so no unreviewed release ever
71
+ auto-executes. Pinning happens where it matters, automatically.
72
+
73
+ **Update:** bump the `@adobe/data-ai` version in `package.json` and install again.
74
+ That's it.
75
+
76
+ ### Claude Code — add the skills plugin (one time)
77
+
78
+ Claude Code loads skills from a marketplace plugin, not from `.claude/skills/`, so
79
+ its skills are a separate one-time install (the `install` step above already
80
+ handles the rules for Claude Code):
42
81
 
43
82
  ```
44
- npx @adobe/data-ai@latest install
83
+ claude plugin marketplace add adobe/data --scope project
84
+ claude plugin install adobe-data-ai@adobe-data-skills --scope project
45
85
  ```
46
86
 
47
- - Skills → `.agents/skills/adobe-data-ai/`.
48
- - Rules → `.claude/rules/adobe-data-ai/`.
49
- - **Update:** re-run the same command.
87
+ (Interactive sessions can use `/plugin marketplace add adobe/data` then
88
+ `/plugin install adobe-data-ai@adobe-data-skills`.) **Update the skills:**
89
+ `claude plugin update adobe-data-ai`. Cursor/Codex need no plugin — `install`
90
+ copies their skills too.
91
+
92
+ ### Just the files, no managed dependency
50
93
 
51
- ## Use
94
+ If you don't want the auto-update wiring — a throwaway scaffold, a non-Node repo,
95
+ or you'd rather commit the bundle and update it on your own schedule — run
96
+ `install` where it can't wire (no `package.json`, or pass `--global`), or simply
97
+ delete the `postinstall`/dev-dependency lines it added. Re-running `install` is
98
+ always a clean wipe-and-recopy; you then update by re-running it yourself.
52
99
 
53
- Ask your agent to run a build command — skills are available by name:
100
+ ## Build skills
101
+
102
+ Step 3 above uses `build-application`; the skills are available by name:
54
103
 
55
104
  - **`build-application`** — build a whole app: a base feature that hosts
56
105
  lazily-loaded peer features.
@@ -66,15 +115,16 @@ by name when you want a single phase or a specialized flow: `build-data`,
66
115
  in subagents), `review` (audit output against the rules), and `structure`
67
116
  (reason about layout).
68
117
 
69
- ## One-liner: install + build an app
70
-
71
- Paste one of these into a shell, replacing the quoted text with your own app
72
- description. It installs the bundle, then runs `build-application` headlessly on
73
- your prompt.
118
+ ## One-liner: install + build an app (headless)
74
119
 
75
- This presumes you are already running the agent within the directory you want to use as your project root.
120
+ Paste this into an agent already running in your project root, replacing the
121
+ quoted text with your own app description. It installs the bundle, then runs
122
+ `build-application` on your prompt:
76
123
 
77
124
  ```
78
- Find the @adobe/data-ai package on npmjs and then install it in the current directory.
79
- Next use it's /build-application skill to build <prompt here>
125
+ Set up @adobe/data-ai in the current directory: run
126
+ `npx @adobe/data-ai@latest install` (this copies the bundle and pins the exact
127
+ version so rules auto-update on every install). On Claude Code also add the
128
+ skills plugin per the package README. Then use its /build-application skill to
129
+ build <prompt here>
80
130
  ```
package/bin/cli.mjs CHANGED
@@ -157,6 +157,78 @@ version on every install. Package: <https://www.npmjs.com/package/${PKG_NAME}>
157
157
  return file;
158
158
  }
159
159
 
160
+ // Wire the consumer repo for auto-updating installs (part of `install`, not a
161
+ // separate step). Idempotently:
162
+ // 1. pins `@adobe/data-ai` in devDependencies (exact version — never a range,
163
+ // so an install can't silently pull an unreviewed release),
164
+ // 2. adds `data-ai install` to the consumer's OWN `postinstall` (a dependency's
165
+ // lifecycle script does NOT run under pnpm, so it must live here), and
166
+ // 3. gitignores the managed, regenerated bundle folders.
167
+ // After this, bumping the pinned version and re-installing recopies the bundle —
168
+ // no manual step, no committed diff. Skips gracefully with a note when there is
169
+ // no package.json (the bundle is still copied; the repo just isn't auto-wired).
170
+ const MANAGED_GITIGNORE = [
171
+ `# ${PKG_NAME} — managed, regenerated on install; do not edit or commit`,
172
+ ".claude/rules/adobe-data-ai/",
173
+ ".claude/rules/adobe-data-ai-bootstrap.md",
174
+ ".agents/skills/adobe-data-ai/",
175
+ ];
176
+
177
+ function wireManagedUpdates(base) {
178
+ const pkgPath = join(base, "package.json");
179
+ if (!existsSync(pkgPath)) {
180
+ process.stdout.write(
181
+ ` (no package.json in ${base} — auto-update not wired; the bundle was still copied.\n` +
182
+ ` Add one, re-run install to wire it, or re-run manually to update.)\n`,
183
+ );
184
+ return;
185
+ }
186
+ const consumer = JSON.parse(readFileSync(pkgPath, "utf8"));
187
+ const changes = [];
188
+
189
+ // 1. pinned devDependency
190
+ consumer.devDependencies ??= {};
191
+ let pinnedDep = false;
192
+ if (consumer.devDependencies[PKG_NAME] !== VERSION) {
193
+ consumer.devDependencies[PKG_NAME] = VERSION;
194
+ changes.push(`devDependencies["${PKG_NAME}"] = "${VERSION}" (exact)`);
195
+ pinnedDep = true;
196
+ }
197
+
198
+ // 2. own postinstall runs the installer (chain if one already exists)
199
+ consumer.scripts ??= {};
200
+ const INSTALL = "data-ai install";
201
+ const post = consumer.scripts.postinstall;
202
+ if (!post) {
203
+ consumer.scripts.postinstall = INSTALL;
204
+ changes.push(`scripts.postinstall = "${INSTALL}"`);
205
+ } else if (!post.includes(INSTALL)) {
206
+ consumer.scripts.postinstall = `${post} && ${INSTALL}`;
207
+ changes.push(`scripts.postinstall += " && ${INSTALL}"`);
208
+ }
209
+ if (changes.length) writeFileSync(pkgPath, JSON.stringify(consumer, null, 2) + "\n");
210
+
211
+ // 3. gitignore the managed bundle folders
212
+ const giPath = join(base, ".gitignore");
213
+ const gi = existsSync(giPath) ? readFileSync(giPath, "utf8") : "";
214
+ if (!gi.includes(MANAGED_GITIGNORE[0])) {
215
+ const sep = gi === "" ? "" : gi.endsWith("\n") ? "\n" : "\n\n";
216
+ writeFileSync(giPath, gi + sep + MANAGED_GITIGNORE.join("\n") + "\n");
217
+ changes.push(".gitignore += managed bundle paths");
218
+ }
219
+
220
+ for (const c of changes) process.stdout.write(` wired: ${c}\n`);
221
+ if (!changes.length) process.stdout.write(" auto-update already wired\n");
222
+ // Adding/repinning the dev-dependency leaves the lockfile out of sync until a
223
+ // normal install records it (and CI with a frozen lockfile would fail until then).
224
+ if (pinnedDep) {
225
+ process.stdout.write(
226
+ "\nNext: run your package manager's install to sync the lockfile\n" +
227
+ " pnpm install # or npm install / yarn\n",
228
+ );
229
+ }
230
+ }
231
+
160
232
  function parseArgs(argv) {
161
233
  const positional = [];
162
234
  const flags = new Set();
@@ -177,11 +249,17 @@ Install the architecture skills + rules for Cursor, Codex, and other agents.
177
249
  same way for every agent — see README.)
178
250
 
179
251
  Usage:
180
- npx ${PKG_NAME}@latest install [options]
252
+ npx ${PKG_NAME}@latest install # copy the bundle AND wire auto-updates (default)
181
253
  npx ${PKG_NAME}@latest list
182
254
 
183
255
  Commands:
184
- install Skills → .agents/skills/${BUNDLE}/, rules → .claude/rules/${BUNDLE}/ (default).
256
+ install Copy the bundle (skills → .agents/skills/${BUNDLE}/, rules →
257
+ .claude/rules/${BUNDLE}/) AND wire this repo for auto-updating installs:
258
+ pin ${PKG_NAME} in devDependencies (exact), add \`data-ai install\` to the
259
+ repo's own postinstall, and gitignore the managed bundle folders. Run once;
260
+ thereafter a plain install refreshes the bundle and bumping the pinned
261
+ version updates it. Skips the wiring (copies only) with \`--global\` or when
262
+ there's no package.json.
185
263
  list Print the skills bundled in this package.
186
264
 
187
265
  Options:
@@ -229,6 +307,10 @@ function main() {
229
307
  process.stdout.write(` ${skills.length} skills → ${skillsDir}\n`);
230
308
  process.stdout.write(` ${ruleCount} rules → ${rulesDir}\n`);
231
309
  process.stdout.write(` bootstrap → ${bootstrapFile}\n`);
310
+
311
+ // A project install also wires the repo for auto-updating installs (idempotent).
312
+ // A global install has no consumer package.json to wire, so it only copies.
313
+ if (!flags.has("global")) wireManagedUpdates(base);
232
314
  }
233
315
 
234
316
  main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/data-ai",
3
- "version": "0.9.92",
3
+ "version": "0.9.94",
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.92"
32
+ "@adobe/data": "0.9.94"
33
33
  },
34
34
  "devDependencies": {
35
35
  "typescript": "^5.8.3",
36
36
  "vitest": "^1.6.0",
37
- "@adobe/data": "0.9.92"
37
+ "@adobe/data": "0.9.94"
38
38
  },
39
39
  "license": "MIT",
40
40
  "scripts": {