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.23",
"version": "0.10.24",
"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.23",
"version": "0.10.24",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
11 changes: 8 additions & 3 deletions packages/data-ai/.claude/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ features/
core.md indexes.md transactions.md computed.md services.md actions.md
systems.md conformance.md
ui/ # element / presentation / lazy-wrapper rules
data/state.md # LEGACY state-based layout (existing features only)
services/main-service/
index.md core-database.md components.md resources.md archetypes.md
indexes.md transactions.md computed.md services.md actions.md
systems.md conformance.md
app.md # src/app/: the composition root
```

Expand All @@ -34,9 +39,9 @@ etc.) apply everywhere, by design. Alongside `features/`, the remaining rules-ro
`.md` files hold **cross-cutting** patterns the feature rules reference —
`app.md`, `data-modelling.md`, `archetypes.md` (the query and iteration model),
`plugin-modelling.md`, `versioning.md`, and the UI rules `element.md` and
`lazy-element.md` (`features/ui/` holds `presentation.md` and the binding-element and
lazy-wrapper rules). They live here so the bundle is
self-contained; this repo symlinks them into its own `.claude/rules/`.
`lazy-element.md` (`features/ui/` holds `presentation.md` and the lazy-wrapper rule).
They live here so the bundle is self-contained; this repo symlinks them into its own
`.claude/rules/`.

Each feature rule's `paths:` glob is scoped to `**/features/*/<layer>/…`, so these
rules apply once an application organizes its source under `features/<name>/`.
Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude/rules/ecs.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ Synchronous, deterministic atomic mutations. Receive `store` and a payload.

### actions

UI components that call actions must never consume returned values — see `features/ui/binding-element.md` (Actions are fire-and-forget).
UI components that call actions must never consume returned values — see `element.md` (Actions are fire-and-forget).

**At most one transaction per action.** Multiple transactions in a single action corrupt the undo/redo stack.

Expand Down
9 changes: 9 additions & 0 deletions packages/data-ai/.claude/rules/element.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,15 @@ Actions are fire-and-forget. If a caller needs a result, that's a
transaction the binding invokes directly, *or* a new observable to
subscribe to — never a return value awaited in render.

## Presentation contract

- The element imports its sibling `*-presentation.ts`, which exports exactly `render`
(consumers derive props with `Parameters<typeof render>[0]`).
- Callback props are named as a verb or verbNoun (`toggleComplete`), never `on*`; the
element's variable matches the prop name.
- Cleanup on unmount goes through a hook (`useEffect(() => () => …, [])`), not a
lifecycle override.

## Deletion test

Strip the file to its essentials. What remains should be: optional
Expand Down
9 changes: 6 additions & 3 deletions packages/data-ai/.claude/rules/features/data/components.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const todo = True.schema;
export const components = { todo, name, complete, order, dragPosition };
```

- **Scope** is declared per file with `Scope` from `@adobe/data/ecs`:
- **Scope** is declared per file with `Scope` from `@adobe/data/schema`:

| scope | shared | durable | spread |
|---|:-:|:-:|---|
Expand All @@ -32,15 +32,18 @@ export const components = { todo, name, complete, order, dragPosition };
| presence | yes | no | `...Scope.presence` |
| session | no | no | `...Scope.session` |

`presence` is not synced yet: `@adobe/data-sync` does not replicate
`nonPersistent` data, so a presence value behaves like `session` until it does.
On a component, the flags scope the **column**. An entity's own scope comes from
its marker components (`entities.md`).
- **Every column lives here**, including implementation-only ones (a tag, a drag
offset). `ecs/core/` only chooses how they are packed.
- **A tag** is a component with `True.schema`. Add one only when an entity's
component set would otherwise be contained in another's (`entities.md`).
- **The name describes the role**, the schema the shape (`position` of a `Vec3`).
- **Reserved names:** `id`, `nonPersistent`, `nonShared` (built into the ECS), and
`index` (it would collide with the `index.ts` barrel).
- **Reserved names:** `id`, `nonPersistent`, `nonShared` are built into the ECS
(`Database.Plugin.create` rejects them at compile time); `index` would collide with
the `index.ts` barrel.
- **A feature built on another** spreads the lower barrel first and pins that its own
names don't shadow it (a spread would overwrite silently):
```ts
Expand Down
5 changes: 4 additions & 1 deletion packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ paths:

# data/ — the feature's pure Data declarations

> **Legacy feature** (has `data/state/` or `services/main-service/`)? Follow `data/state.md` instead.

The foundation layer. Everything here is a declaration about plain, serializable
Data: JSON primitives/arrays/objects plus `ReadonlySet`, `ReadonlyMap`, and `Blob`
(serialize with `Data.stringify` / `Data.parse`). No functions in values, no services,
Expand All @@ -26,4 +28,5 @@ fields, it takes them structurally
(`Iterable<{ readonly mark: PlayerMark; readonly cellIndex: CellIndex }>`).

Components and resources share **one namespace** across every feature in an app:
a name is one column or one resource, never both, and has one owning feature.
a name is one column or one resource, never both (the store warns on a clash, and the resource's schema replaces the component's), and
has one owning feature.
211 changes: 211 additions & 0 deletions packages/data-ai/.claude/rules/features/data/state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
---
paths:
- '**/features/*/data/state/**/*.ts'
---

