Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.96",
"version": "0.9.97",
"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.96",
"version": "0.9.97",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
21 changes: 21 additions & 0 deletions packages/data-ai/.claude/rules/data-modelling.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,27 @@ function record(m: unknown) {
}
```

## Collection ordering is carried by the type

A collection's type states whether its order is meaningful — the model is the
single source of truth, not a downstream comparison flag:

- **`ReadonlyArray<T>`** — order is meaningful. A display list rendered in sequence,
a drag-reorderable list, a positional tuple (`Vec2 = readonly [number, number]`).
- **`ReadonlySet<T>`** — an unordered bag. Entities materialised in nondeterministic
order, a membership set. Use this for **identity-keyed** collections: the element
carries its own `id`, so a `ReadonlySet<Entity>` replaces any
`ReadonlyMap<id, Entity>`.
- **`ReadonlyMap<K, V>`** — a keyed lookup whose **keys are meaningful/deterministic**
(an enum, a name, a stable string). Not for identity keys (those are Sets).

These are first-class `Data` (see `features/data/index.md`) — serialize a
Set/Map-bearing value with `Data.stringify` / `Data.parse` (plain `JSON.stringify`
cannot represent them), and `equals` compares them faithfully. Conformance mirrors
the semantics: `ReadonlyArray` compares positionally, `ReadonlySet` / `ReadonlyMap`
order-independently, and a numeric `id` is ignored (the ECS allocates it) — so there
is no separate "unordered" declaration when writing conformance cases.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic seems duplicated elsewhere. Let's leave it here and just reference it from the other rules instead of duplicating it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 34ae293 — kept the ordering-by-type rule canonical here in data-modelling.md; data/state.md and conformance.md now reference it instead of restating it.

## Shape of keyed collections

- `Record<EnumKey, T>` — every key required at all times. Default lists
Expand Down
7 changes: 4 additions & 3 deletions packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ paths:
# data/ — the data model

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
over them. A data type is a readonly, serializable value suitable for persistence
and for communication over the wire — no functions, no handles, just plain data:
JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob`
(serialize a Set/Map-bearing value with `Data.stringify` / `Data.parse`). 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
Expand Down
48 changes: 33 additions & 15 deletions packages/data-ai/.claude/rules/features/data/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,24 @@ paths:
source of truth. Each transition is a read→write **patch** over state; each
derivation a pure selector. Reference: `data-lit-todo`'s `data/state/`.

The presence of this folder makes the feature **state-based** — its Functional
State Specification is authoritative and the ECS conforms to it. A feature without
`data/state/` is **ECS-based** (the ECS is the source of truth, no conformance) and
this rule does not apply — see `../index.md`, Two modes.

```ts
// state.ts — the aggregate + the transition/derivation namespace.
export type State = { readonly todos: readonly Todo[]; readonly displayCompleted: boolean };
export * as State from "./public.js";
```

Every feature with ECS resources/transactions owns a `State` (a scalar
`{ playing: boolean }`, or `{}` when there is none).
Every **state-based** feature owns a `State` (a scalar `{ playing: boolean }`, or
`{}` when there is none). An ECS-based feature has no `State` at all.

**A `State` field's collection type carries its ordering** — see
`../../data-modelling.md` (Collection ordering is carried by the type). `todos`
above is a `ReadonlyArray` because todo order is a user-visible, reorderable fact;
an unordered entity bag (bullets, sprites) is a `ReadonlySet`.

