Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
42b69c1
checkpoint: type-first conformance pattern (todo reference) + sample …
krisnye Aug 6, 2026
357d810
docs(rules): feature-architecture rules for the co-located conformanc…
krisnye Aug 6, 2026
7b77380
docs(rules): computed conformance builds from the ComputedDatabase layer
krisnye Aug 6, 2026
de24465
docs(rules): barrel-driven coverage guards; void-arg-safe conformance…
krisnye Aug 7, 2026
793f23b
docs(rules): precise state/ derivation criterion; omit empty computed…
krisnye Aug 7, 2026
dd63efe
feat(tictactoe): convert to co-located conformance pattern; rule clar…
krisnye Aug 7, 2026
17b4d66
refactor: align assembled-db alias to MainService; actions read the s…
krisnye Aug 7, 2026
b390060
feat(solid-dashboard): convert to co-located conformance pattern
krisnye Aug 7, 2026
77caa31
feat(react-pixie): convert to co-located conformance pattern
krisnye Aug 7, 2026
26e5398
feat(p2p-tictactoe): convert both features to co-located conformance …
krisnye Aug 7, 2026
95c5604
feat(space-rock): convert to co-located conformance pattern
krisnye Aug 7, 2026
ab46c47
docs(rules): findings from the real-time/systems conversion (space-rock)
krisnye Aug 7, 2026
492f923
test(space-rock): keep genuine non-transition helper tests (create, i…
krisnye Aug 7, 2026
2d1d1a1
refactor: feature-qualified public exports use <Feature>MainService
krisnye Aug 7, 2026
c1649a8
docs(rules): computed conformance scope — wiring logic, not field count
krisnye Aug 7, 2026
3003c1d
feat(data-ai): `init` command for managed, auto-updating installs
krisnye Aug 7, 2026
be57cff
refactor(data-ai): fold `init` into `install` — one command copies + …
krisnye Aug 7, 2026
3e04f3c
feat(data-ai): install prints lockfile-sync reminder after pinning th…
krisnye Aug 7, 2026
ae4a5b8
chore: bump to v0.9.93
krisnye Aug 7, 2026
602c42f
docs(rules): explain case co-location; show vitest matcher; allow reu…
krisnye Aug 7, 2026
b150e73
docs(rules): make the test-helper-runtime ban emphatic and unambiguous
krisnye Aug 7, 2026
7b8261a
feat(data): @adobe/data/testing — shared conformance toolkit; migrate…
krisnye Aug 7, 2026
7636d83
feat(data): migrate tictactoe/space-rock/solid-dashboard/react-pixie …
krisnye Aug 7, 2026
aaeba48
feat: migrate p2p to @adobe/data/testing; rewrite data-ai rules for t…
krisnye Aug 7, 2026
d09029d
feat(data): auto-pairing conformance runners (zero overrides); refact…
krisnye Aug 7, 2026
ff76c63
feat(data): finish zero-override conformance — all samples auto-pair;…
krisnye Aug 7, 2026
a476424
feat(data): patch-shaped transitions (option B) — lib + tictactoe
krisnye Aug 7, 2026
f2c3780
feat(data): todo patch transitions (B); drop tictactoe opponent demo
krisnye Aug 7, 2026
f5805b5
test(tictactoe): sibling unit tests for every data/ helper
krisnye Aug 7, 2026
9e1b73d
feat(data): Conformance.runFeature — one-call feature conformance; ti…
krisnye Aug 7, 2026
743a6cc
feat(data): roll Conformance.runFeature across all samples
krisnye Aug 7, 2026
4175000
docs(rules): teach Conformance.runFeature (one-call feature conformance)
krisnye Aug 7, 2026
331fd63
refactor(data): runSpec takes `state` (the State namespace), like run…
krisnye Aug 7, 2026
e98a975
refactor(data): runSpec takes a single config object, matching runFea…
krisnye Aug 7, 2026
7c01f84
refactor(samples): extract per-feature transitions.ts; migrate data-g…
krisnye Aug 7, 2026
0e3968e
refactor(samples): finish option-B patch-shaped transitions across al…
krisnye Aug 7, 2026
f3d0125
docs(data-ai): single-command install — npx @adobe/data-ai@latest ins…
krisnye Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: 24
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:

- uses: actions/setup-node@v6
with:
node-version: 22
node-version-file: .nvmrc
cache: pnpm

- run: pnpm install --frozen-lockfile
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "data-monorepo",
"version": "0.9.92",
"version": "0.9.93",
"private": true,
"engines": {
"node": ">=24"
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "adobe-data-ai",
"version": "0.9.92",
"version": "0.9.93",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
16 changes: 11 additions & 5 deletions packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ paths:

# data/ — the data model

The foundation layer: the feature's **data types**. A data type is a
readonly, JSON-serializable value suitable for persistence and for
communication over the wire — no functions, no handles, just plain data.
It depends on nothing but `@adobe/data` and other `data/` declarations, and
needs no knowledge of anything built on top of it.
The foundation layer: the feature's **data types** and the pure declarations
over them. A data type is a readonly, JSON-serializable value suitable for
persistence and for communication over the wire — no functions, no handles, just
plain data. A `data/` **type** depends on nothing but `@adobe/data` and other
`data/` types, and needs no knowledge of anything built on top of it. The
**transitions** over those types are less restricted: when one injects a service
it may import freely from `services/` — the service type and any utilities its
namespace exposes (see `state.md`). What it must **never** touch is `ui/`:
`data/` never depends on presentation — that isolation is strict and inviolable.
Only *services themselves* are never authored here; that is what makes this the
value-type layer.

Each data type is its own namespace folder (see `global/namespace.md`), holding
its hand-authored type, its (optional, matching) schema, and its pure
Expand Down
251 changes: 204 additions & 47 deletions packages/data-ai/.claude/rules/features/data/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,55 +5,212 @@ paths:

# data/state/ — the State specification

`State` is the whole feature modelled as **one immutable object** — the
pure, fully-tested source of truth for the feature. Modelling everything as
a single value keeps each transform a small, trivially-testable function of
state: collections of entity sub-types plus scalar fields.
`State` is the whole feature as **one immutable object** — the pure, fully-tested
source of truth. Each transition is a read→write **patch** over state; each
derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`.

Every feature that has ECS resources or transactions owns a `State` — including
a host whose aggregate is a single scalar (`{ playing: boolean }`) or even when there is no state in which case use `{}`.
```ts
// state.ts — the aggregate, plus the namespace of transforms/derivations.
export type State = {
readonly todos: readonly Todo[]; // a collection of entity sub-types
readonly displayCompleted: boolean; // a scalar field
};
// state.ts — the aggregate + the transition/derivation namespace.
export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
export * as State from "./public.js";
```

## Transforms — one per file, `(state, …args) => state`

- **Pure.** No I/O, no framework, no mutation — return a new value.
- **Narrow in, same shape out.** Write on the smallest slice the transform
needs and keep the input generic over that slice so it lifts to
full-state-in / full-state-out:

```ts
export const playMove = <T extends Pick<State, "board" | "firstPlayer">>(
state: T,
input: PlayMoveArgs,
): T => { /* … return { ...state, board: … } */ };
```

A whole-`State` transform (`restartGame(state: State): State`) is fine when
it genuinely touches everything.
- Args may be **narrowed or omitted** (`toggleDisplayCompleted(state)`).
- Guard and **return `state` unchanged** on a no-op / illegal input rather
than throwing — this keeps transforms idempotent under repeated application.
- Each transform has a sibling `*.test.ts`; performance is irrelevant here,
correctness is everything. The test file **exports** its cases —
`export const cases: ConformanceCase<Args>[]` (the shared `conformance-case.ts`
type) — right alongside the `describe`/`it` that exercise them. This is
spec-owned truth the matching main-service conformance test imports **from the
`<transform>.test.js` file** unchanged (see `services/main-service/conformance.md`).
Keeping the cases in the test file rather than a separate `<transform>.cases.ts`
removes a file per transform — less folder clutter, same reuse. Author
`before`/`after` as full `State` (`{ ...State.create(), …overrides }`); the
generic-slice signature lets them flow through. Tolerant full-`State` equality is
the shared `expect-state-matches.ts`.

## Derivations — `(state) => value`

Pure selectors (`visibleTodos(state)`). Sub-type math (a winner, a status)
lives on the relevant `data/<type>` namespace; `state/` only composes over
the whole aggregate.
Every feature with ECS resources/transactions owns a `State` (a scalar
`{ playing: boolean }`, or `{}` when there is none).

**`State` has a standard shape.** Two exports are conventional and drive
conformance:

- **`create(): State`** (`data/state/create.ts`) — the default state. Every
conformance case's `before` is a **delta over `State.create()`**, and both the
pure `spec.test.ts` and the ecs `runFeature` seed from it.
- **`samples: readonly State[]`** (`data/state/samples.ts`) — representative
**full** states `runFeature` round-trips through the projection
(`toState ∘ fromState ≡ identity`, see `conformance.md`).

Both are re-exported through `public.js`, so `State.create()` / `State.samples`
are namespace members. (A `cases` literal must still not touch `public.js` at
load — import `create` from `./create.js` directly there; see below.)

**The discovered transitions are a test-only sibling, NOT on the namespace.**
`data/state/transitions.ts` exports one `import.meta.glob` of the folder — the
`{ fn, cases }` modules — imported by *both* `spec.test.ts` and the ecs
`conformance.test.ts` so the glob is authored once per feature:

```ts
// data/state/transitions.ts — test-only; both test entry points import it
export const transitions = import.meta.glob<Record<string, unknown>>(
["./*.ts", "!./*.test.ts", "!./*.type-test.ts", "!./transitions.ts"],
{ eager: true },
);
```

It stays **out of `public.js`** deliberately: unlike `create`/`samples` (plain
prod-safe values), `transitions` is the test-case graph — hanging it on `State`
would drag `import.meta.glob` (a Vite-only construct) and every `cases` fixture
(fake services, matchers, the whole `@adobe/data/testing` module) into every
production import of `State`, none of which tree-shakes off a live namespace
re-export. So `create`/`samples` earn a namespace slot; `transitions` is a test
concern the tests import directly.

## One file per transform: the function **and** its cases

A transform file exports **exactly two things** — the function and its
`cases` — nothing else (a private helper is fine; a second export is not, and the
spec aggregator throws if it finds one). The cases are the spec-owned truth every
conformance runner reuses; co-locating them removes the per-transform `.cases.ts`
and `.test.ts`.

**Why co-locate `cases` (not a sibling `*.test.ts`)?** They are spec-owned
fixtures the pure `spec.test.ts` **and** the ecs `runFeature` call both reuse
(driving transaction / action / computed conformance) — the transform's contract
expressed as data, not a per-file test — so they belong
beside the thing they specify, and `Conformance<typeof fn>` binds them to the
signature so they can't drift. Kept inert (no `describe`; one aggregator runs
them) they also sidestep the double execution vitest triggers when a single file
both exports cases and runs its own `describe`. Coverage is then enforced
centrally by the shared driver's barrel-driven guard rather than by eyeballing
one test file per transform — and genuine non-transition helpers still keep
their own `*.test.ts` (see below).

The case types, matchers, and runners all live in the shared
**`@adobe/data/testing`** module (two namespaces, `Match` and `Conformance`;
`vitest` is an *optional* peer dependency, already satisfied here). Only one
tiny per-feature file remains — a ~10-line alias, `conformance-case.ts`, that
binds `State` once so transform/derivation files can write a one-parameter type:

```ts
// data/state/conformance-case.ts — the only per-feature conformance declaration
import { Conformance as ConformanceApi } from "@adobe/data/testing";
import type { State } from "./state.js";
export type Conformance<F extends (...args: never[]) => unknown> = ConformanceApi.Cases<State, F>;
export type Derivation<F extends (...args: never[]) => unknown> = ConformanceApi.DerivationCases<F>;
export type Effects<Args> = ConformanceApi.Effects<Args>;
// The entity-reference marker for identity-addressed case args, re-exported so
// cases import it beside `Conformance`: `args: { id: entity(2) }`.
export const entity = ConformanceApi.entity;
```

```ts
// create-todo.ts
import { Match } from "@adobe/data/testing";
import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
export const createTodo = (
state: Pick<State, "todos">,
{ name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
): Pick<State, "todos"> => {
analytics.todoCreated({ name });
return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
};

export const cases: Conformance<typeof createTodo> = [
{ name: "appends the first todo",
before: {}, // empty delta — the default State.create()
args: { name: "a", analytics: AnalyticsService.createFake() },
after: { todos: [{ id: Match.anyNumber, name: "a", complete: false }] }, // only the changed field
effects: { analytics: [["todoCreated", { name: "a" }]] } },
];
```

- **Signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` — a
**read→write patch**. The parameter is the smallest `Pick<State,…>` the
transition **reads**; the return is *only the fields it **writes***. **No
`<T> => T` generic, no `...state` spread** in the return — return the patch and
let the runner merge it. **All non-state inputs go in the single `args` object**
(`Conformance<typeof fn>` reads `Parameters[1]`) — bundle a `dt`, an injected
service, etc. into it, never as a third positional. A transition that takes
**no** args omits `args` from each case entirely (the shared `Case` type makes
`args` optional exactly then). **Guard no-ops by returning an empty patch `{}`**
(or the unchanged slice), never throw.
- **A composer merges sub-patches explicitly.** A transition built from smaller
ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes
layer in; a transition that merely **delegates** to one sub-transition returns
that delegate's patch directly (no spread needed).
- **Co-located `cases` must not touch the feature's `public.js` barrel at module
load** — that barrel re-exports this very file, so calling `State.create()` (or
any barrel member) in a top-level `cases` literal dead-locks the import cycle.
Import the concrete helper directly (`import { create } from "./create.js"`) or
inline full-`State` literals.
- **`Conformance<typeof fn>`** (the alias above) derives the case `args` type from
the function's own signature — author it once, and cases can't drift from what
the function accepts. **`before` is a delta over `State.create()`** — list only
the fields this case sets differently from the default; **`after` is the writes
patch** — only the fields the transition changes. The runner seeds
`{ ...State.create(), ...before }` and compares against
`{ ...State.create(), ...before, ...after }`, so any field a case doesn't mention
is the default and stays unchanged. (A full `before`/`after` still works — it just
overrides the default wholesale.)
- **`after` leaves minted values open** with the shared matchers `Match.anyNumber`
/ `Match.anyString`, imported from `@adobe/data/testing` — there is **no**
per-feature `matchers.ts` anymore. An id the ECS assigns from its own id-space is
`id: Match.anyNumber`, so the pure spec and the ECS satisfy the same case — match
by content, not by the value you don't control. `Match` is framework-agnostic and
honors any asymmetric matcher, so vitest's `expect.stringContaining(...)` interops
on the expected side too. When an id must **line up in two places** within one
comparison — a `selectedId` that points at a specific todo, say — use
`Match.ref(label)`: it asserts id *correspondence* (a bijection up to renaming),
not a pinned value, so the two occurrences of the label must resolve to the same
actual id and two labels can't collide. `anyNumber`/`anyString` are for an id a
case does not pin at all; `ref` for one that must be consistent across the case.
- **Entity-addressed cases use `entity(specId)`.** A transition that addresses an
entity by id writes it as `args: { id: entity(2) }` — `entity` imported from the
feature's `conformance-case.ts` (re-exported from `@adobe/data/testing`). It types
as the id it stands for (like `Match.anyNumber`), so it slots into the transform's
own arg type. `runSpec` unwraps it to the plain data-id for the pure side; the ECS
runners resolve it to the seeded entity (see `conformance.md`).
- No per-transform test. The single **`spec.test.ts`** is one call —
`Conformance.runSpec({ state: State, transitions })` importing `transitions`
from the test-only `./transitions.js` (above) — that auto-discovers every module
exporting `cases`, enforces the two-exports rule, and dispatches on case shape (a
`value` case → derivation; otherwise a transition whose declared `effects` are
also asserted). Passing `{ state: State }` (the same `state` shape `runFeature`
takes) is what makes each case's `before`/`input` a delta over `State.create()`.
Add `match` alongside it only when
the feature needs float tolerance or unordered collections (see `conformance.md`).
There is no per-feature `expect-state-matches.ts`, `record-effects.ts`,
`expect-conforms.ts`, or `conformance-case.type-test.ts` — those are gone; the
shared driver owns comparison, effect recording, and name-based auto-pairing, and
the `Effects` type-test now lives once in `@adobe/data/testing`. A genuine **non-transition helper** in
`state/` — a `create()` constructor, a single-field predicate — has no `cases`
and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
sibling `*.test.ts`** rather than deleting it and losing coverage.

## Injected services and side effects

A transform that needs an outside capability receives it as a **named parameter**
in the args object, keyed by the service name minus its `-service` suffix
(`AnalyticsService` → `analytics`, `NameGeneratorService` → `nameGenerator`);
plain data args sit alongside. Import the service straight from `services/` (an
ordinary import — layers split by kind of type, not dependency). The **same
services appear on `db.services` for the matching action**, so the transition is
the complete spec of *both* the state change and the service calls.

- A transition **is deterministic given its dependencies** — inject a fixed
double and the output (and its calls) are fixed. An **async** dependency makes
the transition `Promise<State>`; keep sync the default.
- **Side effects are declared in the case's `effects`**, keyed by the service
arg, as `[methodName, ...args]` tuples — an `Array` asserts these calls in
order, a `Set` in any order. Only listed services are checked (a value-returning
read like `generateName` you don't list is ignored). See `conformance.md` for
how the recording double captures them.
- Tests inject **deterministic doubles** (never production) whose published
responses the case's `after`/`effects` are authored against — doubles live
adjacent to the interface (`features/services/index.md`).

## Derivations — `(state) => value`, cases `{ input, value }`

Pure selectors that **compose the aggregate** — a value drawn from **two or more
`State` fields** (`visibleTodos` from `todos` + `displayCompleted`;
`currentPlayer` from `board` + `firstPlayer`). A value computed from a **single**
`State` field is that field's own type math and lives on its `data/<type>`
namespace (`winner`/`status` from `board` → `data/board-state`), tested there —
**not** in `state/`. A feature may therefore have zero `state/` derivations.

A `state/` derivation co-locates cases shaped `{ input, value }`, typed
`Derivation<typeof fn>` (input + value read from the signature). Take the **full
`State`** as the parameter (not a `Pick` slice like a transform) — so the case
`input` type is the full `State` and computed conformance can seed it via
`fromState`; `value` may use matchers. The same `spec.test.ts` runs them (dispatching on case shape),
and each ECS computed backing one is conformance-tested from the same cases
(`conformance.md`). Sub-type math (a winner, a status) lives on the
relevant `data/<type>` namespace; `state/` only composes over the aggregate.
Loading