@adobe/data-ai 0.10.21 → 0.10.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/.claude/rules/README.md +26 -31
  2. package/.claude/rules/app.md +46 -0
  3. package/.claude/rules/archetypes.md +5 -3
  4. package/.claude/rules/data-modelling.md +8 -7
  5. package/.claude/rules/ecs.md +11 -7
  6. package/.claude/rules/element.md +6 -5
  7. package/.claude/rules/features/data/components.md +51 -0
  8. package/.claude/rules/features/data/entities.md +37 -0
  9. package/.claude/rules/features/data/index.md +18 -70
  10. package/.claude/rules/features/data/resources.md +34 -0
  11. package/.claude/rules/features/data/values.md +36 -0
  12. package/.claude/rules/features/ecs/actions.md +31 -0
  13. package/.claude/rules/features/ecs/computed.md +31 -0
  14. package/.claude/rules/features/ecs/conformance.md +85 -0
  15. package/.claude/rules/features/ecs/core.md +43 -0
  16. package/.claude/rules/features/ecs/index.md +72 -0
  17. package/.claude/rules/features/{services/main-service → ecs}/indexes.md +6 -7
  18. package/.claude/rules/features/ecs/services.md +36 -0
  19. package/.claude/rules/features/{services/main-service → ecs}/systems.md +61 -47
  20. package/.claude/rules/features/ecs/transactions.md +32 -0
  21. package/.claude/rules/features/index.md +104 -150
  22. package/.claude/rules/features/services/index.md +52 -84
  23. package/.claude/rules/features/spec/index.md +161 -0
  24. package/.claude/rules/features/ui/binding-element.md +5 -5
  25. package/.claude/rules/features/ui/index.md +8 -3
  26. package/.claude/rules/features/ui/lazy-wrapper.md +2 -2
  27. package/.claude/rules/features/ui/presentation-test.md +1 -0
  28. package/.claude/rules/features/ui/presentation.md +1 -0
  29. package/.claude/rules/global/function-references.md +2 -2
  30. package/.claude/rules/global/namespace.md +3 -3
  31. package/.claude/rules/global/react.md +4 -4
  32. package/.claude/rules/lit.md +2 -2
  33. package/.claude/rules/plugin-modelling.md +2 -0
  34. package/.claude/rules/service.md +2 -0
  35. package/.claude/rules/versioning.md +8 -6
  36. package/.claude-plugin/plugin.json +1 -1
  37. package/README.md +8 -0
  38. package/bin/cli.mjs +24 -8
  39. package/package.json +3 -3
  40. package/skills/build-actions/SKILL.md +7 -8
  41. package/skills/build-app-entry/SKILL.md +12 -8
  42. package/skills/build-computed/SKILL.md +8 -7
  43. package/skills/build-core-database/SKILL.md +18 -15
  44. package/skills/build-data/SKILL.md +14 -13
  45. package/skills/build-feature/SKILL.md +2 -1
  46. package/skills/build-game/SKILL.md +6 -4
  47. package/skills/build-indexes/SKILL.md +6 -7
  48. package/skills/build-service-database/SKILL.md +8 -8
  49. package/skills/build-services/SKILL.md +9 -7
  50. package/skills/build-spec/SKILL.md +23 -0
  51. package/skills/build-systems/SKILL.md +12 -18
  52. package/skills/build-transactions/SKILL.md +8 -8
  53. package/skills/build-ui/SKILL.md +5 -4
  54. package/skills/review/SKILL.md +3 -3
  55. package/.claude/rules/features/data/state.md +0 -209
  56. package/.claude/rules/features/services/main-service/actions.md +0 -70
  57. package/.claude/rules/features/services/main-service/archetypes.md +0 -49
  58. package/.claude/rules/features/services/main-service/components.md +0 -41
  59. package/.claude/rules/features/services/main-service/computed.md +0 -82
  60. package/.claude/rules/features/services/main-service/conformance.md +0 -95
  61. package/.claude/rules/features/services/main-service/core-database.md +0 -48
  62. package/.claude/rules/features/services/main-service/index.md +0 -187
  63. package/.claude/rules/features/services/main-service/resources.md +0 -28
  64. package/.claude/rules/features/services/main-service/services.md +0 -25
  65. package/.claude/rules/features/services/main-service/transactions.md +0 -59