# data/state/ — the State specification

> **Legacy state-based layout**, kept for existing features (`data/state/` + `services/main-service/`). New features use the layered layout in `../index.md`.

`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. The ECS implementation is proven equivalent to it by
conformance (`../services/main-service/conformance.md`). Reference:
`data-lit-todo`'s `data/state/`.

The presence of this folder makes the feature **state-based**. A feature without
`data/state/` is **ECS-based** (no spec, no conformance) — see `../index.md`.

## The `State` shape

```ts
// data/state/state.ts
export type State = {
readonly displayCompleted: boolean; // a SINGLETON → an ECS resource of the same name
readonly entities: ReadonlyMap<number, Todo>; // ALL entities, keyed by id; the value has no id
readonly selectedTodo: Entity; // a reference singleton (Entity is a branded number)
};
export * as State from "./public.js";
```

- **Singletons** — every non-`entities` field → an ECS **resource** of the same name.
- **`entities`** — one `ReadonlyMap<number, EntityValue>` holding every entity, keyed
by a numeric id; the value type is the union of the feature's entity value types and
carries **no `id`** (identity is the key). Omit `entities` for a feature with no
entities (all singletons).

`State` may reuse plain ECS value types (the `Entity` id type, a `{ parent, order }`
bundle) but never ECS machinery (a store, a transaction, an index). Entity value types
are their own `data/<type>/` namespaces with a structural `is` guard (`../index.md`).

Two conventional exports drive conformance, re-exported through `public.js`:
- **`create(): State`** (`create.ts`) — the default; every case's `before`/`input` is a
delta over it.
- **`samples: readonly State[]`** (`samples.ts`) — representative full states the ecs
projection round-trips (`toState ∘ fromState ≡ identity`).

## Files: the pure function and its cases are SEPARATE

Each conformed function is two files:

- **`<name>.ts`** — the pure function, a **single export**, with **zero test-tier
imports** (no `@adobe/data-testing`, no fakes). It depends only on `@adobe/data`,
other `data/` types, and — for a transition — the service interfaces it injects.
- **`<name>.cases.ts`** — the spec-owned cases as **inert data**, imported by the
manifest. It imports the case type with `import type` and never constructs a live
service.

This split is enforced by the package's project-reference wall (`tsconfig.build.json`
excludes `*.cases.ts`): a runtime file importing test-tier code is a **compile error**.

### The transform — `<name>.ts`

```ts
// toggle-complete.ts
import type { Services } from "../../services/services.js";
import type { State } from "./state.js";

