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.10.19",
"version": "0.10.20",
"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.10.19",
"version": "0.10.20",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
499 changes: 161 additions & 338 deletions packages/data-ai/.claude/rules/features/data/state.md

Large diffs are not rendered by default.

26 changes: 14 additions & 12 deletions packages/data-ai/.claude/rules/features/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@ 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.**
the pure transforms with sibling `*.cases.ts`, the `transforms.ts` barrel, the `spec.ts`
manifest, `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
Expand Down Expand Up @@ -129,16 +130,17 @@ The tie between `data/` (spec) and `main-service` (implementation) is
**conformance**, one property —
`toState(apply(fromState(before), args)) ≡ transform(before, args)`: each
main-service mutation, seeded and read back through a test-only store↔`State`
projection, equals the pure `data/` transform it stands for. The per-feature
projection lives in `services/main-service/conformance/`, and a **single
`Conformance.runFeature({...})` call** replays the shared cases against the ecs —
pairing each ECS op to its same-named transition automatically and round-tripping
the projection (see `services/main-service/conformance.md`);
the shared `{ before, args, after }` cases are spec-owned — co-located in each
`data/state/<transform>.ts`, which exports its function plus `cases` — so
conforming the implementation is "substitute the implementation, reuse the
expectations." This lets `main-service` be largely mechanical and agent-generated,
with the spec as oracle. *How* to author each layer lives in the per-folder rules below.
projection, equals the pure `data/` transform it stands for. The spec-owned cases
are authored as inert `data/state/*.cases.ts` and gathered by the `data/state/spec.ts`
**manifest**, which both the pure `spec.test.ts` (`Conformance.checkSpec(spec)`) and the
ecs `services/main-service/conformance/conformance.test.ts`
(`Conformance.checkFeature(spec)`) import. `checkFeature` pairs each ECS op to its
same-named transition, replays the shared cases, and round-trips the projection (see
`services/main-service/conformance.md`); the manifest's compile-time guards keep the two
calls the whole conformance surface. So conforming the implementation is "substitute the
implementation, reuse the expectations" — `main-service` is largely mechanical and
agent-generated, with the spec as oracle. *How* to author each layer lives in the
per-folder rules below.

## Reference implementations

Expand Down
45 changes: 27 additions & 18 deletions packages/data-ai/.claude/rules/features/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,24 +61,33 @@ portability and lazy loading (`AsyncDataService.createLazy`).
- Members are async only: `void | Promise<T> | AsyncGenerator<T> | Observe<T>`.
- Provide `create*` factories.

## Deterministic test doubles, adjacent to the contract

A service is the seam consumers swap out under test — `data/` transitions that
take it as an injected dependency (`data/state.md`), actions, systems. Ship a
**deterministic test double** alongside the interface, in the same namespace
folder and under the same `global/namespace.md` standard: `create-fake.ts` is a
**single export**, `createFake`, re-exported through `public.ts` so callers reach
it as `MyService.createFake` (mirroring the `create` / `factory` pair).

Because tests assert on exact `after` values, the double must be **deterministic
and its responses caller-controlled**: `createFake` takes the exact response — the
fixed value, or the ordered sequence each method returns — as a parameter, with a
small inline default. A conformance case then **injects the responses it needs**
(`createFake(["random task"])`, `createFake([4])`) and authors its `after` /
`effects` against those values it supplied — nothing is read from a shared
published constant (that would be a second export, and it makes the assertion
guess at a value it doesn't own). The injected schedule is exactly what makes the
double a dependable oracle: the test controls both the input and the expectation.
## Test doubles are a test-tier `*.fake.ts` — never in the production barrel

A service is the seam consumers swap out under test. Ship a **shape-only recording
template** alongside the interface, in the same namespace folder: `<name>.fake.ts`, a
**single export** `createFake` that returns the service with each method present but
inert (void methods do nothing; value methods return a placeholder). It is **NOT**
re-exported through `public.ts` — so `MyService.createFake` is never reachable from
runtime code — and it imports the contract with `import type` only. The conformance
manifest (`data/state.md`) imports it directly; nothing else does.

```ts
// analytics-service/analytics.fake.ts — test tier, not on the namespace
import type { AnalyticsService } from "./analytics-service.js";
export const createFake = (): AnalyticsService => ({
serviceName: "analytics",
todoToggled: () => {}, // void method: inert
randomTodoRequested: () => Promise.resolve({ startedAt: 0 }), // value method: placeholder
// …one entry per method
});
```

The template supplies only the service's **shape** (the runner calls it once to
enumerate methods — no Proxy). It invents **no return values**: a conformance case
schedules each value-returning method's returns in its `responses` and asserts the
calls in its `effects` (`data/state.md`), so the case owns both the input and the
expectation. A `*.fake.ts` therefore takes no response parameter and hardcodes nothing
a case asserts.

## Where the I/O types live

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ there need not be a same-named transaction; transactions are the looser layer.
**Per-frame / system transitions are exempt.** In a real-time feature the `step*`
/ physics / collision transitions are realized by the **systems** tick loop, not
by an action, and are conformed by the tick-loop test (`systems.md`), not the
action surface of `runFeature`. Give an action only to transitions a user/UI invokes directly
action surface of `checkFeature`. Give an action only to transitions a user/UI invokes directly
(and skip it too when the realization needs more than one transaction — e.g. a
`newGame` that both sets bounds and resets is conformed via its transaction).

Expand Down Expand Up @@ -53,18 +53,18 @@ export const addRandomTodo = async (service: ServiceDatabase) => {
`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
`conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call. It pulls
actions off **`plugin.actions`** and pairs each to the **same-named** `data/state`
transition. **The action is the primary seam** — it reads injected services from
`db.services`, so the case's service args become recording overrides (the runner
builds `Database.toSystemDatabase(Database.create(plugin, { services }))`); it
splits the case `args` into services and plain input, runs the action, then
`Match.assert`s `toState ≡ after` **and** checks the recorded calls against the
case's `effects`. There is no `define`/`conforms` and no coverage guard. A thin
`db.services`, so the runner synthesizes each recording double from the manifest's
`services` templates + the case's `responses` and builds
`Database.toSystemDatabase(Database.create(plugin, { services }))`; it runs the action
with the case's data `args`, then `Match.assert`s `toState ≡ after` **and** checks the
recorded calls against the case's `effects`. No per-op wiring, no coverage guard. A thin
**same-named** action gives a transaction-only or renamed transition something to
pair with (todo's `reorderTodo`). A streaming/capability action with no transition
is skipped. **A per-transition action kept out of the facet** (to bound the
plugin's type) is discovered via `runFeature`'s `ops.actions` glob —
plugin's type) is supplied via the manifest's `ops.actions` glob —
`ops: { actions: import.meta.glob([".../actions/*.ts", "!.../actions/index.ts"], { eager: true }) }`
(p2p negotiation). That is the *only* reason to pass `ops` (see `conformance.md`).
(p2p negotiation). That is the *only* reason to set `ops` (see `conformance.md`).
- An `index.ts` barrel feeds the `actions` plugin facet.
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,11 @@ not a hot per-entity or large-N path. Reusing a tested derivation is good — th
only cost is that it takes the **whole `State`**, so the computed must observe the
full-state projection and re-runs on *any* field change (fine for a small feature,
wasteful on a large or hot one, where you hand-wire the minimal resource/index
reads instead). A derivation takes **no services**, so its co-located `cases` are
inert `{ input, value }` data (no test-doubles) that tree-shake out of the app
build (they reference the `@adobe/data-testing` matchers only, and that module is
`sideEffects: false`) — the one hazard is a `cases` literal touching the
`public.js` barrel at load, which `state.md` already forbids. **Performance is the
first-class constraint**: reuse freely where it doesn't matter, hand-wire minimal
reads where it does.
reads instead). A derivation takes **no services**, so its sibling `*.cases.ts` is
inert `{ input, value }` data (no test-doubles); like every `*.cases.ts` it is
test-tier and excluded from the runtime build by the project-reference wall, so it
never reaches the app bundle. **Performance is the first-class constraint**: reuse
freely where it doesn't matter, hand-wire minimal reads where it does.

```ts
import { cached } from "@adobe/data/cache";
Expand All @@ -53,10 +51,11 @@ only when the count changes, allocating no entity array. Prefer it over
`db.observe.select(...)` mapped through `.length`.

**Conform a computed to its `data/state` derivation** whenever one exists. The
derivation co-locates `{ input, value }` cases (`Derivation<typeof fn>`), and the
feature's single `conformance/conformance.test.ts` `Conformance.runFeature({...})`
call conforms computeds: it pulls them off **`computedPlugin.computed`**, pairs each
to its **same-named** derivation, seeds the store from `input`, reads the computed's
derivation's sibling `*.cases.ts` holds `{ input, value }` cases
(`Conformance.SpecDerivations<typeof fn>`), and the feature's single
`conformance/conformance.test.ts` `Conformance.checkFeature(spec)` call conforms
computeds: it pulls them off **`computedPlugin.computed`**, pairs each to its
**same-named** derivation, seeds the store from `input`, reads the computed's
synchronous emission, and `Match.assert`s it against `value` (see `conformance.md`).
There is no `define`/`conforms` wiring. **`computedPlugin` is the `ComputedDatabase`
layer** — the runner builds computed conformance from that layer, not the assembled
Expand Down
Loading
Loading