@adobe/data-ai 0.9.83 → 0.9.86
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.
- package/.claude/rules/README.md +42 -0
- package/.claude/rules/archetypes.md +60 -0
- package/.claude/rules/cohesion.md +66 -0
- package/.claude/rules/data-modelling.md +71 -0
- package/.claude/rules/element.md +130 -0
- package/.claude/rules/features/data/index.md +35 -0
- package/.claude/rules/features/data/state.md +56 -0
- package/.claude/rules/features/ecs/actions.md +32 -0
- package/.claude/rules/features/ecs/archetypes.md +46 -0
- package/.claude/rules/features/ecs/components.md +41 -0
- package/.claude/rules/features/ecs/computed.md +26 -0
- package/.claude/rules/features/ecs/conformance.md +71 -0
- package/.claude/rules/features/ecs/index.md +179 -0
- package/.claude/rules/features/ecs/indexes.md +63 -0
- package/.claude/rules/features/ecs/resources.md +28 -0
- package/.claude/rules/features/ecs/services.md +24 -0
- package/.claude/rules/features/ecs/systems.md +132 -0
- package/.claude/rules/features/ecs/transactions.md +45 -0
- package/.claude/rules/features/index.md +95 -0
- package/.claude/rules/features/services/index.md +34 -0
- package/.claude/rules/features/ui/index.md +31 -0
- package/.claude/rules/function-references.md +35 -0
- package/.claude/rules/lazy-element.md +127 -0
- package/.claude/rules/namespace.md +179 -0
- package/.claude/rules/plugin-modelling.md +170 -0
- package/.claude/rules/presentation.md +83 -0
- package/.claude/rules/type-casts.md +94 -0
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +54 -76
- package/bin/cli.mjs +81 -26
- package/package.json +9 -2
- package/skills/build-actions/SKILL.md +16 -0
- package/skills/build-app-entry/SKILL.md +15 -0
- package/skills/build-application/SKILL.md +12 -0
- package/skills/build-computed/SKILL.md +16 -0
- package/skills/build-core-database/SKILL.md +24 -0
- package/skills/build-data/SKILL.md +22 -0
- package/skills/build-feature/SKILL.md +18 -0
- package/skills/build-game/SKILL.md +21 -0
- package/skills/build-indexes/SKILL.md +16 -0
- package/skills/build-service-database/SKILL.md +16 -0
- package/skills/build-services/SKILL.md +16 -0
- package/skills/build-systems/SKILL.md +24 -0
- package/skills/build-transactions/SKILL.md +15 -0
- package/skills/build-ui/SKILL.md +13 -0
- package/skills/graph-execute/SKILL.md +13 -0
- package/skills/meta-build/SKILL.md +43 -0
- package/skills/review/SKILL.md +25 -0
- package/skills/structure/SKILL.md +7 -0
- package/skills/x-alpha/SKILL.md +7 -0
- package/skills/x-beta/SKILL.md +7 -0
- package/skills/x-double/SKILL.md +7 -0
- package/skills/x-execute/SKILL.md +16 -0
- package/skills/x-log/SKILL.md +12 -0
- package/skills/x-pow/SKILL.md +9 -0
- package/skills/x-subagent/SKILL.md +8 -0
- package/skills/x-trace/SKILL.md +11 -0
- package/skills/x-validate/SKILL.md +67 -0
- package/skills/bar/SKILL.md +0 -8
- package/skills/foo/SKILL.md +0 -8
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/conformance/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ecs/conformance/ — spec↔implementation projection (test-only)
|
|
7
|
+
|
|
8
|
+
The bridge that keeps the ecs implementation honest against the `data/` spec.
|
|
9
|
+
**Test-only**: imported solely by `*.test.ts` and in no facet barrel, so it never
|
|
10
|
+
enters the runtime bundle (test-support may import the test framework — it is not
|
|
11
|
+
a runtime declaration). **Never call `fromState` / `toState` (or a shared
|
|
12
|
+
clear-all + reinsert helper used only for that projection) from systems,
|
|
13
|
+
transactions, or UI** — that is a full-store rewrite, not an O(1) ECS update.
|
|
14
|
+
**Feature-level**: one projection per feature, reused by every transaction test
|
|
15
|
+
and the system tick-loop test — don't nest it under `transaction-database/`.
|
|
16
|
+
|
|
17
|
+
The property, per `{ before, args, after }` case:
|
|
18
|
+
`toState(apply(fromState(before), args)) ≡ spec(before, args)`.
|
|
19
|
+
|
|
20
|
+
Conformance test-support splits by concern along the layer boundary:
|
|
21
|
+
|
|
22
|
+
- **Store-side, here in `ecs/conformance/`:**
|
|
23
|
+
- `from-state.ts` → `fromState(store: CoreDatabase.Store, state: State): void`
|
|
24
|
+
seeds the store (clear tail→head → insert entities → set resources) to a `State`.
|
|
25
|
+
- `to-state.ts` → `toState(store): State` reads the store back — each kind via
|
|
26
|
+
its full named-archetype component set, so the shapes never alias.
|
|
27
|
+
- `expect-conforms.ts` → **one export, `expectConforms({ cases, spec, apply })`**.
|
|
28
|
+
Per case it asserts `spec(before, args) ≡ after` (keeps the case honest), then
|
|
29
|
+
`fromState(before)` → `apply(store, args)` → `toState ≡ after`.
|
|
30
|
+
- **Spec-side, in `data/state/`** (State values, no store — so both the `data/`
|
|
31
|
+
transform tests and this runner import them without a layer violation):
|
|
32
|
+
`conformance-case.ts` (`ConformanceCase<Args>`), the `<transform>.cases.ts`
|
|
33
|
+
cases, and `expect-state-matches.ts` (State equality).
|
|
34
|
+
|
|
35
|
+
## No cast — build on `Store.create`, not a `Database`
|
|
36
|
+
|
|
37
|
+
A **transaction is `(store, args) => void`**, so transaction conformance needs no
|
|
38
|
+
`Database`: `Store.create(<schema-layer>.plugin)` returns a cast-free writable
|
|
39
|
+
`CoreDatabase.Store` — pass the plugin directly, `Store.create` reads its schema
|
|
40
|
+
facets. Source it from the **lowest layer that declares all the schema** —
|
|
41
|
+
`IndexDatabase`, or `CoreDatabase` if the feature has no indexes — **not**
|
|
42
|
+
`FeatureDatabase`: the store needs only schema, and the behaviour layers
|
|
43
|
+
(transactions / computed / systems) add none.
|
|
44
|
+
`fromState`/`toState`/`apply` all operate on it, and `apply` calls the **raw
|
|
45
|
+
transaction function** directly:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
expectConforms({ cases, spec: State.hitTarget, apply: (store, args) => hitTarget(store, args) });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
A mutation addressed by **entity id** resolves its entities from the seeded store
|
|
52
|
+
inside `apply` (the shared cases stay spec-shaped). Reads never need a cast —
|
|
53
|
+
`Database extends ReadonlyStore`.
|
|
54
|
+
|
|
55
|
+
**Systems** are the one exception: they run via `db.system.functions` and reach
|
|
56
|
+
the store through the db. Get that writable view with the library lens —
|
|
57
|
+
`Database.toSystemDatabase(Database.create(plugin))` (the widening twin of
|
|
58
|
+
`UIService.restrict`, cast-free) — then `fromState(db.store, before)` → drive one
|
|
59
|
+
frame → `toState(db.store)`. Test **selection/detection** logic (which entities
|
|
60
|
+
interact) separately, with seeded edge-case geometries.
|
|
61
|
+
|
|
62
|
+
## State equality — ordering vs precision, kept separate
|
|
63
|
+
|
|
64
|
+
`expect-state-matches.ts` compares with **`equalsUnordered`** from `@adobe/data`
|
|
65
|
+
(arrays as multisets — archetype hole-fill reorders rows — objects key-order
|
|
66
|
+
independent). Absorb float noise — F32↔f64 storage rounding **and** trig epsilon
|
|
67
|
+
(a quadrant `cos`/`sin` yields ~3e-15 where a case authors `0`) — by quantizing
|
|
68
|
+
every number on both sides onto a small grid (`Math.round(Math.fround(n)*k)/k`)
|
|
69
|
+
before comparing. Ordering and precision stay separate concerns. **Guard the
|
|
70
|
+
projection itself** with one `fromState → toState` identity test on
|
|
71
|
+
representative states.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# ecs/ — the Entity-Component-System layer
|
|
7
|
+
|
|
8
|
+
Everything ECS-specific lives here: the schema that stores `data/` types as
|
|
9
|
+
entity state (`core-database/`), the derived and mutating logic over it
|
|
10
|
+
(`indexes/`, `transactions/`, `computed/`), the database-bound service factories
|
|
11
|
+
(`services/`), the async orchestrators (`actions/`), and the layered plugins
|
|
12
|
+
that compose it all.
|
|
13
|
+
|
|
14
|
+
The distinction from `data/`: a `data/` type is ECS-agnostic (a plain
|
|
15
|
+
serializable value); an ECS construct is *how that type is stored and operated
|
|
16
|
+
on* inside a database. `mark: PlayerMark.schema` is not a data-model fact — it
|
|
17
|
+
is "the ECS keeps a PlayerMark here".
|
|
18
|
+
|
|
19
|
+
## The core schema — `core-database/`
|
|
20
|
+
|
|
21
|
+
A feature's whole schema — every component, resource, and archetype — lives in
|
|
22
|
+
one `core-database/` folder: `core-database.ts` plus one file per facet the
|
|
23
|
+
feature uses (all three facets are optional on `Database.Plugin.create`):
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
ecs/core-database/
|
|
27
|
+
core-database.ts # Database.Plugin.create({ components?, resources?, archetypes? }) → CoreDatabase
|
|
28
|
+
components.ts # export const components = Database.components({ … })
|
|
29
|
+
resources.ts # export const resources = Database.resources({ … })
|
|
30
|
+
archetypes.ts # export const archetypes = Database.archetypes(components, { … })
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`core-database.ts` is a thin assembler; each facet file exports one typed object
|
|
34
|
+
built by a `Database.*` helper. No per-item files, no barrels. Create only the
|
|
35
|
+
facet files the feature needs — a singleton feature with no entities is just
|
|
36
|
+
`resources.ts` + `core-database.ts` (no empty `components({})` boilerplate).
|
|
37
|
+
|
|
38
|
+
### Schema scopes
|
|
39
|
+
|
|
40
|
+
State divides on two orthogonal axes — **scope** (shared with peers vs. local to
|
|
41
|
+
this client) and **lifetime** (durable across reloads vs. ephemeral) — giving
|
|
42
|
+
four scopes, each stamping a flag pair:
|
|
43
|
+
|
|
44
|
+
| scope | shared? | durable? | flags |
|
|
45
|
+
|-------|:--:|:--:|-------|
|
|
46
|
+
| **document** | yes | yes | — |
|
|
47
|
+
| **settings** | no | yes | `nonShared` |
|
|
48
|
+
| **presence** | yes | no | `nonPersistent` |
|
|
49
|
+
| **session** | no | no | `nonPersistent` + `nonShared` |
|
|
50
|
+
|
|
51
|
+
`Database.components` / `Database.resources` group declarations by scope and
|
|
52
|
+
stamp the flags. Every scope key is optional — declare only the ones you use
|
|
53
|
+
(most features are document-only):
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// components.ts
|
|
57
|
+
export const components = Database.components({
|
|
58
|
+
document: { todo: True.schema, name: Name.schema, complete: Boolean.schema },
|
|
59
|
+
session: { dragPosition: DragPosition.schema }, // omit settings / presence
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// resources.ts — entries must carry a `default` (enforced at the call)
|
|
63
|
+
export const resources = Database.resources({
|
|
64
|
+
settings: { displayCompleted: Boolean.schema },
|
|
65
|
+
});
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
A component/resource *value* is a plain `data/`-schema re-export (or a small
|
|
69
|
+
inline literal); its scope — and therefore its flags — is stated only by which
|
|
70
|
+
group it sits in. Hover a scope key for its meaning. The database does not yet
|
|
71
|
+
act on these flags (see the `nonShared` schema note); they model
|
|
72
|
+
shared/local × durable/ephemeral now, and the durable-shared subset
|
|
73
|
+
(`document`) is the set a bare store could be rebuilt from for reconciliation.
|
|
74
|
+
|
|
75
|
+
**Shared columns live in `data/`.** A column two features share (e.g. a `name`
|
|
76
|
+
on both todos and users) is a `data/` type both reference by identity
|
|
77
|
+
(`Name.schema`), so `combinePlugins` dedupes it. Only the `document` scope
|
|
78
|
+
preserves schema identity (it applies no flags) — never share a
|
|
79
|
+
settings/presence/session column across features by identity.
|
|
80
|
+
|
|
81
|
+
### Archetypes
|
|
82
|
+
|
|
83
|
+
`archetypes.ts` declares the feature's entity shapes with
|
|
84
|
+
`Database.archetypes(components, { … })`, which validates every key against the
|
|
85
|
+
component map and preserves each archetype's literal tuple — no per-archetype
|
|
86
|
+
`as const`. Archetypes are a packing convenience, not serialized data; one may
|
|
87
|
+
span scopes (a document entity with an ephemeral `dragPosition` slot). An
|
|
88
|
+
archetype mirroring a `data/` row type carries a private compile-time
|
|
89
|
+
drift-guard (see `archetypes.md`).
|
|
90
|
+
|
|
91
|
+
## Layered composition (behaviour)
|
|
92
|
+
|
|
93
|
+
Above the schema, behaviour is a chain of `Database.Plugin`s, each its own
|
|
94
|
+
**namespace folder** — `<layer>-database/` holding the eponymous
|
|
95
|
+
`<layer>-database.ts` (the plugin, which `extends` the previous layer) plus the
|
|
96
|
+
facet folder it adds. **Name each layer for the facet it adds — never for the
|
|
97
|
+
feature or app.** A feature creates only the layers it uses:
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
ecs/
|
|
101
|
+
core-database/ # the schema (above)
|
|
102
|
+
index-database/ # extends core; indexes/
|
|
103
|
+
transaction-database/ # extends index; transactions/
|
|
104
|
+
computed-database/ # extends transaction; computed/
|
|
105
|
+
service-database/ # extends computed; services/
|
|
106
|
+
action-database/ # extends service; actions/
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Each exports a namespace mirroring the plugin — the composed object is a
|
|
110
|
+
`Database` (the `@adobe/data` term) even though the folder is `ecs/`:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export type IndexDatabase = Database.Plugin.ToDatabase<typeof indexDatabasePlugin>;
|
|
114
|
+
export namespace IndexDatabase {
|
|
115
|
+
export const plugin = indexDatabasePlugin;
|
|
116
|
+
export type Store = Database.Plugin.ToStore<typeof indexDatabasePlugin>;
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### The assembled database — `FeatureDatabase`
|
|
121
|
+
|
|
122
|
+
*Which* layer is topmost varies with the facets a feature uses (computed /
|
|
123
|
+
service / action / system). Consumers — `ui/`, the app entry, `ecs/conformance/`
|
|
124
|
+
— need the *whole* feature database but must not name that layer, or adding or
|
|
125
|
+
dropping a layer rewrites them all. Alias it once:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// ecs/feature-database.ts
|
|
129
|
+
export { SystemDatabase as FeatureDatabase } from "./system-database/system-database.js";
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Every consumer references **`FeatureDatabase`** (`.plugin`, `.Store`) — never the
|
|
133
|
+
topmost layer. Add or drop a layer → change only this one line.
|
|
134
|
+
|
|
135
|
+
**Cross-feature naming.** Inside a feature the core and assembled databases are
|
|
136
|
+
the bare `CoreDatabase` / `FeatureDatabase`. When *another* feature or package
|
|
137
|
+
imports one — a peer built on it, the base `imports` a peer's `core-database`, a
|
|
138
|
+
downstream package — reference it **feature-qualified**: `<Feature>CoreDatabase`
|
|
139
|
+
/ `<Feature>Database`, re-exported so from the feature's barrel
|
|
140
|
+
(`export { CoreDatabase as TodoCoreDatabase } from "…/core-database.js"`) — so
|
|
141
|
+
two features' `CoreDatabase`s never collide.
|
|
142
|
+
|
|
143
|
+
## What each layer takes as input
|
|
144
|
+
|
|
145
|
+
- **`plugin`** — every layer `extends` the previous layer's `plugin`; that is
|
|
146
|
+
the one universal thread up the chain.
|
|
147
|
+
- **`Store`** (via `ToStore`) — used *only* by **transactions** (`t:
|
|
148
|
+
<Layer>.Store`). A store carries the index handles and the initiating
|
|
149
|
+
`userId`, so a store *is* the transaction context; there is no separate
|
|
150
|
+
transaction-context type. Use **`CoreDatabase.Store`** for a transaction that
|
|
151
|
+
reads/writes entities, resources, or archetypes; **`IndexDatabase.Store`** the
|
|
152
|
+
moment it reads an index.
|
|
153
|
+
- **the whole `Database`** — **computed**, **services**, and **actions** each
|
|
154
|
+
take `db: <Layer>` (the lowest layer whose database exposes what they
|
|
155
|
+
read/call): computed reads `db.observe.*`; services read observables and call
|
|
156
|
+
transactions; actions call `db.services.*` then `db.transactions.*`.
|
|
157
|
+
|
|
158
|
+
Each subfolder has its own rule. Modelling a plugin's authored vs. derived
|
|
159
|
+
surface is covered by `plugin-modelling.md`.
|
|
160
|
+
|
|
161
|
+
## Facets are flat until they aren't
|
|
162
|
+
|
|
163
|
+
A **behavioural** facet folder (`indexes/`, `transactions/`, `computed/`,
|
|
164
|
+
`actions/`) holds one file per item plus an `index.ts` barrel — flat while that
|
|
165
|
+
reads well, roughly up to half a dozen items. When a cohesive cluster grows past
|
|
166
|
+
that, promoting it into a themed subfolder with its own `index.ts` (re-exported
|
|
167
|
+
by the facet barrel) is a standard move — only once the folder actually feels
|
|
168
|
+
crowded:
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
transactions/
|
|
172
|
+
order/ # a cohesive cluster: fractional-ordering helpers
|
|
173
|
+
index.ts next-order.ts normalize-order.ts select-ordered-todos.ts
|
|
174
|
+
create-todo.ts drag-todo.ts …
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The **schema** facets (components / resources / archetypes) are *single files*
|
|
178
|
+
in `core-database/`, not folders. A whole feature should stay small enough that
|
|
179
|
+
subfoldering rarely bites — grow by adding features, not by bloating one.
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/indexes/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# database/indexes/ — ECS indexes
|
|
7
|
+
|
|
8
|
+
One index descriptor per file: an object `satisfies CoreDatabase.Index` that
|
|
9
|
+
tells the store to maintain a lookup keyed on a component, so queries and
|
|
10
|
+
computed values can find entities without scanning.
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
import type { CoreDatabase } from "../../core-database/core-database.js";
|
|
14
|
+
|
|
15
|
+
export const byComplete = {
|
|
16
|
+
key: "complete",
|
|
17
|
+
} as const satisfies CoreDatabase.Index;
|
|
18
|
+
```
|
|
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
|
|
22
|
+
`Index` helper can't express, so declare those with a bare `as const` (no
|
|
23
|
+
`satisfies`) and let `index-database.ts`'s `create()` validate them.
|
|
24
|
+
|
|
25
|
+
Name the export for what it indexes (`byComplete`, `byOwner`). An
|
|
26
|
+
`index.ts` barrel re-exports every index; it feeds the `indexes` facet on
|
|
27
|
+
`index-database.ts`.
|
|
28
|
+
|
|
29
|
+
## Discrete keys only — derive a bucket for spatial / range queries
|
|
30
|
+
|
|
31
|
+
An index matches keys by **equality on a discrete value**, so a continuous or
|
|
32
|
+
range-valued column (a `position`, a timestamp, a score) can't be indexed as-is
|
|
33
|
+
— `{ key: "position" }` buckets by exact value and finds nothing near a point.
|
|
34
|
+
Derive a discrete **bucket key** — but prefer a **computed-key extractor**
|
|
35
|
+
(`key: { cell: (c) => cellKey(c.position!) }`, naming the read columns in
|
|
36
|
+
`components`) over storing the derived key as its own component. The extractor
|
|
37
|
+
recomputes the key from its source columns, so the index stays correct with no
|
|
38
|
+
maintenance; a stored bucket column must instead be recomputed at every
|
|
39
|
+
insertion/update site (or by a system) and silently drifts if one is missed. The
|
|
40
|
+
derivation itself is a pure `data/` helper.
|
|
41
|
+
|
|
42
|
+
Bucketed broad-phase only pays off at scale (hundreds+ candidates per query);
|
|
43
|
+
below that a linear scan over an archetype wins. Index when the scan is a
|
|
44
|
+
**proven** cost, not preemptively.
|
|
45
|
+
|
|
46
|
+
## Scoping, arrays, and many-to-many
|
|
47
|
+
|
|
48
|
+
- **Coverage is by columns.** An index applies to every archetype whose
|
|
49
|
+
components are a superset of the key's columns. Two archetypes that share a
|
|
50
|
+
column are both indexed — so a unique index keyed on a shared column (e.g.
|
|
51
|
+
`name`, held by both users and todos) would collide.
|
|
52
|
+
- **Scope with `archetype`.** `{ key: "name", archetype: "User", unique: true }`
|
|
53
|
+
restricts the index to one archetype, excluding others that share the column.
|
|
54
|
+
`archetype` is **not** on the `Database.Index<C>` `satisfies` helper (it carries
|
|
55
|
+
no archetype map), so declare such an index with `as const` (no `satisfies`)
|
|
56
|
+
and let `index-database.ts`'s `create()` validate it against the real facet.
|
|
57
|
+
- **Array columns fan out automatically.** Keying on a `string[]` column makes a
|
|
58
|
+
multi-value index — one bucket entry per element — and `find({ col: element })`
|
|
59
|
+
takes the element, not the array.
|
|
60
|
+
- **Model a many-to-many as a denormalized array + two indexes.** A
|
|
61
|
+
`todo.assignees: string[]` (owned/displayed by one feature) plus a unique
|
|
62
|
+
`usersByName` (name → user) and a multi-value `todosByAssignee` (assignee →
|
|
63
|
+
todos) gives efficient navigation in both directions from one join key.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/core-database/resources.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# core-database/resources.ts — the feature's resources
|
|
7
|
+
|
|
8
|
+
A resource is a singleton component — one instance held on a single entity. One
|
|
9
|
+
file, one export: `resources`, built with `Database.resources(...)`. It is
|
|
10
|
+
identical to `Database.components` except **every entry must carry a `default`**
|
|
11
|
+
— a missing `default` is a compile error here, at the declaration site.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Database } from "@adobe/data/ecs";
|
|
15
|
+
import { Boolean } from "@adobe/data/schema";
|
|
16
|
+
|
|
17
|
+
export const resources = Database.resources({
|
|
18
|
+
settings: { displayCompleted: Boolean.schema }, // per-device view toggle
|
|
19
|
+
});
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Give each resource a `default` — spread the data-type's schema and add one, or
|
|
23
|
+
bind a `data/` / primitive schema that already carries a sensible default.
|
|
24
|
+
- **Scope = which group it sits in** (see `ecs/index.md`). A per-device
|
|
25
|
+
preference is `settings` (durable, `nonShared`); a synced-but-unsaved value is
|
|
26
|
+
`presence`; a saved shared value is `document`.
|
|
27
|
+
- Because there is only ever one instance, linear-memory packing buys nothing —
|
|
28
|
+
never choose a struct schema for efficiency here.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/services/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# database/services/ — database-bound service factories
|
|
7
|
+
|
|
8
|
+
One factory per file: `create<Name>Service(db, …)` that binds a service
|
|
9
|
+
implementation to a live database — reading its observables and invoking
|
|
10
|
+
its transactions. This is where the async `services/` contracts (or
|
|
11
|
+
framework services like `AgenticService`) are wired to ECS state.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
export const createAgentService = (db: ComputedDatabase, mark: PlayerMark): AgenticService =>
|
|
15
|
+
/* … reads db.observe.*, calls db.transactions.* … */;
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Type `db` on the lowest layer exposing what the factory reads/calls (never the
|
|
19
|
+
`service-database` itself — that would be a cycle).
|
|
20
|
+
|
|
21
|
+
An `index.ts` barrel re-exports the factories; `service-database.ts`
|
|
22
|
+
registers them under the `services` facet, each keyed by the name
|
|
23
|
+
consumers read it as (`db.services.agent`). Defining a standalone service
|
|
24
|
+
contract (not yet bound to a db) belongs in the feature `services/` layer.
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/systems/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# database/systems/ — the real-time tick loop
|
|
7
|
+
|
|
8
|
+
Systems are the framework's real-time backbone: functions the scheduler runs
|
|
9
|
+
**every frame**, in dependency order, to advance a simulation. A feature only
|
|
10
|
+
has a `systems/` folder when it is real-time (a game, a physics/particle sim).
|
|
11
|
+
Turn-based features never need one — they mutate through transactions on user
|
|
12
|
+
input and stop.
|
|
13
|
+
|
|
14
|
+
A system is a `SystemDeclaration` — `{ create, schedule?: { before?, after?, during? } }`.
|
|
15
|
+
`create` runs ONCE at construction (capture closures / query args); it returns the
|
|
16
|
+
per-frame function (or `void` for an init-only system, e.g. seeding the first
|
|
17
|
+
entities). Ordering lives under **`schedule`** (not top-level).
|
|
18
|
+
|
|
19
|
+
**Declare the `systems` map inline in `system-database.ts`.** The `create: (db) => …`
|
|
20
|
+
arrow must be written inline in `Plugin.create` for two reasons: only there is `db`
|
|
21
|
+
strongly typed (to the assembled plugin's database, with a **writable `store`**), and
|
|
22
|
+
a pre-typed standalone `create` reference breaks the scheduler's system-name inference
|
|
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`.
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// in system-database.ts
|
|
30
|
+
import { Database, scheduler } from "@adobe/data/ecs";
|
|
31
|
+
import { Motion } from "../../data/motion/motion.js";
|
|
32
|
+
|
|
33
|
+
const plugin = Database.Plugin.create({
|
|
34
|
+
extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler),
|
|
35
|
+
systems: {
|
|
36
|
+
control: { create: (db) => () => { /* input → ship; db.transactions.fireBullet() */ } },
|
|
37
|
+
movement: {
|
|
38
|
+
schedule: { after: ["control"], before: ["collision"] },
|
|
39
|
+
create: (db) => () => {
|
|
40
|
+
const dt = db.store.resources.frameDelta;
|
|
41
|
+
for (const arch of db.store.queryArchetypes(["position", "velocity"])) {
|
|
42
|
+
const pos = arch.columns.position, vel = arch.columns.velocity;
|
|
43
|
+
for (let i = 0; i < arch.rowCount; i++) {
|
|
44
|
+
pos.set(i, Motion.advance(pos.get(i), vel.get(i), dt)); // in-place, no migration
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
collision: { schedule: { after: ["movement"] }, create: (db) => () => { /* … */ } },
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
- **Systems mutate `db.store` directly** — that is the writable surface a system
|
|
55
|
+
receives (unlike the readonly `.store` consumers get). In-place column writes
|
|
56
|
+
(`col.set(i, …)`) for hot per-row work that stays in the same archetype;
|
|
57
|
+
`db.store.update / insert / delete` for lifecycle (spawning, dying) that changes
|
|
58
|
+
archetype membership. Resources: `db.store.resources.x = …`.
|
|
59
|
+
- **Dispatch a transaction for a discrete atomic event** — when the effect is a
|
|
60
|
+
self-contained event that already has a conformant transaction (score a hit,
|
|
61
|
+
lose a life, spawn a wave, fire a bullet), call `db.transactions.*` instead of
|
|
62
|
+
re-implementing it: you reuse the `data/`-verified logic and observers (a reactive
|
|
63
|
+
HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths
|
|
64
|
+
(movement, aging) that run on every entity every frame and shouldn't pay
|
|
65
|
+
per-entity transaction overhead.
|
|
66
|
+
- **Optimize for execution speed in systems** if there are multiple items, then make sure we are using efficient store queries and reading/writing minimal data columns. If this is not hot path, then the simplified data functions can be used, but in hot paths we only consider data functions to be specification. (ie: only use them if it's clearly not a hot path or you cannot write anything more performant by directly using ecs store)
|
|
67
|
+
- **The step math may live in `data/`.** but only for non-hot path
|
|
68
|
+
- **Iterate archetypes per `archetypes.md`.** Express selection with
|
|
69
|
+
`queryArchetypes(include, { exclude })`; when a system destroys/migrates rows
|
|
70
|
+
(bullets expiring, entities dying) iterate **tail → head** so hole-fills don't
|
|
71
|
+
invalidate the cursor. A per-frame system touching many rows is exactly where
|
|
72
|
+
those rules pay off. **A `db.transactions.*` call mid-scan migrates rows too** —
|
|
73
|
+
a transaction that inserts/deletes invalidates any live archetype cursor, so
|
|
74
|
+
snapshot the ids you'll act on (or reverse-iterate) *before* dispatching
|
|
75
|
+
transactions inside a per-frame loop.
|
|
76
|
+
- **Replicate the oracle's top-level guard in every system.** If the `data/`
|
|
77
|
+
`step` opens with `if (isGameOver(state)) return state`, each split system must
|
|
78
|
+
early-return on the same condition — a frozen frame is only a true no-op if
|
|
79
|
+
*every* system honors the guard.
|
|
80
|
+
- **Index reads go through `db.indexes`** (plural) — the writable `db.store` a
|
|
81
|
+
system receives is typed *without* its index handles, so a broad-phase query is
|
|
82
|
+
`db.indexes.byCell.find({ cell })`, not `db.store.indexes.…`.
|
|
83
|
+
- **Ordering is declared under `schedule`, not implied.** `schedule.before` /
|
|
84
|
+
`schedule.after` are hard constraints (name sibling systems); `schedule.during`
|
|
85
|
+
is a soft same-tier hint. Systems with no ordering relation share a tier
|
|
86
|
+
(conceptually parallel) — never rely on declaration order. When you split a
|
|
87
|
+
monolithic `data/` `step` into several ordered systems, the schedule **must
|
|
88
|
+
mirror the step's internal sequence** (advance → fire → age → collide → refill)
|
|
89
|
+
— otherwise the tick diverges from the oracle even though each system is
|
|
90
|
+
individually correct. Watch the subtle case: if a spawn step (fire) is followed
|
|
91
|
+
by a step that also processes the just-spawned entity (the new bullet is aged
|
|
92
|
+
and advanced this same tick), you can't advance *all* bodies in one system
|
|
93
|
+
before the spawn — split that entity's advance out so it runs *after* the spawn,
|
|
94
|
+
alongside the step that owns it.
|
|
95
|
+
- Games or simulations with immediate mode rendering may want to store a session
|
|
96
|
+
resource of the canvas and render to it within a system. @adobe/data-gpu provides
|
|
97
|
+
example patterns for doing this.
|
|
98
|
+
- All simulation systems should be optimized for performance.
|
|
99
|
+
Avoid allocating intermediate objects if possible; read/write columns directly if hot-path. You can use transactions if needed, for instance if we are writing non hot path things which we have other things observing. (Only transactions are observable, not direct writes)
|
|
100
|
+
|
|
101
|
+
## Driving the loop — the scheduler
|
|
102
|
+
|
|
103
|
+
Systems don't run themselves. Combine the built-in scheduler so they run on
|
|
104
|
+
`requestAnimationFrame`:
|
|
105
|
+
|
|
106
|
+
```ts
|
|
107
|
+
import { Database, scheduler } from "@adobe/data/ecs";
|
|
108
|
+
// in system-database.ts:
|
|
109
|
+
Database.Plugin.create({ extends: Database.Plugin.combine(ComputedDatabase.plugin, scheduler), systems });
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The scheduler adds a `schedulerState` resource (`"running" | "paused" |
|
|
113
|
+
"disposed"`) — gate start/pause/resume through it (start `"paused"` if the game
|
|
114
|
+
begins on a user action). A headless host (tests, server sim) omits the
|
|
115
|
+
scheduler and drives frames itself by calling `db.system.functions[name]()` for
|
|
116
|
+
each `name` in `db.system.order` — so a simulation is fully runnable and testable
|
|
117
|
+
with no rAF and no rendering attached.
|
|
118
|
+
|
|
119
|
+
## Layer
|
|
120
|
+
|
|
121
|
+
`system-database.ts` extends the previous ecs layer (usually `ComputedDatabase`),
|
|
122
|
+
combined with `scheduler`, and declares the `systems` map **inline** (see above —
|
|
123
|
+
this is the one facet not split one-per-file, because `create`'s `db` is only typed
|
|
124
|
+
inline and standalone declarations break name inference). A `systems/` folder is
|
|
125
|
+
optional: it holds extracted per-frame body helpers (`(db) => () => void`) only when
|
|
126
|
+
an inline body grows too large — the schedule and registration still live inline in
|
|
127
|
+
`system-database.ts`. The headless conformance test drives one frame via
|
|
128
|
+
`db.system.order` / `db.system.functions` and compares to the `data/` `step` oracle,
|
|
129
|
+
**reusing the feature projection** (`ecs/conformance.md`); it lives at
|
|
130
|
+
`system-database/tick-loop.test.ts`. Test **selection/detection** logic (which
|
|
131
|
+
entities collide, which pair resolves) separately, with seeded edge-case geometries
|
|
132
|
+
— it's a system concern, not a transaction, and where subtle bugs hide.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/ecs/**/transactions/**/*.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# database/transactions/ — atomic mutations
|
|
7
|
+
|
|
8
|
+
One mutation per file: a function taking the transaction store as its
|
|
9
|
+
first argument and pure `data/` args, returning `void`. It reads and
|
|
10
|
+
writes `t.resources` / entities; all its writes commit or roll back
|
|
11
|
+
together.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { PlayMoveArgs } from "../../../data/play-move-args/play-move-args.js";
|
|
15
|
+
import type { CoreDatabase } from "../../core-database/core-database.js";
|
|
16
|
+
|
|
17
|
+
export const playMove = (t: CoreDatabase.Store, { index }: PlayMoveArgs) => {
|
|
18
|
+
// guard, then mutate t.resources / entities / archetypes
|
|
19
|
+
};
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- Type the store parameter as `<Layer>.Store`: **`CoreDatabase.Store`** for a
|
|
23
|
+
transaction that reads/writes entities, resources, or archetypes (the common
|
|
24
|
+
case — all schema lives in `core-database`); **`IndexDatabase.Store`** the
|
|
25
|
+
moment it reads an index. A store *is* the transaction context: it carries the
|
|
26
|
+
index handles and the initiating `t.userId`. There is no separate
|
|
27
|
+
transaction-context type.
|
|
28
|
+
- Keep transactions idempotent under replay where sync/P2P applies —
|
|
29
|
+
validate and silently return on illegal or out-of-turn input rather
|
|
30
|
+
than throwing.
|
|
31
|
+
- Decisions come from pure `data/` helpers; the transaction only applies the
|
|
32
|
+
result — read the touched slice, call the `data/` transform, write the diff.
|
|
33
|
+
- **Conform every transaction to its `data/` transform** in its sibling
|
|
34
|
+
`*.test.ts`, via `expectConforms` over the spec's shared cases (see
|
|
35
|
+
`ecs/conformance.md`): seed `fromState(before)`, dispatch, assert `toState ≡
|
|
36
|
+
after`, covering every branch and edge case. A transaction taking **entity
|
|
37
|
+
ids** resolves them from the seeded store in the `apply` closure; one with no
|
|
38
|
+
`data/` analogue (`setBounds`, `setInput`) gets a direct resource assertion.
|
|
39
|
+
- An `index.ts` barrel feeds the `transactions` plugin facet — so it must
|
|
40
|
+
re-export **only** the mutations. A read/query helper shared by several
|
|
41
|
+
transactions (`readShip`, `readBoard` — a `(t) => value` function) may live
|
|
42
|
+
beside them in `transactions/`, but keep it **out of the barrel**, or it gets
|
|
43
|
+
registered as a dispatchable transaction. (These are related helpers, so they
|
|
44
|
+
belong here, not in a separate folder — `cohesion.md`'s peer-level caution
|
|
45
|
+
doesn't apply.)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/data/**/*.ts'
|
|
4
|
+
- '**/features/*/services/**/*.ts'
|
|
5
|
+
- '**/features/*/ecs/**/*.ts'
|
|
6
|
+
- '**/features/*/ui/**/*.ts'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Feature architecture — a verifiable spec and an optimized implementation
|
|
10
|
+
|
|
11
|
+
Each feature is built twice in one codebase: a **pure specification** you can
|
|
12
|
+
trust, and an **efficient implementation** proven equivalent to it.
|
|
13
|
+
|
|
14
|
+
- **`data/` is the specification.** The whole feature modelled as one immutable
|
|
15
|
+
`State` with pure transformations over it — correct by construction, fully
|
|
16
|
+
unit-tested, performance irrelevant. This is the source of truth.
|
|
17
|
+
- **`ecs/` is the implementation.** The same model physically arranged for
|
|
18
|
+
mutation efficiency (a reactive Entity-Component-System). Its reads and writes
|
|
19
|
+
wrap the `data/` functions where practical; where they must be hand-optimized,
|
|
20
|
+
unit tests verify they still agree with the `data/` precedent.
|
|
21
|
+
|
|
22
|
+
Because the optimized `ecs/` layer is largely mechanical given the `data/` spec,
|
|
23
|
+
it can be generated and kept honest by AI rules, with the conformance tests as
|
|
24
|
+
the safety net. Net result: write a slow-but-verifiable app, then derive a fast
|
|
25
|
+
one that provably behaves the same.
|
|
26
|
+
|
|
27
|
+
## The four layers
|
|
28
|
+
|
|
29
|
+
`ui → ecs → services → data` — higher imports lower, never the reverse. `data/`
|
|
30
|
+
depends on nothing but `@adobe/data` and other `data/` declarations. A feature
|
|
31
|
+
creates only the layers it uses.
|
|
32
|
+
|
|
33
|
+
| Layer | Role |
|
|
34
|
+
|-------|------|
|
|
35
|
+
| `data/` | The spec: `State`, pure transforms & derivations, entity sub-types. Pure, tested. |
|
|
36
|
+
| `services/` | Async capability contracts (ports to the outside world). Optional. |
|
|
37
|
+
| `ecs/` | The implementation: the ECS materialisation + reactive reads/writes over it. |
|
|
38
|
+
| `ui/` | Presentation. |
|
|
39
|
+
|
|
40
|
+
## One app, many features
|
|
41
|
+
|
|
42
|
+
An application is a set of **features**, each its own `features/<name>/` folder
|
|
43
|
+
with the same four layers. One base feature (`features/main/`) is the host; the
|
|
44
|
+
rest are peers that load lazily.
|
|
45
|
+
|
|
46
|
+
**Keep each feature small; grow by adding features, not by bloating one.** A
|
|
47
|
+
feature is meant to fit in the head — a handful of files per layer. When a
|
|
48
|
+
part of a feature keeps growing, that is usually the signal to split it into
|
|
49
|
+
its own peer feature rather than let one feature's folders balloon.
|
|
50
|
+
|
|
51
|
+
- **Dependencies point toward the base, never out of it.** A peer feature may
|
|
52
|
+
build on another feature's `data/` types and declarations (kept acyclic). The
|
|
53
|
+
base must not depend on its children — with one sanctioned exception below.
|
|
54
|
+
- **The base `imports` every peer's *schema* plugin** —
|
|
55
|
+
`Database.Plugin.create({ imports })`, not `extends`. `imports` merges the
|
|
56
|
+
peer's `core-database` (components / resources / archetypes) into the shared
|
|
57
|
+
store at runtime **without** pulling its types or behavior into the base's type
|
|
58
|
+
or bundle (`extends` would do both, and cost quadratically). So one store knows
|
|
59
|
+
every feature's schema — data coexists, persists, and syncs — while the base
|
|
60
|
+
stays decoupled. Import the peer's `core-database.ts` plugin (schema only) —
|
|
61
|
+
under its feature-qualified name `<Peer>CoreDatabase` (see the cross-feature
|
|
62
|
+
naming rule in `ecs/index.md`): its indexes, transactions, computed, services,
|
|
63
|
+
and UI stay out until the feature loads. A column two features share (e.g.
|
|
64
|
+
`name`) lives in `data/` and is referenced by identity, so `combinePlugins`
|
|
65
|
+
dedupes it.
|
|
66
|
+
- **Peers load lazily by being used.** `DatabaseElement`, on connect, walks up to
|
|
67
|
+
the nearest ancestor database and `extend`s it with its own plugin — so the
|
|
68
|
+
first time a feature element renders, its full plugin (indexes, transactions,
|
|
69
|
+
computed, services) is added to the shared live database and its code chunk is
|
|
70
|
+
fetched. Gate that first render behind a user action (a button, a tab) so the
|
|
71
|
+
load is genuinely on-demand.
|
|
72
|
+
- **The base reaches a child only through a lazy element wrapper** — a tiny
|
|
73
|
+
`Foo()` that `void import()`s the child element. That dynamic import is the one
|
|
74
|
+
allowed core→child seam; the heavy element and its service database stay in the
|
|
75
|
+
child's own chunk.
|
|
76
|
+
|
|
77
|
+
## Spec and implementation, kept honest
|
|
78
|
+
|
|
79
|
+
The tie between `data/` (spec) and `ecs/` (implementation) is **conformance**,
|
|
80
|
+
one property — `toState(apply(fromState(before), args)) ≡ transform(before, args)`:
|
|
81
|
+
each ecs mutation, seeded and read back through a test-only `ecs ↔ State`
|
|
82
|
+
projection, equals the pure `data/` transform it stands for. The projection and
|
|
83
|
+
its runner live in `ecs/conformance/` (see `ecs/conformance.md`); the shared
|
|
84
|
+
`{ before, args, after }` cases are spec-owned (`data/state/<transform>.cases.ts`),
|
|
85
|
+
so conforming the ecs path is "substitute the implementation, reuse the
|
|
86
|
+
expectations." This lets the ecs layer be largely mechanical and agent-generated,
|
|
87
|
+
with the spec as oracle. *How* to author each layer lives in the per-folder rules
|
|
88
|
+
below.
|
|
89
|
+
|
|
90
|
+
## Per-layer detail
|
|
91
|
+
|
|
92
|
+
See the rules under each folder: `data/`, `services/`, `ecs/` (components,
|
|
93
|
+
resources, archetypes, computed, indexes, transactions), `ui/`. Cross-cutting
|
|
94
|
+
patterns live at the rules root — `namespace.md`, `data-modelling.md`,
|
|
95
|
+
`archetypes.md` (row iteration), `type-casts.md`, `cohesion.md`.
|