// Reads entities, writes entities — an `{ entities }` patch — and logs `todoToggled`.
export const toggleComplete = (
state: Pick<State, "entities">,
{ id, analytics }: { readonly id: number } & Pick<Services, "analytics">,
): Pick<State, "entities"> => {
analytics.todoToggled();
const target = state.entities.get(id);
if (target === undefined) return { entities: state.entities };
return { entities: new Map(state.entities).set(id, { ...target, complete: !target.complete }) };
};
```

- **Transition signature** `(state: Pick<State, …reads>, args) => Pick<State, …writes>` —
a read→write patch. The parameter is the smallest `Pick<State,…>` it reads; the return
is only the fields it writes. No `<T> => T` generic, no `...state` spread — return the
patch and let the runner merge it. All non-state inputs go in the single `args` object;
a transition that takes neither args nor services takes only `(state)`.
- **Inject services with `Pick<Services, …>`** — never an inline `{ analytics: … }`. Data
args sit alongside via intersection: `{ readonly count: number } & Pick<Services, "a">`.
- **Guard no-ops by returning an empty/unchanged patch**, never by throwing. Reserve
`throw` for a genuine precondition violation a case names with `throws`.
- **Derivation signature** `(state) => value` — a pure selector over **two or more**
`State` fields. An entity query returns entity **ids** (`readonly number[]` /
`ReadonlySet<number>`); look values up in `entities`. Value math over a **single**
`data/<type>` lives on that type's namespace, not here.

### The cases — `<name>.cases.ts`

```ts
// toggle-complete.cases.ts
import { Entity } from "@adobe/data/ecs";
import type { Conformance } from "@adobe/data-testing";
import type { State } from "./state.js";
import type { toggleComplete } from "./toggle-complete.js";

export const cases: Conformance.SpecCases<State, typeof toggleComplete> = {
args: { type: "object", properties: { id: Entity.schema } }, // marks entity-reference args
cases: [
{ name: "marks an incomplete todo complete",
before: { entities: new Map([[1, { name: "a", complete: false, order: 0 }]]) },
args: { id: 1 }, // DATA only — services are never here
after: { entities: new Map([[1, { name: "a", complete: true, order: 0 }]]) },
effects: { analytics: [["todoToggled"]] } }, // service calls to assert
],
};
```

- Annotate `Conformance.SpecCases<State, typeof fn>` for a transition,
`Conformance.SpecDerivations<typeof fn>` for a derivation. That annotation is the
transition/derivation discriminator — nothing is inferred from the signature.
- **`before` is a delta over `State.create()`**; **`after` is the writes patch** (only
the fields the transition changes). A derivation case is `{ name, input, value }`.
- **`args` is data only.** Injected services are stripped from the case's `args` type;
the runner synthesizes each service's recording double from the manifest's `services`
templates. A transition with only services takes no `args`.
- **Service returns are scheduled as data in `responses`**, keyed by service then method;
only value-returning methods take a schedule (a per-call FIFO — exhausting it throws).
**Service calls to assert go in `effects`** as `[method, ...args]` tuples (`Array` =
ordered, `Set` = any order); a value-returning read you don't assert is simply omitted.
The case thus **owns both the input and the expectation** — the double invents nothing.
```ts
// create-random-todo.cases.ts (a value-returning service)
responses: { nameGenerator: { generateName: ["random task"] },
analytics: { randomTodoRequested: [{ startedAt: 0 }] } },
effects: { analytics: [["randomTodoRequested"],
["randomTodoAdded", { timing: { startedAt: 0 }, name: "random task" }]] },
```
- **Entity ids are plain spec-id numbers everywhere** — map keys in `before`/`after`/
`input`, and reference fields hold the id they point at (`{ selectedTodo: 2 }`). The
ecs mints its own ids, so conformance compares up to an id-bijection. An
entity-addressed transition adds the `args` schema (above) marking each `Entity.schema`
field; the case then writes the id plain (`args: { id: 2 }`). For a minted scalar a
case won't pin (a timestamp), use `Match.anyNumber` / `Match.anyString`.
- **A `throws` case** declares `throws: true` (any error) or `throws: "substring"`
instead of `after`/`effects`/`responses` — the two are mutually exclusive.

## `transforms.ts` — the conformed-function barrel

```ts
// transforms.ts — exactly the conformed transforms and derivations, nothing else
export { createTodo } from "./create-todo.js";
export { toggleComplete } from "./toggle-complete.js";
export { visibleTodos } from "./visible-todos.js";
// …
```

Its keys are the conformance surface. A genuine **non-transition helper** (`appendTodo`,
`create`, `samples`) is *not* listed here and carries no cases; it keeps its own
`*.test.ts`.

## `spec.ts` — the conformance manifest

The single object both `spec.test.ts` and the ecs `conformance.test.ts` import. It is a
**test-tier** file (excluded from the runtime build) — the one place the feature touches
`@adobe/data-testing`.

```ts
// spec.ts
import { Conformance } from "@adobe/data-testing";
import { State } from "./state.js";
import * as transforms from "./transforms.js";
import { MainService } from "../../services/main-service/main-service.js";
import { ComputedDatabase } from "../../services/main-service/computed-database/computed-database.js";
import { projection } from "../../services/main-service/conformance/projection.js";
import { createFake as analytics } from "../../services/analytics-service/analytics.fake.js";
import { cases as createTodo } from "./create-todo.cases.js";
import { cases as toggleComplete } from "./toggle-complete.cases.js";
import { cases as visibleTodos } from "./visible-todos.cases.js";
// …one import per conformed fn