**`State` has a standard shape.** Two exports are conventional and drive
conformance:
Expand Down Expand Up @@ -97,9 +107,10 @@ export const entity = ConformanceApi.entity;
// create-todo.ts
import { Match } from "@adobe/data-testing";
import type { Conformance } from "./conformance-case.js"; // the thin per-feature alias above
import type { Services } from "../../services/services.js"; // the feature's service map
export const createTodo = (
state: Pick<State, "todos">,
{ name, complete, analytics }: { name: string; complete?: boolean; analytics: AnalyticsService },
{ name, complete, analytics }: { name: string; complete?: boolean } & Pick<Services, "analytics">,
): Pick<State, "todos"> => {
analytics.todoCreated({ name });
return { todos: [...state.todos, { name, complete: complete ?? false }] }; // writes patch only
Expand Down Expand Up @@ -144,9 +155,12 @@ export const cases: Conformance<typeof createTodo> = [
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
per-feature `matchers.ts` anymore. An entity's own numeric `id` is **ignored by
default** (the ECS allocates it from its own id-space), so a case simply **omits
`id`** and the entity's content still compares — no `id: Match.anyNumber` needed.
(Reach for `id: Match.anyNumber` only when the type makes `id` required and a
literal would otherwise pin it.) 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
Expand All @@ -167,8 +181,9 @@ export const cases: Conformance<typeof createTodo> = [
`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`).
Add `match` alongside it only when the feature needs float `tolerance` (see
`conformance.md`); unordered collections are modeled as `ReadonlySet` /
`ReadonlyMap` on `State` (below), not declared as a match option.
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
Expand All @@ -179,13 +194,16 @@ export const cases: Conformance<typeof createTodo> = [

## 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 transform that needs an outside capability injects it with **`Pick<Services,
…>`** — never an ad-hoc inline type. `Services` is the feature's service map
(`services/services.ts`, see `../services/index.md`), keyed by the service name
minus its `-service` suffix (`analytics`, `nameGenerator`), so the key and its type
come from **one** place and can't drift per-transition. Plain data args sit
alongside via intersection: `{ readonly count: number } & Pick<Services, "analytics">`
(or `Pick<Services, "a" | "b">` when a transition takes only services). The **same
services appear on `db.services` for the matching action** — pinned to `Services` in
the ecs `service-database` — 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
Expand Down
28 changes: 28 additions & 0 deletions packages/data-ai/.claude/rules/features/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,34 @@ spec, it can be generated and kept honest by AI rules, with the conformance
tests as the safety net. Net result: write a slow-but-verifiable app, then
derive a fast one that provably behaves the same.

## Two modes: state-based vs. ECS-based

The "built twice" structure above is the **state-based** mode. Every feature is in
one of two modes, discriminated by **the presence of `data/state/`**:

- **State-based** — a **Functional State Specification (FSS)** is the source of
truth: the pure `data/State` aggregate with its transitions and derivations, and
the ECS is a conformance-verified implementation of it. Adds `data/state/` (State,
transitions, co-located `cases`, `spec.test.ts`), `services/main-service/conformance/`,
and the `state` projection computed. **This is how every new feature is authored.**
- **ECS-based** — the **ECS is the source of truth**, authored directly:
**no `data/state/`**, no FSS, no conformance. This is a **legacy** shape — features
written before the state-based approach existed. New features are **never** authored
this way; ECS-based exists only to describe and maintain those older features.

**Discriminator:** `data/state/` present → state-based; absent → ECS-based. The
`data/<type>` value folders (the serializable types backing components/resources,
the wire, and persistence) exist in **both** modes — only the aggregate `State`
spec and the conformance layer are mode-specific. `data/` never disappears; the
*aggregate spec* does.

**Testing follows the mode.** A state-based feature is verified by conformance: the
shared `cases` drive both the pure spec and the ECS, so a transaction or action with
a same-named transition needs no test of its own. An **ECS-based feature has no
conformance cases, so every transaction and every action carries its own unit
test** — the direct substitute for the conformance oracle (see `services/main-service/transactions.md`
and `actions.md`).

## The layers

**The layers organize by the *kind of type* each folder holds, not by a strict
Expand Down
27 changes: 27 additions & 0 deletions packages/data-ai/.claude/rules/features/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,33 @@ Everything a feature exposes as a service lives here. Two kinds:
The rest of this rule governs the capability contracts; `main-service` follows
its own subtree.

## `services/services.ts` — the injectable service map

The folder root exports one `Services` type: the feature's capability services
keyed by short name (the `-service` suffix dropped), the single source of truth for
service injection.

```ts
// services/services.ts
import type { AnalyticsService } from "./analytics-service/analytics-service.js";
import type { NameGeneratorService } from "./name-generator-service/name-generator-service.js";
export type Services = {
readonly analytics: AnalyticsService;
readonly nameGenerator: NameGeneratorService;
};
```

- **Transitions inject with `Pick<Services, …>`** (`data/state.md`), never a
re-declared inline `{ analytics: AnalyticsService }` — so the key/type live in one
place.
- **The ecs `service-database` is pinned to it.** After its `ServiceDatabase` type,
a drift-guard asserts the resolved services match the map, so `db.services` (what
actions call) and `Services` (what transitions inject) can't diverge:
`type _Pin = Assert<Equal<ServiceDatabase["services"], Services>>`.
- **Inherited services**, when a peer feature builds on another, intersect the
parent map: `export type Services = MainServices & { readonly baz: BazService }`.
- A feature with no capability services needs no `services.ts`.

## Capability contracts

Each is a namespace folder (`global/namespace.md`); the export and folder both carry the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ export const addRandomTodo = async (service: ServiceDatabase) => {
Reactive computeds refresh only on a committed transaction, so an imperative
read of one can hand back a stale shared cache (and it's the UI's layer, not
the action's). This also keeps the action correct under the conformance seed.
- **ECS-based feature (no `data/state/`):** no conformance cases exist, so **every
action carries its own unit test** — build a db, run the action with fake
`services`, and assert the committed state and the recorded service calls. The
bullet below applies only to **state-based** features (see `../../index.md`, Two modes).
- **Conformance** is the action surface of the feature's single
`conformance/conformance.test.ts` `Conformance.runFeature({...})` call. It pulls
actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ paths:

# services/main-service/conformance/ — keeping the ECS honest against the spec

**State-based features only.** This folder exists only when the feature has a
`data/state/` spec to conform to. An **ECS-based** feature (no `data/state/`) omits
this folder entirely and unit-tests its transactions/actions directly instead (see
`../../index.md`, Two modes, and `transactions.md` / `actions.md`).

Test-only (imported only by `*.test.ts`, in no facet barrel). The `data/state`
cases are the shared truth; the shared **`@adobe/data-testing`** runner replays
them against the ECS. This folder holds only the *feature-specific projection*
Expand Down Expand Up @@ -33,10 +38,13 @@ installing `@adobe/data` never pulls in a `vitest` peer dependency):
- **`Match`** — the tolerant, matcher-aware value comparison: `matches(actual,
expected, options?)` and its throwing wrapper `assert(...)`, plus the matchers
`Match.anyNumber` / `Match.anyString` / `Match.ref(label)`. Options are
`{ unordered?: ReadonlySet<string>; tolerance?: number }` — arrays compare **in
order** (default) unless a key is named in `unordered`, and numbers snap to
`tolerance` (default `0.01`) to absorb F32↔f64 / trig noise. Framework-agnostic:
it honors any asymmetric matcher, so vitest's `expect.any(...)` interops.
`{ tolerance?: number }` — numbers snap to `tolerance` (default `0.01`) to absorb
F32↔f64 / trig noise. **Ordering is carried by the value's type**: a
`ReadonlyArray` compares **in order**, a `ReadonlySet` / `ReadonlyMap`
**order-independently** — there is no `unordered` option. A numeric `id` a case
does not mention is **ignored** (the ECS allocates it), so entity content compares
without pinning ids. Framework-agnostic: it honors any asymmetric matcher, so
vitest's `expect.any(...)` interops.
- **`Conformance`** — the case types (`Case`, `Cases`, `DerivationCase`,
`DerivationCases`, `Effects`, `ServiceCall`), the `entity(specId)` identity
marker, the id `resolver(map)`, the whole-feature driver **`runFeature`**, the
Expand Down Expand Up @@ -92,7 +100,7 @@ Conformance.runFeature({
computedPlugin: ComputedDatabase.plugin, // omit if no state/ derivations
projection,
hydrate: ["visibleTodos"], // entity-id-list computeds; omit if none
match: { unordered: new Set(["bullets", "asteroids"]) }, // entity bags / tolerance; omit if none
match: { tolerance: 0.1 }, // float grid only; omit if 0.01 is fine
ops: { actions: import.meta.glob([...]) }, // ONLY when ops aren't in the plugin facet
});
```
Expand Down Expand Up @@ -136,8 +144,9 @@ Conformance.runFeature({
`data-lit-tictactoe` is the zero-config call (no `computedPlugin`, no `hydrate`,
no `match`, no `ops` — moves are board-index addressed, so no `entity()`
markers). `data-lit-todo` adds `hydrate: ["visibleTodos"]` and `entity()` markers.
`data-lit-space-rock-game` adds `match: { unordered: … }` for its entity bags
(its per-frame transitions are conformed by the systems tick loop, not here — see
`data-lit-space-rock-game` models its entity bags (`bullets`, `asteroids`) as
`ReadonlySet` on `State`, so they compare order-independently by type — no `match`
option (its per-frame transitions are conformed by the systems tick loop, not here — see
`systems.md`).

## The pure spec — `data/state/spec.test.ts`
Expand Down Expand Up @@ -189,24 +198,24 @@ op is infra, and a thin **same-named** op (todo's `reorderTodo` action, space-ro
`createInitial` transaction) gives the transition something to pair with. Do **not**
reintroduce a per-item adapter to bridge a name mismatch — add the same-named op.

## Ordering, tolerance, `ref` — all via `match`

`runFeature` threads `match?: MatchOptions` through to every comparison, so
per-feature tuning is data, not code:

- **Ordered by default.** `toState` reads a display-ordered collection in order —
that is what verifies a reorder — and positional tuples (a `Vec2`) must stay in
order.
- **Entity bags compare as multisets.** A collection the ECS materialises with no
display order (bullets, asteroids) is named in
`match: { unordered: new Set(["bullets", "asteroids"]) }` so just those fields
compare order-independently — never blanket-unordered (it would conflate
`[100,180]` with `[180,100]`).
- **Float noise** is absorbed by the default `tolerance` (`0.01`); raise it only
when a case genuinely needs a looser grid.
- **`Match.ref(label)`** on the expected side asserts id correspondence for a
referential feature (an id that must line up in two places); `Match.anyNumber` /
`anyString` leave an id a case doesn't pin fully open.
## Ordering, tolerance, `ref`

Ordering is carried by the value's **type**, not a match option — `ReadonlyArray`
positional, `ReadonlySet` / `ReadonlyMap` order-independent (the rule and its
rationale live in `../../../data-modelling.md`). What's specific to writing
conformance cases:

- **A numeric `id` is ignored unless a case pins it.** The ECS allocates entity ids
from its own space, so a case omits `id` and the entity's content still compares.
Pin it only to assert a reference (below).
- **Float noise** is absorbed by the default `tolerance` (`0.01`), threaded through
`match?: { tolerance }`; raise it only when a case needs a looser grid.
- **`Match.ref(label)`** on the expected side asserts id *correspondence* for a
referential feature — a reference that must line up with the entity it points at
(a `selectedId` → a specific todo). Put `ref("t")` on both the reference **and**
that entity's `id`; the labels form a bijection over the actual ids.
`Match.anyNumber` / `anyString` leave an id a case doesn't pin fully open. `ref`
correspondence holds even across a `ReadonlySet` boundary.

## Recording side effects — built in, no Proxy

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => {
result — read the touched slice, call the `data/` transform, write the diff.
- Keep transaction files **single-export** (the `transactions/` barrel is
`export *`-ed into the plugin facet, so a second export would pollute it).
- **ECS-based feature (no `data/state/`):** there are no conformance cases, so
**every transaction carries its own unit test** — `Store.create(plugin)`, run the
transaction, assert the resulting resources/entities/archetypes. That test is the
direct substitute for the conformance oracle (see `../../index.md`, Two modes). The
bullet below applies only to **state-based** features.
- **Conformance is wired once, centrally, and auto-paired** — not per-file. The
feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
call conforms transactions: it pulls them off **`plugin.transactions`** (the
Expand Down
Loading