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.91",
"version": "0.9.92",
"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.91",
"version": "0.9.92",
"description": "Architecture skills for @adobe/data — data-oriented modelling, archetype iteration, hot-path performance, and related conventions.",
"author": {
"name": "Adobe"
Expand Down
7 changes: 4 additions & 3 deletions packages/data-ai/.claude/rules/data-modelling.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ discriminated unions.
## Example

```ts
// types/http-method/schema.ts
export const schema = { type: "string", enum: ["GET", "POST", "PUT", "DELETE"] }
as const satisfies Schema;
// types/http-method/http-method.ts — the hand-authored type owns the member identity
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
// (a schema.ts, if/when a runtime boundary needs one, is written to match this
// type and pinned to it — see global/namespace.md and features/data/index.md)

// In an unrelated request-counter plugin:

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 @@ -143,7 +143,7 @@ Synchronous, deterministic atomic mutations. Receive `store` and a payload.

### actions

UI components that call actions must never consume returned values — see `.claude/rules/binding-element.md` (Actions are fire-and-forget).
UI components that call actions must never consume returned values — see `features/ui/binding-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
2 changes: 1 addition & 1 deletion packages/data-ai/.claude/rules/element.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ exists *only* for locator inputs — never for state, never for derived
values, never for parent-forwarded flags. The `P` in `DatabaseElement<P>` (and
the `get plugin()` it returns) is the surface the element *consumes* — never the
varying topmost-layer name (see `features/services/main-service/index.md`): `typeof
FeatureDatabase.plugin` for a self-contained feature. **Exception:** an element
MainService.plugin` for a self-contained feature. **Exception:** an element
meant to be *extended* — injected with various databases that build on this
feature (a peer app that adds an agent, a p2p/presence build) — types on the
minimal base layer it consumes (e.g. `ComputedDatabase.plugin`) so every
Expand Down
57 changes: 48 additions & 9 deletions packages/data-ai/.claude/rules/features/data/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,62 @@ It depends on nothing but `@adobe/data` and other `data/` declarations, and
needs no knowledge of anything built on top of it.

Each data type is its own namespace folder (see `global/namespace.md`), holding
its schema, its derived type, and its pure synchronous helpers together:
its hand-authored type, its (optional, matching) schema, and its pure
synchronous helpers together:

```
data/player-mark/
player-mark.ts # type alias + `export * as PlayerMark from "./public.js"`
schema.ts # `export const schema = { … } as const satisfies Schema`
player-mark.ts # HAND-AUTHORED type + `export * as PlayerMark from "./public.js"`
schema.ts # (optional / later) schema matching the type, pinned via Assert<Equal<…>>
public.ts # re-exports schema + helpers
is.ts values.ts opponent.ts … # one pure helper per file
```

- The **type is derived from the schema** (`Schema.ToType<typeof schema>`),
never hand-written alongside it.
- The **schema is the single source of truth** for the shape; the derived
type and every consumer flow from it.
- The **type is authored by hand** in `<type>.ts` — the readable source of
truth. Write it with the richest types available (`F32`, `Vec3`, unions,
branded aliases) so members carry their semantic meaning and get strong
IntelliSense / hover docs.
- The **schema is derived from the type, not the reverse.** It is written to
match the type and **pinned** to it with a compile-time assertion so the two
cannot drift (see below). A field typed as a branded primitive reuses that
type's schema — `F32` → `F32.schema`, `Vec3` → `Vec3.schema`.
- **Schemas are optional until a runtime boundary needs one.** A pure `data/`
type is complete with `<type>.ts` alone. The schema is added only when
persistence, the wire, or an ECS component/resource requires it — and is
typically written by the agent in a later phase (e.g. when implementing the
ECS), not by the human up front.
- Helpers are synchronous and pure; each has a sibling `*.test.ts`.
- Objects with only numeric 32 bit values/sub-structs should use Schema.fromStructProperties
It guarantees a valid struct schema which will be stored in linear memory.
- Objects of only 32-bit numeric values / sub-structs: the schema uses
`Schema.fromStructProperties` (guarantees a valid struct schema, stored in
linear memory).

## Pinning the schema to the type

The type is written first and by hand; the schema is written to match it and
is locked to it at compile time, so schema authoring (whenever it happens)
cannot silently drift from the type readers rely on.

```ts
// player-mark/player-mark.ts — HAND-AUTHORED, the source of truth
export type PlayerMark = 'X' | 'O';
export * as PlayerMark from './public.js';

// player-mark/schema.ts — written to MATCH the type (often later, by the agent)
import { Schema } from '@adobe/data/schema';
import type { Assert, Equal } from '@adobe/data/types';
import type { PlayerMark } from './player-mark.js';

export const schema = { type: 'string', enum: ['X', 'O'] } as const satisfies Schema;

// Compile-time pin: fails to build if schema and type diverge.
type _Pin = Assert<Equal<Schema.ToType<typeof schema>, PlayerMark>>;
```

The `Assert<Equal<…>>` line is the whole safety net: `Schema.ToType<typeof
schema>` must be exactly the hand-authored type (including readonly/optional),
or the file does not compile. `Schema.ToType` is used **only** inside the
assertion — never as the exported type. Consumers always import the
hand-authored `PlayerMark`.

One folder is special: **`data/state/`** holds the feature's single `State`
aggregate and its pure transforms/derivations (its own rule, `state.md`);
Expand Down
17 changes: 10 additions & 7 deletions packages/data-ai/.claude/rules/features/data/state.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,16 @@ export * as State from "./public.js";
- 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. Keep its cases in a sibling `<transform>.cases.ts`
typed `ConformanceCase<Args>[]` (the shared `conformance-case.ts` type) —
spec-owned truth the matching main-service conformance test imports unchanged (see
`services/main-service/conformance.md`). 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`, alongside the cases 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`

Expand Down
2 changes: 1 addition & 1 deletion packages/data-ai/.claude/rules/features/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ main-service mutation, seeded and read back through a test-only store↔`State`
projection, equals the pure `data/` transform it stands for. The projection and
its runner live in `services/main-service/conformance/` (see
`services/main-service/conformance.md`); the shared `{ before, args, after }`
cases are spec-owned (`data/state/<transform>.cases.ts`), so conforming the
cases are spec-owned (exported from `data/state/<transform>.test.ts`), 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,19 @@ timing — and then commit the result through a transaction.
```ts
import type { ServiceDatabase } from "../../service-database/service-database.js";

export const addRandomTodo = async (db: ServiceDatabase) => {
const name = await db.services.nameGenerator.generateName(); // await a services/ port
db.transactions.createTodo({ name }); // then exactly one commit
export const addRandomTodo = async (service: ServiceDatabase) => {
const name = await service.services.nameGenerator.generateName(); // await a services/ port
service.transactions.createTodo({ name }); // then exactly one commit
};
```

- Type the `db` parameter on the lowest database layer exposing what the
- Type the `service` parameter on the lowest database layer exposing what the
action touches — usually `ServiceDatabase` (services **and**
transactions). Never the action layer itself (that would be a cycle).
- **Call at most one transaction** per action, so undo/redo stays one step
per operation.
- **Fire-and-forget.** An action's return value is not consumed; results flow
back through observables. `db.services.*` calls are `void` or
back through observables. `service.services.*` calls are `void` or
awaited-internally, never surfaced to the caller.
- Do the outside-world work here: await/sequence `services/` calls, and if a
slow call needs timing, compute it here around the call.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ paths:

One derived value per file: a `cached` function of a database layer that
returns an `Observe` of state projected through pure `data/` helpers.
Derivation logic itself lives in `data/`; a computed only wires a db
Derivation logic itself lives in `data/`; a computed only wires a service
observable to it.

```ts
Expand All @@ -16,8 +16,8 @@ import { Observe } from "@adobe/data/observe";
import { BoardState } from "../../../data/board-state/board-state.js";
import type { IndexDatabase } from "../../index-database/index-database.js";

export const status = cached((db: IndexDatabase) =>
Observe.withFilter(db.observe.resources.board, BoardState.deriveStatus),
export const status = cached((service: IndexDatabase) =>
Observe.withFilter(service.observe.resources.board, BoardState.deriveStatus),
);
```

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,9 @@ Conformance test-support splits by concern along the layer boundary:
`fromState(before)` → `apply(store, args)` → `toState ≡ after`.
- **Spec-side, in `data/state/`** (State values, no store — so both the `data/`
transform tests and this runner import them without a layer violation):
`conformance-case.ts` (`ConformanceCase<Args>`), the `<transform>.cases.ts`
cases, and `expect-state-matches.ts` (State equality).
`conformance-case.ts` (`ConformanceCase<Args>`), the cases **exported from each
`<transform>.test.ts`** (`import { cases } from "…/<transform>.test.js"` — no
separate `.cases.ts` file), and `expect-state-matches.ts` (State equality).

## No cast — build on `Store.create`, not a `Database`

Expand All @@ -39,7 +40,7 @@ A **transaction is `(store, args) => void`**, so transaction conformance needs n
`CoreDatabase.Store` — pass the plugin directly, `Store.create` reads its schema
facets. Source it from the **lowest layer that declares all the schema** —
`IndexDatabase`, or `CoreDatabase` if the feature has no indexes — **not**
`FeatureDatabase`: the store needs only schema, and the behaviour layers
`MainService`: the store needs only schema, and the behaviour layers
(transactions / computed / systems) add none.
`fromState`/`toState`/`apply` all operate on it, and `apply` calls the **raw
transaction function** directly:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,26 +119,29 @@ export namespace IndexDatabase {
}
```

### The assembled database — `FeatureDatabase`
### The assembled database — `MainService`

*Which* layer is topmost varies with the facets a feature uses (computed /
service / action / system). Consumers — `ui/`, the app entry, `services/main-service/conformance/`
— need the *whole* feature database but must not name that layer, or adding or
dropping a layer rewrites them all. Alias it once:
dropping a layer rewrites them all. Alias it once from the **folder-eponymous
`main-service.ts`** (per `global/namespace.md`, a folder's primary export lives in
its same-named file), as a namespace so consumers reach `MainService.plugin` /
`MainService.Store`:

```ts
// services/main-service/feature-database.ts
export { SystemDatabase as FeatureDatabase } from "./system-database/system-database.js";
// services/main-service/main-service.ts
export { SystemDatabase as MainService } from "./system-database/system-database.js";
```

Every consumer references **`FeatureDatabase`** (`.plugin`, `.Store`) — never the
Every consumer references **`MainService`** (`.plugin`, `.Store`) — never the
topmost layer. Add or drop a layer → change only this one line.

**Cross-feature naming.** Inside a feature the core and assembled databases are
the bare `CoreDatabase` / `FeatureDatabase`. When *another* feature or package
the bare `CoreDatabase` / `MainService`. When *another* feature or package
imports one — a peer built on it, the base `imports` a peer's `core-database`, a
downstream package — reference it **feature-qualified**: `<Feature>CoreDatabase`
/ `<Feature>Database`, re-exported so from the feature's barrel
/ `<Feature>MainService`, re-exported so from the feature's barrel
(`export { CoreDatabase as TodoCoreDatabase } from "…/core-database.js"`) — so
two features' `CoreDatabase`s never collide.

Expand All @@ -153,9 +156,10 @@ two features' `CoreDatabase`s never collide.
reads/writes entities, resources, or archetypes; **`IndexDatabase.Store`** the
moment it reads an index.
- **the whole `Database`** — **computed**, **services**, and **actions** each
take `db: <Layer>` (the lowest layer whose database exposes what they
read/call): computed reads `db.observe.*`; services read observables and call
transactions; actions call `db.services.*` then `db.transactions.*`.
take `service: <Layer>` (the lowest layer whose database exposes what they
read/call): computed reads `service.observe.*`; services read observables and
call transactions; actions call `service.services.*` then
`service.transactions.*`.

Each subfolder has its own rule. Modelling a plugin's authored vs. derived
surface is covered by `plugin-modelling.md`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ paths:

# database/services/ — database-bound service factories

One factory per file: `create<Name>Service(db, …)` that binds a service
One factory per file: `create<Name>Service(service, …)` that binds a service
implementation to a live database — reading its observables and invoking
its transactions. This is where the async `services/` contracts (or
framework services like `AgenticService`) are wired to ECS state.

```ts
export const createAgentService = (db: ComputedDatabase, mark: PlayerMark): AgenticService =>
/* … reads db.observe.*, calls db.transactions.* … */;
export const createAgentService = (service: ComputedDatabase, mark: PlayerMark): AgenticService =>
/* … reads service.observe.*, calls service.transactions.* … */;
```

Type `db` on the lowest layer exposing what the factory reads/calls (never the
`service-database` itself — that would be a cycle).
Type `service` on the lowest layer exposing what the factory reads/calls (never
the `service-database` itself — that would be a cycle).

An `index.ts` barrel re-exports the factories; `service-database.ts`
registers them under the `services` facet, each keyed by the name
consumers read it as (`db.services.agent`). Defining a standalone service
contract (not yet bound to a db) belongs in the feature `services/` layer.
consumers read it as (`service.services.agent`). Defining a standalone
service contract (not yet bound to a database) belongs in the feature
`services/` layer.
8 changes: 4 additions & 4 deletions packages/data-ai/.claude/rules/features/ui/binding-element.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ A binding element is a thin wire between a service and a presentation. The class

No other members. No `@state`. No private handler fields. No lifecycle methods (`connectedCallback`, `updated`, `firstUpdated`,
`disconnectedCallback`) except on app-entrypoint classes where the lifecycle concern is app-boot shaped but cannot be expressed as a hook
(see `.claude/rules/hooks.md` for hook-first lifecycle).
(see `hooks.md` for hook-first lifecycle).

## `@property` allowlist

Expand Down Expand Up @@ -114,7 +114,7 @@ one-liners** above.
## Lifecycle goes through hooks

Any need for mount/unmount/update behavior routes through a hook. **Reuse order, one hook per file, presentation vs binding placement, and
testing notes:** `.claude/rules/hooks.md`.
testing notes:** `hooks.md`.

Never inline lifecycle logic in the binding. App-entrypoint classes may override lifecycle methods when the concern is app-boot shaped
(service wiring, ancestor lookup); even then, prefer a hook.
Expand Down Expand Up @@ -158,14 +158,14 @@ expose them by design; reaching for them means a missing computed.

A binding element imports a sibling `*-presentation.ts` file that exports exactly `render` (required) and optionally `unlocalized`. Nothing
else — no types, no styles, no helpers. Consumers derive the props type via `Parameters<typeof render>[0]`. Presentation stays hook-free by
default; element hooks live here or in shared modules per `.claude/rules/hooks.md` and `.claude/rules/presentation.md`.
default; element hooks live here or in shared modules per `hooks.md` and `features/ui/presentation.md`.

**For refactors whose stated scope does not include presentations**, the matching `*-presentation.ts` file must not change. Verify with
`git diff --name-only | grep presentation`. If a scope-limited refactor requires a presentation signature change, stop and reconsider the
binding-side helper — the scope fence exists because presentations are the public contract between binding and render output.

Callback prop names use **verb** or **verbNoun** form. Never `on*` prefix. The binding-side variable matches the presentation prop name 1:1.
See `.claude/rules/presentation.md` for full examples.
See `features/ui/presentation.md` for full examples.

## Size budget

Expand Down
Loading