export const spec = Conformance.feature({
state: State,
fns: transforms,
plugin: MainService.plugin,
computedPlugin: ComputedDatabase.plugin, // required iff the feature has a derivation
projection,
hydrate: ["visibleTodos"], // EXACTLY the entity-id-list derivations
services: { analytics, nameGenerator }, // required iff a transition injects a service
cases: { createTodo, toggleComplete, visibleTodos, /* …one per fn */ },
});
```

`Conformance.feature` enforces four guards at **compile time**:
- **`cases` coverage** — exactly one case module per `fns` entry (missing or extra → error).
- **`hydrate` exactness** — exactly the derivations that return a collection of the
projection's entity value (its ecs computed emits ids); miss one or add a non-hydrating
name → error.
- **`services` completeness** — exactly the injected-service keys the transitions declare,
each a `() => Service` template; absent for a service-free feature.
- **`computedPlugin` presence** — required when any case module is a derivation.

## `spec.test.ts` — the pure spec

```ts
// spec.test.ts
import { Conformance } from "@adobe/data-testing";
import { spec } from "./spec.js";
Conformance.checkSpec(spec);
```

One call verifies every transform and derivation against its cases (seeding `before`
over `State.create()`, synthesizing service doubles from `services` + `responses`,
asserting `after`/`value`/`effects`/`throws`). The ecs side is one matching call in
`conformance.test.ts` (`../services/main-service/conformance.md`).
18 changes: 12 additions & 6 deletions packages/data-ai/.claude/rules/features/ecs/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,16 @@ calls actions, so conforming actions covers the path the app runs.
- **Extra ECS tests** (collision selection, a refill after the field clears) live here
too, since they need the spec or the projection.

## Escape hatch: per-case ambient context
## Per-case ambient context

A feature whose action reads ambient per-peer context (a `userId` seeded under a
custom concurrency, as in p2p presence) can't use `checkFeature`. It drives
`Conformance.runActions` directly, feeding it
`Conformance.adaptCases(spec.fns, spec.cases, spec.services, true)` with its own
`makeDb` and `seedContext`. This is the only sanctioned use of the lower-level runners.
An action that reads context the spec can't carry (the acting peer's `userId`,
stamped by the db's concurrency at dispatch, as in p2p presence) still uses
`checkFeature`. Pass a test `concurrency` and a `seedContext(db, before, args)` that
primes it from each case:

```ts
Conformance.implementation(spec, { plugin, projection,
concurrency: peerConcurrency,
seedContext: (_db, _before, args) => { peerUserId = (args as { mark: string }).mark; },
});
```
4 changes: 4 additions & 0 deletions packages/data-ai/.claude/rules/features/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ src/

A feature creates only the layers and folders it uses.

A feature that has `data/state/` or `services/main-service/` uses the **legacy
state-based layout**: follow `data/state.md` and `services/main-service/` for it, and
don't migrate it unless asked.

## Dependencies

```
Expand Down
2 changes: 2 additions & 0 deletions packages/data-ai/.claude/rules/features/services/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ paths:

# services/ — the feature's services

> **Legacy feature** (has `data/state/` or `services/main-service/`)? Follow `services/main-service/` instead.

A service is the boundary between the feature and the outside world (generation,
analytics, networking). Each is a namespace folder (`global/namespace.md`) holding its
interface, its implementation and a fake. Services depend only on `data/`.
Expand Down
Loading
Loading