@@ -0,0 +1,85 @@
1
+ ---
2
+ paths:
3
+ - '**/features/*/ecs/conformance/**/*.ts'
4
+ ---
5
+
6
+ # ecs/conformance/ — proving the ECS matches the spec
7
+
8
+ Test tier. The spec's cases are replayed against the ECS: every spec action against
9
+ the `MainService` action of the same name, and every derivation against its
10
+ computed. The folder holds:
11
+
12
+ ```
13
+ conformance/
14
+ projection.ts the store ⇄ State mapping
15
+ implementation.ts the spec paired with its ECS build
16
+ frame.cases.ts whole-frame cases (real-time features)
17
+ conformance.test.ts Conformance.checkFeature(implementation)
18
+ ```
19
+
20
+ ## `projection.ts`
21
+
22
+ ```ts
23
+ export const projection = { fromState, toState, toData };
24
+ ```
25
+
26
+ - `fromState(store, state)` clears the store, inserts each entity (supplying tags and
27
+ implementation-only columns), sets the resources, and returns the
28
+ `spec id → entity` map it built. The runner resolves case ids through it.
29
+ - `toState(store)` reads the whole store back as a `State`.
30
+ - `toData(store, entity)` reads one entity as its value. It is needed only when a
31
+ computed emits entity ids (`hydrate`).
32
+
33
+ ## `implementation.ts`
34
+
35
+ ```ts
36
+ export const implementation = Conformance.implementation(spec, {
37
+ plugin: MainService.plugin,
38
+ computedPlugin: ComputedDatabase.plugin, // required when the spec has derivations
39
+ projection,
40
+ hydrate: ["visibleTodos"], // exactly the computeds that emit entity ids
41
+ services: { game: GameService.createFake }, // base fakes for app-injected services
42
+ frame: { args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; } },
43
+ unmodelled: { physics: "wasm rigid-body step" },
44
+ cases: frameCases }, // real-time features
45
+ });
46
+ ```
47
+
48
+ - **`computedPlugin`** is the `ComputedDatabase` layer, built on its own, so a
49
+ `withCache` value from a layer above can't go stale across the seed.
50
+ - **`services`** are fakes injected into every database the runner builds. They are
51
+ needed for a service whose `create` throws. Each case's recording doubles override
52
+ them.
53
+ - **`frame`** is required when the spec has `systems`. `args` writes each frame data
54
+ arg into the store; `unmodelled` names, with a reason, each system the spec doesn't
55
+ model; `cases` are whole-frame cases (`frame.cases.ts` here, typed
56
+ `Conformance.FrameCases<State, typeof systems>`). Each spec system conforms by
57
+ running just that system; each frame case runs every modelled system once in
58
+ `db.system.order` (`systems.md`). Systems keep their in-place column writes.
59
+ - `hydrate` exactness and `computedPlugin` requiredness are checked at compile time.
60
+
61
+ ## `conformance.test.ts`
62
+
63
+ ```ts
64
+ Conformance.checkFeature(implementation);
65
+ ```
66
+
67
+ This single call conforms every spec action and derivation and round-trips every
68
+ `State.samples` entry through the projection. A spec op with no same-named action or
69
+ computed fails by name, as does an ECS system that is neither modelled nor unmodelled. So when the UI's real op is richer (`dragTodo`),
70
+ add a thin same-named action (`reorderTodo`) for the spec op to pair with. The UI
71
+ calls actions, so conforming actions covers the path the app runs.
72
+
73
+ - **Comparison.** `ReadonlyArray` is positional; `ReadonlySet` and `ReadonlyMap` are
74
+ order-independent. Ids compare up to a bijection. Floats use
75
+ `match: { tolerance }`, set on the spec (default `0.01`).
76
+ - **Extra ECS tests** (collision selection, a refill after the field clears) live here
77
+ too, since they need the spec or the projection.
78
+
79
+ ## Escape hatch: per-case ambient context
80
+
81
+ A feature whose action reads ambient per-peer context (a `userId` seeded under a
82
+ custom concurrency, as in p2p presence) can't use `checkFeature`. It drives
83
+ `Conformance.runActions` directly, feeding it
84
+ `Conformance.adaptCases(spec.fns, spec.cases, spec.services, true)` with its own
85
+ `makeDb` and `seedContext`. This is the only sanctioned use of the lower-level runners.
@@ -0,0 +1,43 @@
1
+ ---
2
+ paths:
3
+ - '**/features/*/ecs/core/**/*.ts'
4
+ ---
5
+
6
+ # ecs/core/ — the schema
7
+
8
+ The core database builds the feature's `data/` components and resources into a plugin
9
+ and chooses how entities are packed into archetypes.
10
+
11
+ ```ts
12
+ // core/archetypes.ts — the spec entity plus implementation-only columns
13
+ export const archetypes = Database.archetypes(components, {
14
+ Todo: ["todo", ...Todo],
15
+ });
16
+
17
+ // core/core-database.ts
18
+ const coreDatabasePlugin = Database.Plugin.create({ components, resources, archetypes });
19
+
20
+ export type CoreDatabase = Database.Plugin.ToDatabase<typeof coreDatabasePlugin>;
21
+ type CoreComponents = Store.Components<Database.Plugin.ToStore<typeof coreDatabasePlugin>>;
22
+
23
+ export namespace CoreDatabase {
24
+ export const plugin = coreDatabasePlugin;
25
+ export type Store = Database.Plugin.ToStore<typeof coreDatabasePlugin>;
26
+ export type Index = Database.Index<CoreComponents>;
27
+ }
28
+ ```
29
+
30
+ - **Import `components` and `resources` from `data/`.** Never author schemas here.
31
+ - **Spread the entity tuple** from `data/entities/` and add implementation-only
32
+ columns (a tag). `UpperCase` archetype names.
33
+ - **Never pack a `nonPersistent` column into a persistent entity's archetype** unless
34
+ it has a real, non-null default. On load such a column is stripped, and every
35
+ reloaded entity then drops out of that archetype and out of every query on it. Add
36
+ the column on demand with `t.update` instead. A persisting feature keeps a save/load
37
+ round-trip test (`data-lit-todo`'s `app/persistence.test.ts`).
38
+ - **A feature built on another** `extends` the lower feature's core plugin and adds its
39
+ own `components` barrel (which already spreads the lower one) and archetypes.
40
+ - **`Component` / `Archetype` key types**, when needed, derive from the `components`
41
+ barrel (`Extract<keyof typeof components, string>`), never from the plugin: the
42
+ plugin is built from `archetypes`, so a plugin-derived key type is circular when used
43
+ to check an archetype.
@@ -0,0 +1,72 @@
1
+ ---
2
+ paths:
3
+ - '**/features/*/ecs/main-service.ts'
4
+ - '**/features/*/ecs/*/*-database.ts'
5
+ ---
6
+
7
+ # ecs/ — the implementation
8
+
9
+ The feature implemented as a reactive Entity-Component-System, conformed to
10
+ `spec/`. It is a chain of `Database.Plugin` layers, each extending the one before and
11
+ adding one facet:
12
+
13
+ ```
14
+ ecs/
15
+ core/ core-database.ts, archetypes.ts the schema (core.md)
16
+ indexes/ index-database.ts + one file per index + index.ts
17
+ transactions/ transaction-database.ts + one file per transaction + index.ts
18
+ services/ service-database.ts + database-bound service factories
19
+ computed/ computed-database.ts + one file per computed + index.ts
20
+ actions/ action-database.ts + one file per action + index.ts
21
+ systems/ system-database.ts (systems declared inline)
22
+ main-service.ts
23
+ conformance/ test tier (conformance.md)
24
+ ```
25
+
26
+ The order is fixed: `core → indexes → transactions → services → computed → actions
27
+ → systems`. Services sit below computed because computeds may derive from service
28
+ observables; a service factory therefore never reads a computed. A feature creates
29
+ only the layers it uses. Each layer's file exports a namespace mirroring its plugin:
30
+
31
+ ```ts
32
+ const indexDatabasePlugin = Database.Plugin.create({ extends: CoreDatabase.plugin, indexes });
33
+ export type IndexDatabase = Database.Plugin.ToDatabase<typeof indexDatabasePlugin>;
34
+ export namespace IndexDatabase {
35
+ export const plugin = indexDatabasePlugin;
36
+ export type Store = Database.Plugin.ToStore<typeof indexDatabasePlugin>;
37
+ }
38
+ ```
39
+
40
+ Runtime code here imports `data/` and `services/`, never `spec/`.
41
+
42
+ ## `MainService` — the assembled database
43
+
44
+ `main-service.ts` aliases the top layer, so consumers never name it:
45
+
46
+ ```ts
47
+ export { ActionDatabase as MainService } from "./actions/action-database.js";
48
+ ```
49
+
50
+ `ui/`, `app/` and `conformance/` reference `MainService.plugin` / `MainService.Store`.
51
+ Adding or dropping a layer changes this one line. When another feature or package
52
+ imports a feature's databases, it aliases them feature-qualified
53
+ (`import { CoreDatabase as MainCoreDatabase }`).
54
+
55
+ ## What each op takes
56
+
57
+ - **Transactions** take the store: `CoreDatabase.Store`, or `IndexDatabase.Store` once
58
+ they read an index. A store is the transaction context (it carries index handles and
59
+ `t.userId`).
60
+ - **Computed, service factories and actions** take the whole database, typed on the
61
+ lowest layer that exposes what they read or call — never their own layer.
62
+
63
+ ## Folders are flat until they aren't
64
+
65
+ A behavioural folder holds one file per op plus an `index.ts` barrel that feeds the
66
+ facet. When a cohesive cluster grows past about half a dozen files, give it a
67
+ subfolder with its own `index.ts` (e.g. `transactions/order/`). A helper several ops
68
+ share lives beside them but stays **out of the barrel**, or it would be registered
69
+ as an op.
70
+
71
+ The per-layer rules are in this folder. How a plugin's authored and derived surfaces
72
+ are modelled is in the rules-root `plugin-modelling.md`.
@@ -1,24 +1,23 @@
1
1
  ---
2
2
  paths:
3
- - '**/features/*/services/main-service/index-database/indexes/**/*.ts'
3
+ - '**/features/*/ecs/indexes/**/*.ts'
4
4
  ---
5
5
 
6
- # database/indexes/ — ECS indexes
6
+ # ecs/indexes/ — ECS indexes
7
7
 
8
8
  One index descriptor per file: an object `satisfies CoreDatabase.Index` that
9
9
  tells the store to maintain a lookup keyed on a component, so queries and
10
10
  computed values can find entities without scanning.
11
11
 
12
12
  ```ts
13
- import type { CoreDatabase } from "../../core-database/core-database.js";
13
+ import type { CoreDatabase } from "../core/core-database.js";
14
14
 
15
15
  export const byComplete = {
16
16
  key: "complete",
17
17
  } as const satisfies CoreDatabase.Index;
18
18
  ```
19
19
 
20
- `CoreDatabase.Index` is bound to every component (all scopes live in the one
21
- `core-database`). Archetype-scoped indexes carry an extra `archetype` field the
20
+ `CoreDatabase.Index` is bound to every component in the core database. Archetype-scoped indexes carry an extra `archetype` field the
22
21
  `Index` helper can't express, so declare those with a bare `as const` (no
23
22
  `satisfies`) and let `index-database.ts`'s `create()` validate them.
