@adobe/data-ai 0.9.91 → 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/data-modelling.md +4 -3
- package/.claude/rules/ecs.md +1 -1
- package/.claude/rules/element.md +1 -1
- package/.claude/rules/features/data/index.md +59 -14
- package/.claude/rules/features/data/state.md +204 -44
- 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 +39 -5
- package/.claude/rules/features/services/main-service/computed.md +54 -3
- package/.claude/rules/features/services/main-service/conformance.md +209 -62
- package/.claude/rules/features/services/main-service/index.md +14 -10
- package/.claude/rules/features/services/main-service/services.md +8 -7
- package/.claude/rules/features/services/main-service/transactions.md +15 -6
- package/.claude/rules/features/ui/binding-element.md +4 -4
- package/.claude/rules/global/namespace.md +38 -10
- package/.claude/rules/global/react.md +1 -1
- package/.claude/rules/lit.md +3 -3
- package/.claude/rules/observe.md +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +82 -32
- package/bin/cli.mjs +84 -2
- package/package.json +3 -3
- package/skills/build-app-entry/SKILL.md +1 -1
- package/skills/build-data/SKILL.md +2 -2
- package/skills/build-systems/SKILL.md +1 -1
|
@@ -15,9 +15,10 @@ discriminated unions.
|
|
|
15
15
|
## Example
|
|
16
16
|
|
|
17
17
|
```ts
|
|
18
|
-
// types/http-method/
|
|
19
|
-
export
|
|
20
|
-
|
|
18
|
+
// types/http-method/http-method.ts — the hand-authored type owns the member identity
|
|
19
|
+
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
|
|
20
|
+
// (a schema.ts, if/when a runtime boundary needs one, is written to match this
|
|
21
|
+
// type and pinned to it — see global/namespace.md and features/data/index.md)
|
|
21
22
|
|
|
22
23
|
// In an unrelated request-counter plugin:
|
|
23
24
|
|
package/.claude/rules/ecs.md
CHANGED
|
@@ -143,7 +143,7 @@ Synchronous, deterministic atomic mutations. Receive `store` and a payload.
|
|
|
143
143
|
|
|
144
144
|
### actions
|
|
145
145
|
|
|
146
|
-
UI components that call actions must never consume returned values — see
|
|
146
|
+
UI components that call actions must never consume returned values — see `features/ui/binding-element.md` (Actions are fire-and-forget).
|
|
147
147
|
|
|
148
148
|
**At most one transaction per action.** Multiple transactions in a single action corrupt the undo/redo stack.
|
|
149
149
|
|
package/.claude/rules/element.md
CHANGED
|
@@ -125,7 +125,7 @@ exists *only* for locator inputs — never for state, never for derived
|
|
|
125
125
|
values, never for parent-forwarded flags. The `P` in `DatabaseElement<P>` (and
|
|
126
126
|
the `get plugin()` it returns) is the surface the element *consumes* — never the
|
|
127
127
|
varying topmost-layer name (see `features/services/main-service/index.md`): `typeof
|
|
128
|
-
|
|
128
|
+
MainService.plugin` for a self-contained feature. **Exception:** an element
|
|
129
129
|
meant to be *extended* — injected with various databases that build on this
|
|
130
130
|
feature (a peer app that adds an agent, a p2p/presence build) — types on the
|
|
131
131
|
minimal base layer it consumes (e.g. `ComputedDatabase.plugin`) so every
|
|
@@ -5,30 +5,75 @@ 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
|
-
its
|
|
21
|
+
its hand-authored type, its (optional, matching) schema, and its pure
|
|
22
|
+
synchronous helpers together:
|
|
16
23
|
|
|
17
24
|
```
|
|
18
25
|
data/player-mark/
|
|
19
|
-
player-mark.ts # type
|
|
20
|
-
schema.ts #
|
|
26
|
+
player-mark.ts # HAND-AUTHORED type + `export * as PlayerMark from "./public.js"`
|
|
27
|
+
schema.ts # (optional / later) schema matching the type, pinned via Assert<Equal<…>>
|
|
21
28
|
public.ts # re-exports schema + helpers
|
|
22
29
|
is.ts values.ts opponent.ts … # one pure helper per file
|
|
23
30
|
```
|
|
24
31
|
|
|
25
|
-
- The **type is
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
32
|
+
- The **type is authored by hand** in `<type>.ts` — the readable source of
|
|
33
|
+
truth. Write it with the richest types available (`F32`, `Vec3`, unions,
|
|
34
|
+
branded aliases) so members carry their semantic meaning and get strong
|
|
35
|
+
IntelliSense / hover docs.
|
|
36
|
+
- The **schema is derived from the type, not the reverse.** It is written to
|
|
37
|
+
match the type and **pinned** to it with a compile-time assertion so the two
|
|
38
|
+
cannot drift (see below). A field typed as a branded primitive reuses that
|
|
39
|
+
type's schema — `F32` → `F32.schema`, `Vec3` → `Vec3.schema`.
|
|
40
|
+
- **Schemas are optional until a runtime boundary needs one.** A pure `data/`
|
|
41
|
+
type is complete with `<type>.ts` alone. The schema is added only when
|
|
42
|
+
persistence, the wire, or an ECS component/resource requires it — and is
|
|
43
|
+
typically written by the agent in a later phase (e.g. when implementing the
|
|
44
|
+
ECS), not by the human up front.
|
|
29
45
|
- Helpers are synchronous and pure; each has a sibling `*.test.ts`.
|
|
30
|
-
- Objects
|
|
31
|
-
|
|
46
|
+
- Objects of only 32-bit numeric values / sub-structs: the schema uses
|
|
47
|
+
`Schema.fromStructProperties` (guarantees a valid struct schema, stored in
|
|
48
|
+
linear memory).
|
|
49
|
+
|
|
50
|
+
## Pinning the schema to the type
|
|
51
|
+
|
|
52
|
+
The type is written first and by hand; the schema is written to match it and
|
|
53
|
+
is locked to it at compile time, so schema authoring (whenever it happens)
|
|
54
|
+
cannot silently drift from the type readers rely on.
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// player-mark/player-mark.ts — HAND-AUTHORED, the source of truth
|
|
58
|
+
export type PlayerMark = 'X' | 'O';
|
|
59
|
+
export * as PlayerMark from './public.js';
|
|
60
|
+
|
|
61
|
+
// player-mark/schema.ts — written to MATCH the type (often later, by the agent)
|
|
62
|
+
import { Schema } from '@adobe/data/schema';
|
|
63
|
+
import type { Assert, Equal } from '@adobe/data/types';
|
|
64
|
+
import type { PlayerMark } from './player-mark.js';
|
|
65
|
+
|
|
66
|
+
export const schema = { type: 'string', enum: ['X', 'O'] } as const satisfies Schema;
|
|
67
|
+
|
|
68
|
+
// Compile-time pin: fails to build if schema and type diverge.
|
|
69
|
+
type _Pin = Assert<Equal<Schema.ToType<typeof schema>, PlayerMark>>;
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The `Assert<Equal<…>>` line is the whole safety net: `Schema.ToType<typeof
|
|
73
|
+
schema>` must be exactly the hand-authored type (including readonly/optional),
|
|
74
|
+
or the file does not compile. `Schema.ToType` is used **only** inside the
|
|
75
|
+
assertion — never as the exported type. Consumers always import the
|
|
76
|
+
hand-authored `PlayerMark`.
|
|
32
77
|
|
|
33
78
|
One folder is special: **`data/state/`** holds the feature's single `State`
|
|
34
79
|
aggregate and its pure transforms/derivations (its own rule, `state.md`);
|
|
@@ -5,52 +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
|
-
|
|
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,23 +10,57 @@ 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
|
|
|
16
|
-
export const addRandomTodo = async (
|
|
17
|
-
const name = await
|
|
18
|
-
|
|
30
|
+
export const addRandomTodo = async (service: ServiceDatabase) => {
|
|
31
|
+
const name = await service.services.nameGenerator.generateName(); // await a services/ port
|
|
32
|
+
service.transactions.createTodo({ name }); // then exactly one commit
|
|
19
33
|
};
|
|
20
34
|
```
|
|
21
35
|
|
|
22
|
-
- Type the `
|
|
36
|
+
- Type the `service` parameter on the lowest database layer exposing what the
|
|
23
37
|
action touches — usually `ServiceDatabase` (services **and**
|
|
24
38
|
transactions). Never the action layer itself (that would be a cycle).
|
|
25
39
|
- **Call at most one transaction** per action, so undo/redo stays one step
|
|
26
40
|
per operation.
|
|
27
41
|
- **Fire-and-forget.** An action's return value is not consumed; results flow
|
|
28
|
-
back through observables. `
|
|
42
|
+
back through observables. `service.services.*` calls are `void` or
|
|
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.
|
|
@@ -7,20 +7,71 @@ paths:
|
|
|
7
7
|
|
|
8
8
|
One derived value per file: a `cached` function of a database layer that
|
|
9
9
|
returns an `Observe` of state projected through pure `data/` helpers.
|
|
10
|
-
Derivation logic itself lives in `data/`; a computed only wires a
|
|
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";
|
|
16
37
|
import { BoardState } from "../../../data/board-state/board-state.js";
|
|
17
38
|
import type { IndexDatabase } from "../../index-database/index-database.js";
|
|
18
39
|
|
|
19
|
-
export const status = cached((
|
|
20
|
-
Observe.withFilter(
|
|
40
|
+
export const status = cached((service: IndexDatabase) =>
|
|
41
|
+
Observe.withFilter(service.observe.resources.board, BoardState.deriveStatus),
|
|
21
42
|
);
|
|
22
43
|
```
|
|
23
44
|
|
|
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.
|