@adobe/data-ai 0.9.92 → 0.9.93
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/rules/features/data/index.md +11 -5
- package/.claude/rules/features/data/state.md +204 -47
- package/.claude/rules/features/index.md +29 -10
- package/.claude/rules/features/services/index.md +19 -0
- package/.claude/rules/features/services/main-service/actions.md +34 -0
- package/.claude/rules/features/services/main-service/computed.md +51 -0
- package/.claude/rules/features/services/main-service/conformance.md +209 -63
- package/.claude/rules/features/services/main-service/transactions.md +15 -6
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +82 -32
- package/bin/cli.mjs +84 -2
- package/package.json +3 -3
|
@@ -5,11 +5,17 @@ paths:
|
|
|
5
5
|
|
|
6
6
|
# data/ — the data model
|
|
7
7
|
|
|
8
|
-
The foundation layer: the feature's **data types
|
|
9
|
-
readonly, JSON-serializable value suitable for
|
|
10
|
-
communication over the wire — no functions, no handles, just
|
|
11
|
-
|
|
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
|
|
9
|
-
|
|
10
|
-
a
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
`
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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/ —
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
`toState
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
`
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
147
|
+
import { State } from "./state.js";
|
|
148
|
+
import { transitions } from "./transitions.js";
|
|
149
|
+
|
|
150
|
+
Conformance.runSpec({ state: State, transitions });
|
|
50
151
|
```
|
|
51
152
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
`
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
##
|
|
18
|
+
## Quick start
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
31
|
-
the
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.9.93",
|
|
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.
|
|
32
|
+
"@adobe/data": "0.9.93"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^5.8.3",
|
|
36
36
|
"vitest": "^1.6.0",
|
|
37
|
-
"@adobe/data": "0.9.
|
|
37
|
+
"@adobe/data": "0.9.93"
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"scripts": {
|