24
23
 
@@ -37,7 +36,7 @@ Derive a discrete **bucket key** — but prefer a **computed-key extractor**
37
36
  recomputes the key from its source columns, so the index stays correct with no
38
37
  maintenance; a stored bucket column must instead be recomputed at every
39
38
  insertion/update site (or by a system) and silently drifts if one is missed. The
40
- derivation itself is a pure `data/` helper.
39
+ derivation itself is a pure `data/values/` helper.
41
40
 
42
41
  Bucketed broad-phase only pays off at scale (hundreds+ candidates per query);
43
42
  below that a linear scan over an archetype wins. Index when the scan is a
@@ -58,6 +57,6 @@ below that a linear scan over an archetype wins. Index when the scan is a
58
57
  multi-value index — one bucket entry per element — and `find({ col: element })`
59
58
  takes the element, not the array.
60
59
  - **Model a many-to-many as a denormalized array + two indexes.** A
61
- `todo.assignees: string[]` (owned/displayed by one feature) plus a unique
60
+ `todo.assignees: string[]` (owned by one feature) plus a unique
62
61
  `usersByName` (name → user) and a multi-value `todosByAssignee` (assignee →
63
62
  todos) gives efficient navigation in both directions from one join key.
@@ -0,0 +1,36 @@
1
+ ---
2
+ paths:
3
+ - '**/features/*/ecs/services/**/*.ts'
4
+ ---
5
+
6
+ # ecs/services/ — registering services on the database
7
+
8
+ `service-database.ts` registers the feature's services under the `services` facet,
9
+ keyed as the spec's `Services` map, and pins the two together:
10
+
11
+ ```ts
12
+ const serviceDatabasePlugin = Database.Plugin.create({
13
+ extends: TransactionDatabase.plugin,
14
+ services: {
15
+ nameGenerator: NameGeneratorService.create,
16
+ analytics: AnalyticsService.create,
17
+ },
18
+ });
19
+ export type ServiceDatabase = Database.Plugin.ToDatabase<typeof serviceDatabasePlugin>;
20
+ type _ServicesPin = Assert<Equal<ServiceDatabase["services"], Services>>;
21
+ ```
22
+
23
+ - **A `services/` service** registers its `create`. If `create` throws (the service
24
+ needs runtime input), the app injects the real one (`../../app.md`), and conformance
25
+ injects a fake (`conformance.md`).
26
+ - **A database-bound service** (it reads observables or calls transactions, such as
27
+ an agent) is a factory here, one per file: `createAgentService(db, …)`, with `db`
28
+ typed on the lowest layer it needs. It sits below computed, so it derives what it
29
+ observes from the store (`db.derive`) and `data/values/` helpers, sharing a read
30
+ helper with the transactions where useful (tictactoe's `readBoard`). The `index.ts`
31
+ barrel re-exports the factories.
32
+ - **A service built from another service** needs that service registered first, so
33
+ register it in a second `Plugin.create` extending the first: a factory only sees
34
+ the services of the plugins it extends.
35
+ - Register each service once, in the feature that owns it, under one key. Combined
36
+ plugins require the same factory per key.
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  paths:
3
- - '**/features/*/services/main-service/system-database/**/*.ts'
3
+ - '**/features/*/ecs/systems/**/*.ts'
4
4
  ---
5
5
 
6
- # database/systems/ — the real-time tick loop
6
+ # ecs/systems/ — the real-time tick loop
7
7
 
8
8
  Systems are the framework's real-time backbone: functions the scheduler runs
9
9
  **every frame**, in dependency order, to advance a simulation. A feature only
@@ -21,19 +21,19 @@ arrow must be written inline in `Plugin.create` for two reasons: only there is `
21
21
  strongly typed (to the assembled plugin's database, with a **writable `store`**), and
22
22
  a pre-typed standalone `create` reference breaks the scheduler's system-name inference
23
23
  (the name union collapses to `never`, which then rejects every `schedule` entry). Keep
24
- each `create` thin by delegating the real per-frame work to a pure `data/` step
25
- function; if an ecs-specific body is substantial, extract it to a `systems/<name>.ts`
26
- helper `(db) => () => void` (annotate its `db`) and call it from the inline `create`.
24
+ each `create` thin by delegating math to pure `data/values/` helpers (never `spec/`);
25
+ if an ecs-specific body is substantial, extract it to a `systems/<name>.ts` helper
26
+ `(db) => () => void` (annotate its `db`) and call it from the inline `create`.
27
27
 
28
28
  ```ts
29
29
  // in system-database.ts
30
30
  import { Database, scheduler } from "@adobe/data/ecs";
31
- import { Motion } from "../../data/motion/motion.js";
31
+ import { Motion } from "../../data/values/motion/motion.js";
32
32
 
33
33
  const plugin = Database.Plugin.create({
34
- // combine scheduler with the CURRENT TOP layer — ComputedDatabase here, but
35
- // ActionDatabase / ServiceDatabase if the feature built those (systems come last).
36
- extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler),
34
+ // combine scheduler with the current top layer — ActionDatabase, since every
35
+ // spec action has an action (systems come last).
36
+ extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler),
37
37
  systems: {
38
38
  control: { create: (db) => () => { /* input → ship; db.transactions.fireBullet() */ } },
39
39
  movement: {
@@ -59,18 +59,15 @@ const plugin = Database.Plugin.create({
59
59
  `db.store.update / insert / delete` for lifecycle (spawning, dying) that changes
60
60
  archetype membership. Resources: `db.store.resources.x = …`.
61
61
  - **Dispatch a transaction for a discrete atomic event** — when the effect is a
62
- self-contained event that already has a conformant transaction (score a hit,
63
- lose a life, spawn a wave, fire a bullet), call `db.transactions.*` instead of
64
- re-implementing it: you reuse the `data/`-verified logic and observers (a reactive
65
- HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths
62
+ self-contained event (score a hit, lose a life, spawn a wave, fire a bullet), call
63
+ `db.transactions.*`: observers (a reactive HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths
66
64
  (movement, aging) that run on every entity every frame and shouldn't pay
67
65
  per-entity transaction overhead.
68
66
  - **Optimize hot paths for speed.** With many entities, query with efficient
69
67
  `queryArchetypes` and read/write minimal columns directly; avoid intermediate
70
- allocations. On a hot path treat `data/` step functions as **spec only** — use
71
- them when the path isn't hot or you can't beat them by writing the store
72
- directly; off hot paths, reuse them freely. (Only transactions are observable —
73
- direct store writes fire no observers.)
68
+ allocations. Never turn per-row work into per-entity transactions to make it
69
+ conformable: `frame` conformance checks in-place writes as they are. (Only
70
+ transactions are observable — direct store writes fire no observers.)
74
71
  - **Iterate archetypes per `archetypes.md`.** Express selection with
75
72
  `queryArchetypes(include, { exclude })`; when a system destroys/migrates rows
76
73
  (bullets expiring, entities dying) iterate **tail → head** so hole-fills don't
@@ -79,25 +76,22 @@ const plugin = Database.Plugin.create({
79
76
  a transaction that inserts/deletes invalidates any live archetype cursor, so
80
77
  snapshot the ids you'll act on (or reverse-iterate) *before* dispatching
81
78
  transactions inside a per-frame loop.
82
- - **Replicate the oracle's top-level guard in every system.** If the `data/`
83
- `step` opens with `if (isGameOver(state)) return state`, each split system must
84
- early-return on the same condition — a frozen frame is only a true no-op if
85
- *every* system honors the guard.
79
+ - **Every system guards itself** (e.g. early-return once the game is over), as its
80
+ spec function does — a frozen frame is only a true no-op if *every* system honors
81
+ the guard.
86
82
  - **Index reads go through `db.indexes`** (plural) — the writable `db.store` a
87
83
  system receives is typed *without* its index handles, so a broad-phase query is
88
84
  `db.indexes.byCell.find({ cell })`, not `db.store.indexes.…`.
89
85
  - **Ordering is declared under `schedule`, not implied.** `schedule.before` /
90
86
  `schedule.after` are hard constraints (name sibling systems); `schedule.during`
91
87
  is a soft same-tier hint. Systems with no ordering relation share a tier
92
- (conceptually parallel) — never rely on declaration order. When you split a
93
- monolithic `data/` `step` into several ordered systems, the schedule **must
94
- mirror the step's internal sequence** (advance → fire → age → collide → refill)
95
- — otherwise the tick diverges from the oracle even though each system is
96
- individually correct. Watch the subtle case: if a spawn step (fire) is followed
97
- by a step that also processes the just-spawned entity (the new bullet is aged
98
- and advanced this same tick), you can't advance *all* bodies in one system
99
- before the spawn — split that entity's advance out so it runs *after* the spawn,
100
- alongside the step that owns it.
88
+ (conceptually parallel) — never rely on declaration order; systems sharing a tier
89
+ must commute (conformance checks it). The schedule is the only statement of order:
90
+ the spec never re-sequences systems, and frame conformance composes them in the
91
+ order the schedule derives. Watch the subtle case: if a spawn (fire) is followed by
92
+ a step that also processes the just-spawned entity (the new bullet is aged and
93
+ advanced this same tick), you can't advance *all* bodies in one system before the
94
+ spawn — split that entity's advance out so it runs *after* the spawn.
101
95
  - Immediate-mode rendering: store the canvas in a session resource and render to
102
96
  it inside a system (see @adobe/data-gpu for patterns).
103
97
 
@@ -109,32 +103,52 @@ Systems don't run themselves. Combine the built-in scheduler so they run on
109
103
  ```ts
110
104
  import { Database, scheduler } from "@adobe/data/ecs";
111
105
  // in system-database.ts:
112
- Database.Plugin.create({ extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), systems });
106
+ Database.Plugin.create({ extends: Database.Plugin.combine(ActionDatabase.plugin, scheduler), systems });
113
107
  ```
114
108
 
115
109
  The scheduler adds a `schedulerState` resource (`"running" | "paused" |
116
110
  "disposed"`) — gate start/pause/resume through it (start `"paused"` if the game
117
- begins on a user action). A headless host (tests, server sim) omits the
118
- scheduler and drives frames itself by calling `db.system.functions[name]()` for
119
- each `name` in `db.system.order` — so a simulation is fully runnable and testable
120
- with no rAF and no rendering attached.
111
+ begins on a user action). A headless host (tests, server sim) drives frames itself: for each tier in
112
+ `db.system.order` (an array of tiers), call `db.system.functions[name]()` for each
113
+ name, skipping `schedulerSystem` and init-only systems (no function). So a simulation
114
+ is fully runnable and testable with no rAF and no rendering attached.
121
115
 
122
116
  ## Layer
123
117
 
124
- `system-database.ts` extends the feature's **current top** main-service layer (`ActionDatabase` /
125
- `ServiceDatabase` / `ComputedDatabase` — whichever it built; systems come last in the pipeline, so
126
- they must sit atop any service/action layers, not a hardcoded `ComputedDatabase`), combined with
118
+ `system-database.ts` extends the feature's top ecs layer (`ActionDatabase`, since every spec
119
+ action has an action; systems come last in the pipeline), combined with
127
120
  `scheduler`, and declares the `systems` map **inline** (see above —
128
121
  this is the one facet not split one-per-file, because `create`'s `db` is only typed
129
- inline and standalone declarations break name inference). A `systems/` folder is
130
- optional: it holds extracted per-frame body helpers (`(db) => () => void`) only when
131
- an inline body grows too large — the schedule and registration still live inline in
132
- `system-database.ts`. The headless conformance test drives one frame via
133
- `db.system.order` / `db.system.functions` and compares to the `data/` `step` oracle,
134
- **reusing the feature projection** (`services/main-service/conformance.md`); it lives at
135
- `system-database/tick-loop.test.ts`. Test **selection/detection** logic (which
136
- entities collide, which pair resolves) separately, with seeded edge-case geometries
137
- — it's a system concern, not a transaction, and where subtle bugs hide.
122
+ inline and standalone declarations break name inference). Extracted per-frame body
123
+ helpers (`(db) => () => void`) sit beside it only when an inline body grows too large.
124
+
125
+ ## Conformance
126
+
127
+ Each system has a same-named pure function in the spec's `systems` group
128
+ (`../spec/index.md`), and the implementation declares how frame args reach the store
129
+ and which systems the spec does not model (`conformance.md`):
130
+
131
+ ```ts
132
+ frame: {
133
+ args: { dt: (db, dt) => { db.store.resources.frameDelta = dt; },
134
+ input: (db, input) => { db.store.resources.input = input; } },
135
+ unmodelled: { physics: "wasm rigid-body step", seedLevel: "init-only" },
136
+ cases: frameCases, // ecs/conformance/frame.cases.ts
137
+ },
138
+ ```
139
+
140
+ - **Per system:** each case seeds the store, writes the case's args, runs **just that
141
+ system**, and compares with its spec function. Selection and detection logic (which
142
+ entities collide) is tested here, with seeded edge-case geometries.
143
+ - **Per frame:** each frame case (`ecs/conformance/frame.cases.ts`) folds the spec
144
+ system functions in `db.system.order` and also runs one real frame; both must give
145
+ `after`. The order comes only from the `schedule` declarations.
146
+ - **Shared guards:** the spec's `systems.noOp` states (game over) are checked against
147
+ every system, on both sides.
148
+ - **Unmodelled systems** (wasm, init-only) are skipped on both sides and reported as
149
+ skipped tests. Every system is modelled or unmodelled — a compile error otherwise.
150
+ To test an unmodelled system's glue, put the wasm call behind a service and model it
151
+ with a fake.
138
152
 
139
153
  ## Iterating archetype rows
140
154
 
@@ -0,0 +1,32 @@
1
+ ---
2
+ paths:
3
+ - '**/features/*/ecs/transactions/**/*.ts'
4
+ ---
5
+
6
+ # ecs/transactions/ — atomic mutations
7
+
8
+ One mutation per file: a function taking the transaction store and Data args,
9
+ returning `void`. All its writes commit or roll back together.
10
+
11
+ ```ts
12
+ export const toggleComplete = (t: CoreDatabase.Store, { id }: { readonly id: Entity }) => {
13
+ const row = t.read(id, t.archetypes.Todo);
14
+ if (row === null) return; // not a todo: no-op, as the spec says
15
+ t.update(id, { complete: !row.complete });
16
+ };
17
+ ```
18
+
19
+ - **Type the store** `CoreDatabase.Store`, or `IndexDatabase.Store` once it reads an
20
+ index.
21
+ - **Validate and silently return** on illegal input rather than throwing, so replays
22
+ under sync stay idempotent.
23
+ - **Guard by entity kind.** `t.read(id)` returns any entity. Check the entity is the
24
+ kind the op expects (`t.read(id, t.archetypes.Todo)`, or its tag) before writing, or
25
+ an id naming another kind gets written to.
26
+ - **Decisions come from `data/values/` helpers**; the transaction reads the touched
27
+ slice, applies the helper, and writes the result.
28
+ - **Transactions are an implementation detail.** Conformance checks the action that
29
+ calls them (`actions.md`), so a transaction needs no spec op of its own. Give one a
30
+ unit test only when it has logic no action covers.
31
+ - **The barrel re-exports only mutations.** A read helper shared by several
32
+ transactions (`readBoard`) lives beside them, outside the barrel.