@adobe/data-ai 0.10.1 → 0.10.3
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 +2 -1
- package/.claude/rules/archetypes.md +59 -0
- package/.claude/rules/features/data/state.md +47 -2
- package/.claude/rules/features/services/index.md +2 -1
- package/.claude/rules/features/services/main-service/actions.md +1 -1
- package/.claude/rules/features/services/main-service/archetypes.md +19 -16
- package/.claude/rules/features/services/main-service/computed.md +1 -1
- package/.claude/rules/features/services/main-service/conformance.md +12 -11
- package/.claude/rules/features/services/main-service/core-database.md +48 -0
- package/.claude/rules/features/services/main-service/index.md +2 -1
- package/.claude/rules/features/services/main-service/indexes.md +1 -1
- package/.claude/rules/features/services/main-service/services.md +1 -1
- package/.claude/rules/features/services/main-service/systems.md +13 -63
- package/.claude/rules/features/services/main-service/transactions.md +1 -1
- package/.claude/rules/global/namespace.md +11 -12
- package/.claude/rules/versioning.md +143 -0
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +3 -3
package/.claude/rules/README.md
CHANGED
|
@@ -35,7 +35,8 @@ The `global/` rules (`namespace.md`, `cohesion.md`, `type-casts.md`,
|
|
|
35
35
|
`paths:` globs (`**/*.ts` etc.) apply everywhere, by design, so the folder name
|
|
36
36
|
makes that explicit. Alongside `features/`, the remaining rules-root `.md` files
|
|
37
37
|
hold the other **cross-cutting** patterns the feature rules reference —
|
|
38
|
-
`data-modelling.md`, `archetypes.md` (row iteration), `plugin-modelling.md`,
|
|
38
|
+
`data-modelling.md`, `archetypes.md` (row iteration), `plugin-modelling.md`,
|
|
39
|
+
`versioning.md` (schema history & upgrade-on-load), and
|
|
39
40
|
the UI file rules (`element.md`, `lazy-element.md`, `presentation.md`). They live
|
|
40
41
|
here so the bundle is self-contained; this repo symlinks them into its own
|
|
41
42
|
`.claude/rules/` (as it does `features/`).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/*-plugin.ts'
|
|
4
|
+
- '**/*-plugin/**/*.ts'
|
|
5
|
+
- '**/*-database.ts'
|
|
6
|
+
- '**/*-database/**/*.ts'
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
# Archetypes — the ECS query & iteration model
|
|
10
|
+
|
|
11
|
+
General to all `@adobe/data` ECS use, feature-organized or not. The
|
|
12
|
+
`core-database/archetypes.ts` *file* conventions live in
|
|
13
|
+
`features/services/main-service/archetypes.md`.
|
|
14
|
+
|
|
15
|
+
An archetype is a named set of component keys — a kind of entity — and also a
|
|
16
|
+
**query**: `queryArchetypes(include, { exclude })` (and the `select` / `count`
|
|
17
|
+
built on it) returns every archetype whose columns are a **superset** of
|
|
18
|
+
`include`. So `count(archetype.components)` counts that kind and every kind that
|
|
19
|
+
extends it, and a base archetype no entity is inserted into is a first-class
|
|
20
|
+
query, not dead weight.
|
|
21
|
+
|
|
22
|
+
## Model each logical supertype as a base archetype
|
|
23
|
+
|
|
24
|
+
Give a state-model supertype (`Layer` ⊃ `ImageLayer` / `GroupLayer`) its own
|
|
25
|
+
archetype over the shared components — with a matching `data/` type — and build
|
|
26
|
+
the subtypes off it:
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
const layer = ["placement", "name", "visible", "opacity"] as const;
|
|
30
|
+
const archetypes = { Layer: layer, ImageLayer: ["image", ...layer, "asset"], GroupLayer: ["group", ...layer] };
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`count(archetypes.Layer.components)` then spans every layer kind, current and
|
|
34
|
+
future — correct by construction. Skip a supertype archetype only when nothing
|
|
35
|
+
queries over it.
|
|
36
|
+
|
|
37
|
+
## Row iteration
|
|
38
|
+
|
|
39
|
+
**Select in the query, not the loop.** `queryArchetypes(include, { exclude })` takes
|
|
40
|
+
required and excluded component lists — use them; don't query wider then skip rows or
|
|
41
|
+
archetypes with an `if`.
|
|
42
|
+
|
|
43
|
+
**When every row migrates out, iterate tail → head.** Archetypes are densely
|
|
44
|
+
packed: removing a non-last row hole-fills by moving the tail into the gap.
|
|
45
|
+
Forward iteration while migrating every row pays that shift each step — and forces
|
|
46
|
+
an id snapshot to survive it. Reverse removes from the tail: no shift, no snapshot.
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
// ✅ no shifts, no allocation
|
|
50
|
+
for (let i = arch.rowCount - 1; i >= 0; i--) {
|
|
51
|
+
db.store.update(arch.columns.id.get(i), { _worldMatrix: Mat4x4.identity });
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
If only *some* rows migrate, snapshot just the ids you'll touch — forward is then fine.
|
|
56
|
+
|
|
57
|
+
**Don't snapshot what the query already filters.** A "every id right now" snapshot
|
|
58
|
+
is only needed when forward iteration would invalidate; reverse iteration or an
|
|
59
|
+
`exclude` clause removes the need.
|
|
@@ -195,7 +195,7 @@ export const createTodo = (
|
|
|
195
195
|
return { entities: new Map(state.entities).set(id, { name, complete: false, order }) };
|
|
196
196
|
};
|
|
197
197
|
|
|
198
|
-
export const cases = Conformance.cases(createTodo,
|
|
198
|
+
export const cases = /*@__PURE__*/ Conformance.cases(createTodo,
|
|
199
199
|
{ name: "adds the first todo",
|
|
200
200
|
before: {}, // empty delta — the default State.create() (empty entities)
|
|
201
201
|
args: { name: "a", analytics: AnalyticsService.createFake() },
|
|
@@ -217,7 +217,10 @@ export const cases = Conformance.cases(createTodo,
|
|
|
217
217
|
injected service, etc. into it, never as a third positional. A transition that takes
|
|
218
218
|
**no** args omits `args` from each case entirely (the shared `Case` type makes
|
|
219
219
|
`args` optional exactly then). **Guard no-ops by returning an empty patch `{}`**
|
|
220
|
-
(or the unchanged slice), never throw
|
|
220
|
+
(or the unchanged slice), never throw for an ordinary "nothing to do here"
|
|
221
|
+
condition. Reserve `throw` for a genuine precondition violation the case wants
|
|
222
|
+
to name (see "A case that expects a throw" below) — the rare exception, not the
|
|
223
|
+
default guard style.
|
|
221
224
|
- **A composer merges sub-patches explicitly.** A transition built from smaller
|
|
222
225
|
ones spreads them — `return { ...s, ...sub(s) }` — so each sub-patch's writes
|
|
223
226
|
layer in; a transition that merely **delegates** to one sub-transition returns
|
|
@@ -227,6 +230,22 @@ export const cases = Conformance.cases(createTodo,
|
|
|
227
230
|
any barrel member) in a top-level `cases` literal dead-locks the import cycle.
|
|
228
231
|
Import the concrete helper directly (`import { create } from "./create.js"`) or
|
|
229
232
|
inline full-`State` literals.
|
|
233
|
+
- **Always write the call as `/*@__PURE__*/ Conformance.cases(fn, …)`** (same for
|
|
234
|
+
`Conformance.derivations`) — the annotation is not optional style, it is what
|
|
235
|
+
makes the co-location safe. `@adobe/data-testing`'s `sideEffects: false` only
|
|
236
|
+
lets a bundler drop an unused *import* of the package; `Conformance.cases(fn,
|
|
237
|
+
…)` is still an ordinary function call happening in *this* file, and an
|
|
238
|
+
unannotated call is conservatively kept even when the `cases` export it
|
|
239
|
+
initializes is never read — because the transition function `fn` is legitimately
|
|
240
|
+
used elsewhere, this module is very much reachable, so the unused `cases` const
|
|
241
|
+
is the only thing tree-shaking has to remove, and it can't without the hint.
|
|
242
|
+
Verified against this repo's actual Vite/Rollup production build: a transition
|
|
243
|
+
whose `cases` call lacked the annotation shipped the full case-fixture data
|
|
244
|
+
(and the builder's inlined logic) into the bundle; adding `/*@__PURE__*/`
|
|
245
|
+
removed it completely while leaving the transition function itself intact. A
|
|
246
|
+
linter/codemod catching a bare `Conformance.cases(`/`Conformance.derivations(`
|
|
247
|
+
call (missing the leading comment) in a `data/state/*.ts` file would catch this
|
|
248
|
+
by construction — worth adding if these calls keep getting hand-authored.
|
|
230
249
|
- **`Conformance.cases(fn, ...cases)`** (the builder above) derives each case's
|
|
231
250
|
types from `fn`'s own signature — pass the transition function and cases can't drift
|
|
232
251
|
from what it accepts. **`before` is a delta over `State.create()`** — list only
|
|
@@ -295,6 +314,32 @@ export const cases = Conformance.cases(createTodo,
|
|
|
295
314
|
and isn't a `(state,args)=>state` transform, so `runSpec` skips it: **keep its own
|
|
296
315
|
sibling `*.test.ts`** rather than deleting it and losing coverage.
|
|
297
316
|
|
|
317
|
+
### A case that expects a throw
|
|
318
|
+
|
|
319
|
+
A case is normally `{ name, before, args?, after, effects? }`. When a transition
|
|
320
|
+
must reject its input outright — not the "guard no-op, return `{}`" case above,
|
|
321
|
+
but a genuine precondition violation the spec wants to name — declare `throws`
|
|
322
|
+
instead of `after`/`effects`:
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
export const cases = Conformance.cases(withdrawFunds,
|
|
326
|
+
{ name: "rejects a withdrawal over the balance",
|
|
327
|
+
before: { balance: 10 },
|
|
328
|
+
args: { amount: 11 },
|
|
329
|
+
throws: "insufficient funds" }, // substring the thrown Error.message must contain
|
|
330
|
+
);
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
`throws: true` accepts any thrown error; a string narrows to one whose message
|
|
334
|
+
**contains** it. A case can declare `after`/`effects` **or** `throws`, never
|
|
335
|
+
both — the shared `Case` type makes them mutually exclusive, and there is
|
|
336
|
+
nothing to compare against `after` once a call never returns a patch. The same
|
|
337
|
+
case runs against the pure transform (`runSpec`) **and** the paired ecs
|
|
338
|
+
transaction/action (`runFeature`/`runTransactions`/`runActions`), so a `throws`
|
|
339
|
+
case proves both sides reject the same input the same way. Reach for this only
|
|
340
|
+
when throwing is the transition's actual contract — most invalid input should
|
|
341
|
+
still no-op per the rule above.
|
|
342
|
+
|
|
298
343
|
## Injected services and side effects
|
|
299
344
|
|
|
300
345
|
A transform that needs an outside capability injects it with **`Pick<Services,
|
|
@@ -5,10 +5,11 @@ paths:
|
|
|
5
5
|
|
|
6
6
|
# core-database/archetypes.ts — the feature's archetypes
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
One file, one export: `archetypes`, built with `Database.archetypes(components, …)`,
|
|
9
|
+
which validates every key against the component map and preserves each archetype's
|
|
10
|
+
literal tuple (so no per-archetype `as const satisfies`). The archetype **model** —
|
|
11
|
+
query/superset semantics, base archetypes for logical supertypes, row iteration — is
|
|
12
|
+
the rules-root `archetypes.md`; this file is the declaration convention.
|
|
12
13
|
|
|
13
14
|
```ts
|
|
14
15
|
import { Database } from "@adobe/data/ecs";
|
|
@@ -19,19 +20,24 @@ export const archetypes = Database.archetypes(components, {
|
|
|
19
20
|
});
|
|
20
21
|
```
|
|
21
22
|
|
|
22
|
-
- `UpperCase`
|
|
23
|
-
name does.
|
|
23
|
+
- `UpperCase` names — an archetype names a *shape*, like a class.
|
|
24
24
|
- Archetypes may **span scopes**: a document entity can include an ephemeral
|
|
25
|
-
`session` column
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
`session` column — `components` merges every scope, so any-scope key validates.
|
|
26
|
+
|
|
27
|
+
## satisfies CoreDatabase.Archetype
|
|
28
|
+
|
|
29
|
+
Type-check a shared base tuple against `CoreDatabase.Archetype` (= `readonly Component[]`;
|
|
30
|
+
see `core-database.md`) — the same type checks every `count` / `select` query key downstream:
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
const layer = ["placement", "name", "visible", "opacity"] as const satisfies CoreDatabase.Archetype;
|
|
34
|
+
```
|
|
29
35
|
|
|
30
36
|
## Drift guard against the data-type
|
|
31
37
|
|
|
32
|
-
An archetype that mirrors a `data/` row type carries a **non-exported**
|
|
33
|
-
|
|
34
|
-
|
|
38
|
+
An archetype that mirrors a `data/` row type carries a **non-exported** compile-time
|
|
39
|
+
check — infer the row shape from the archetype and assert it equals the data-type, so
|
|
40
|
+
the two can't drift:
|
|
35
41
|
|
|
36
42
|
```ts
|
|
37
43
|
import { Schema } from "@adobe/data/schema";
|
|
@@ -41,6 +47,3 @@ import type { PlacedMark } from "../../data/placed-mark/placed-mark.js";
|
|
|
41
47
|
const _schema = Schema.fromArchetype(components, archetypes.PlacedMark);
|
|
42
48
|
type _Check = Assert<Equal<Schema.ToType<typeof _schema>, PlacedMark>>;
|
|
43
49
|
```
|
|
44
|
-
|
|
45
|
-
Iterating archetype **rows** at runtime is a separate concern — see the
|
|
46
|
-
rules-root `archetypes.md`.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
paths:
|
|
3
|
-
- '**/features/*/services/main-service
|
|
3
|
+
- '**/features/*/services/main-service/conformance/**/*.ts'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# services/main-service/conformance/ — keeping the ECS honest against the spec
|
|
@@ -18,15 +18,12 @@ them against the ECS. This folder holds only the *feature-specific projection*
|
|
|
18
18
|
`conformance/` (the zero-config example) and `data-lit-todo` (entity markers +
|
|
19
19
|
`hydrate`); `data-lit-space-rock-game` for the entity-bag / real-time variant.
|
|
20
20
|
|
|
21
|
-
**The projection helpers
|
|
22
|
-
`
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
a normal runtime API — the collision is only in the name.) Likewise
|
|
28
|
-
`Match.matches` / `Match.assert` are comparison helpers for `*.test.ts` only —
|
|
29
|
-
never a runtime branch.
|
|
21
|
+
**The projection helpers (`fromState`, `toState`, `toData(store, entity)`) and
|
|
22
|
+
`Match.matches` / `Match.assert` are test-only — never a production/runtime branch.**
|
|
23
|
+
`fromState`/`toState` rewrite the whole store out-of-band; runtime code reads through
|
|
24
|
+
observables/indexes and writes through transactions. (The conformance
|
|
25
|
+
`toData(store, entity)` reader is unrelated to the library's runtime `db.toData()`
|
|
26
|
+
store-serialization method — same name only.)
|
|
30
27
|
|
|
31
28
|
## What `@adobe/data-testing` gives you
|
|
32
29
|
|
|
@@ -168,7 +165,11 @@ It discovers every file exporting `cases`, enforces the two-exports rule, and
|
|
|
168
165
|
dispatches on case shape (transition → state + effects, derivation →
|
|
169
166
|
`fn(input) ≡ value`), seeding each case's `before`/`input` as a delta over
|
|
170
167
|
`State.create()`. On the ECS side it resolves the reference args a case's `args`
|
|
171
|
-
schema marks; the pure side reads them plain.
|
|
168
|
+
schema marks; the pure side reads them plain. A transition case may declare
|
|
169
|
+
`throws` instead of `after`/`effects` to assert the call rejects its input —
|
|
170
|
+
see `../../data/state.md` ("A case that expects a throw"). `runSpec`,
|
|
171
|
+
`runTransactions`, and `runActions` all honor it identically, so the same case
|
|
172
|
+
proves the pure transform and its paired ecs op fail the same way.
|
|
172
173
|
|
|
173
174
|
## The exception — a per-surface `userId`, via the lower-level runners
|
|
174
175
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/features/*/services/main-service/**/core-database/core-database.ts'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# core-database/core-database.ts — the assembled schema + its namespace
|
|
7
|
+
|
|
8
|
+
A thin assembler: `Database.Plugin.create({ components?, resources?, archetypes? })`
|
|
9
|
+
over the facet files, exposed through the `CoreDatabase` namespace. This is the
|
|
10
|
+
single-file namespace form (one value export — `plugin` — everything else a type;
|
|
11
|
+
see `global/namespace.md`).
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { Database, type Store } from "@adobe/data/ecs";
|
|
15
|
+
import { components } from "./components.js";
|
|
16
|
+
import { resources } from "./resources.js";
|
|
17
|
+
import { archetypes } from "./archetypes.js";
|
|
18
|
+
|
|
19
|
+
const coreDatabasePlugin = Database.Plugin.create({ components, resources, archetypes });
|
|
20
|
+
|
|
21
|
+
export type CoreDatabase = Database.Plugin.ToDatabase<typeof coreDatabasePlugin>;
|
|
22
|
+
// Resolved component map, at module scope so the imported `Store` namespace isn't
|
|
23
|
+
// shadowed by `CoreDatabase.Store` below.
|
|
24
|
+
type CoreComponents = Store.Components<Database.Plugin.ToStore<typeof coreDatabasePlugin>>;
|
|
25
|
+
|
|
26
|
+
export namespace CoreDatabase {
|
|
27
|
+
export const plugin = coreDatabasePlugin;
|
|
28
|
+
/** The store transactions operate on — a store *is* the transaction context. */
|
|
29
|
+
export type Store = Database.Plugin.ToStore<typeof coreDatabasePlugin>;
|
|
30
|
+
/** Index-declaration type bound to this database's components. */
|
|
31
|
+
export type Index = Database.Index<CoreComponents>;
|
|
32
|
+
/** A component name, and a component-key tuple — an archetype's defining shape, or the key
|
|
33
|
+
* list of a `count` / `select` query. */
|
|
34
|
+
export type Component = Extract<keyof typeof components, string>;
|
|
35
|
+
export type Archetype = readonly Component[];
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Derive `Component` / `Archetype` from `components`, never the plugin
|
|
40
|
+
|
|
41
|
+
Read the keys straight from the leaf `components` module
|
|
42
|
+
(`Extract<keyof typeof components, string>`). Do **not** derive them from the
|
|
43
|
+
plugin (`Store.Components<ToStore<plugin>>`, i.e. `CoreComponents`): the plugin is
|
|
44
|
+
built *from* `archetypes`, so a plugin-derived `Archetype` is **circular** the
|
|
45
|
+
moment it is used to `satisfies`-check an archetype tuple — and the cycle
|
|
46
|
+
collapses the entire database type to `{}`. The leaf derivation is cycle-free and
|
|
47
|
+
usable both in `archetypes.ts` (to check shared bases) and downstream (to check
|
|
48
|
+
`count` / `select` query keys).
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
paths:
|
|
3
|
-
- '**/features/*/services/main-service
|
|
4
|
-
- '**/systems/*.ts'
|
|
3
|
+
- '**/features/*/services/main-service/system-database/**/*.ts'
|
|
5
4
|
---
|
|
6
5
|
|
|
7
6
|
# database/systems/ — the real-time tick loop
|
|
@@ -66,8 +65,12 @@ const plugin = Database.Plugin.create({
|
|
|
66
65
|
HUD) are notified. Reserve direct `db.store` writes for the hot per-row paths
|
|
67
66
|
(movement, aging) that run on every entity every frame and shouldn't pay
|
|
68
67
|
per-entity transaction overhead.
|
|
69
|
-
- **Optimize for
|
|
70
|
-
|
|
68
|
+
- **Optimize hot paths for speed.** With many entities, query with efficient
|
|
69
|
+
`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.)
|
|
71
74
|
- **Iterate archetypes per `archetypes.md`.** Express selection with
|
|
72
75
|
`queryArchetypes(include, { exclude })`; when a system destroys/migrates rows
|
|
73
76
|
(bullets expiring, entities dying) iterate **tail → head** so hole-fills don't
|
|
@@ -95,11 +98,8 @@ const plugin = Database.Plugin.create({
|
|
|
95
98
|
and advanced this same tick), you can't advance *all* bodies in one system
|
|
96
99
|
before the spawn — split that entity's advance out so it runs *after* the spawn,
|
|
97
100
|
alongside the step that owns it.
|
|
98
|
-
-
|
|
99
|
-
|
|
100
|
-
example patterns for doing this.
|
|
101
|
-
- All simulation systems should be optimized for performance.
|
|
102
|
-
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)
|
|
101
|
+
- Immediate-mode rendering: store the canvas in a session resource and render to
|
|
102
|
+
it inside a system (see @adobe/data-gpu for patterns).
|
|
103
103
|
|
|
104
104
|
## Driving the loop — the scheduler
|
|
105
105
|
|
|
@@ -136,58 +136,8 @@ an inline body grows too large — the schedule and registration still live inli
|
|
|
136
136
|
entities collide, which pair resolves) separately, with seeded edge-case geometries
|
|
137
137
|
— it's a system concern, not a transaction, and where subtle bugs hide.
|
|
138
138
|
|
|
139
|
-
|
|
139
|
+
## Iterating archetype rows
|
|
140
140
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
component lists. Use them. Don't query a wider set and then skip rows or
|
|
145
|
-
archetypes with an `if`.
|
|
146
|
-
|
|
147
|
-
```ts
|
|
148
|
-
// ❌ post-filter
|
|
149
|
-
for (const arch of db.store.queryArchetypes(["position", "rotation"])) {
|
|
150
|
-
if (arch.components.has("_worldMatrix")) continue;
|
|
151
|
-
...
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// ✅ declarative
|
|
155
|
-
for (const arch of db.store.queryArchetypes(
|
|
156
|
-
["position", "rotation"],
|
|
157
|
-
{ exclude: ["_worldMatrix"] },
|
|
158
|
-
)) {
|
|
159
|
-
...
|
|
160
|
-
}
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
## When every row migrates out, iterate tail → head
|
|
164
|
-
|
|
165
|
-
Archetypes are densely packed. Removing or migrating a row that isn't the
|
|
166
|
-
last one triggers a hole-fill: the tail row is moved into the gap.
|
|
167
|
-
Iterating `0 → rowCount-1` while migrating every row pays this cost on
|
|
168
|
-
every iteration. Iterating `rowCount-1 → 0` means each removal is from
|
|
169
|
-
the tail — no shift, indices ahead of the cursor stay valid.
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
// ❌ shifts the tail into every hole, and the snapshot allocation is
|
|
173
|
-
// only there to survive the shifts.
|
|
174
|
-
const ids = [...];
|
|
175
|
-
for (let i = 0; i < arch.rowCount; i++) ids[i] = arch.columns.id.get(i);
|
|
176
|
-
for (const id of ids) db.store.update(id, { _worldMatrix: Mat4x4.identity });
|
|
177
|
-
|
|
178
|
-
// ✅ no shifts, no allocation
|
|
179
|
-
for (let i = arch.rowCount - 1; i >= 0; i--) {
|
|
180
|
-
db.store.update(arch.columns.id.get(i), { _worldMatrix: Mat4x4.identity });
|
|
181
|
-
}
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
If only *some* rows migrate (filter inside the loop), snapshot the ids
|
|
185
|
-
you'll touch — forward iteration is fine because rows you don't touch
|
|
186
|
-
stay put.
|
|
187
|
-
|
|
188
|
-
## Don't snapshot what the query already filters
|
|
189
|
-
|
|
190
|
-
A snapshot of "all entity ids in this archetype right now" is only
|
|
191
|
-
needed when forward iteration would invalidate. Reverse iteration
|
|
192
|
-
removes the need; an `exclude` clause removes the need to look at rows
|
|
193
|
-
that don't qualify in the first place.
|
|
141
|
+
A per-frame system reads/writes archetype columns directly. The row-iteration
|
|
142
|
+
rules — declarative `queryArchetypes` selection, tail → head migration, snapshot
|
|
143
|
+
avoidance — are general ECS concerns in the rules-root `archetypes.md`.
|
|
@@ -148,19 +148,18 @@ export const add = ({ x: x1, y: y1 }: Point, { x: x2, y: y2 }: Point): Point =>
|
|
|
148
148
|
This pattern is ONLY allowed if there is only a single value export and every other export is just a type.
|
|
149
149
|
As soon as there is more than one value type it MUST be promoted to the full namespace pattern.
|
|
150
150
|
|
|
151
|
-
Example:
|
|
151
|
+
Example — a type merged with one value export and any number of type-only members:
|
|
152
152
|
|
|
153
|
-
//
|
|
153
|
+
// window/window.ts
|
|
154
|
+
const value = createWindow();
|
|
154
155
|
|
|
155
|
-
|
|
156
|
-
components,
|
|
157
|
-
resources,
|
|
158
|
-
archetypes,
|
|
159
|
-
});
|
|
156
|
+
export type Window = typeof value;
|
|
160
157
|
|
|
161
|
-
export
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
export const plugin = coreDatabasePlugin;
|
|
165
|
-
export type Store = Database.Plugin.ToStore<typeof coreDatabasePlugin>;
|
|
158
|
+
export namespace Window {
|
|
159
|
+
export const instance = value; // the sole value export
|
|
160
|
+
export type Size = { width: number; height: number };
|
|
166
161
|
}
|
|
162
|
+
|
|
163
|
+
The canonical use is a feature's `core-database.ts` / layered `*-database.ts` (a
|
|
164
|
+
`plugin` value plus `Store` / `Index` / `Component` / `Archetype` types) — see
|
|
165
|
+
`features/services/main-service/core-database.md`.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
paths:
|
|
3
|
+
- '**/versioning/**/*.ts'
|
|
4
|
+
- '**/versions.ts'
|
|
5
|
+
- '**/versions.test.ts'
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
# Database schema versioning & upgrade-on-load
|
|
9
|
+
|
|
10
|
+
A versioned `@adobe/data` database keeps an ordered **version history** so an old
|
|
11
|
+
persisted document can be upgraded to the current schema on load. You (human or
|
|
12
|
+
agent) should almost never have to reason about this by hand: **change the schema,
|
|
13
|
+
run the tests, and the failing test prints the exact fix to apply.**
|
|
14
|
+
|
|
15
|
+
## The pieces (all from `@adobe/data/ecs`)
|
|
16
|
+
|
|
17
|
+
- **`versions.ts`** — `export const versions: readonly VersionEntry[]`. `entries[i]`
|
|
18
|
+
IS version `i`; `entries[0]` is a frozen copy of the initial schema. `currentVersion
|
|
19
|
+
= versions.length - 1`.
|
|
20
|
+
- **`VersionEntry`** — `{ version, changes: { components?, resources? }, handler? }`.
|
|
21
|
+
`version` MUST equal the array index. `changes` is a **JSON Merge Patch** applied to
|
|
22
|
+
the folded-so-far schema — record ONLY what changes, not a restated copy:
|
|
23
|
+
- `name: { …fields… }` adds a new component/resource, or deep-merges those fields
|
|
24
|
+
onto the existing schema;
|
|
25
|
+
- a key set to **`undefined`** DELETES it — `name: undefined` drops the whole
|
|
26
|
+
component/resource; a nested `precision: undefined` drops just that field;
|
|
27
|
+
- `null` is a normal VALUE (so `default: null` sets the default), not a delete;
|
|
28
|
+
arrays/scalars replace wholesale.
|
|
29
|
+
Because it MERGES, a shape change must delete the fields that no longer apply —
|
|
30
|
+
number → object is `{ type: "object", properties: {…}, precision: undefined, default: undefined }`,
|
|
31
|
+
never a bare `{ type: "object", … }` (which would keep the old `precision`/`default`).
|
|
32
|
+
Never write empty `changes: {}` — an entry must record at least one change. Components
|
|
33
|
+
and resources are separate namespaces. `handler` is present ONLY for a change that is
|
|
34
|
+
not automatically convertible.
|
|
35
|
+
- The version is **NOT an ECS resource** — it rides the save-format metadata. The db
|
|
36
|
+
exposes it as `db.version` (`= currentVersion`, or `0` when unversioned), stamps it into
|
|
37
|
+
every `toData` per persisted quadrant (`schemaVersions: { document, settings }`), and
|
|
38
|
+
reads it back on load. Do not declare a `documentVersion`/`version` component or resource.
|
|
39
|
+
- **`createVersionUpgrader(versions)`** passed as `Database.create(plugin, { versioning })`.
|
|
40
|
+
No config — the per-quadrant saved versions come from the blob metadata. Each `handler`
|
|
41
|
+
touches exactly ONE persisted quadrant (`document` or `settings`); the upgrader replays
|
|
42
|
+
each quadrant from its own saved version up to current (see rule 7).
|
|
43
|
+
- ONE co-located guard test in `versions.test.ts` calling `assertVersioning(...)` (see below).
|
|
44
|
+
|
|
45
|
+
## Rules — do not violate
|
|
46
|
+
|
|
47
|
+
1. **Existing entries are FROZEN.** Never edit, reorder, or delete an entry — it is
|
|
48
|
+
historical fact. Only ever APPEND a new entry.
|
|
49
|
+
2. **Version 0 is a deep copy, never a reference.** It must not change when the live
|
|
50
|
+
schema changes; that is the whole point of a history.
|
|
51
|
+
3. **Additive & minor changes need NO handler** — the load path auto-converts them
|
|
52
|
+
(add-field-with-default, widen/narrow, reorder, clamp, add component/resource).
|
|
53
|
+
Just record the `changes` patch.
|
|
54
|
+
4. **Breaking changes REQUIRE a handler** — a type change, a rename, splitting/moving
|
|
55
|
+
a field. The handler runs against the store staged to the previous version and
|
|
56
|
+
mutates it in place to the new shape. For an ISOLATED single-component change,
|
|
57
|
+
`Store.remapComponent(store, name, newSchema, old => newValue)` does it in one call —
|
|
58
|
+
but it is NOT required and NOT always applicable. A migration that reads from or
|
|
59
|
+
writes to SEVERAL components (a cross-component move, deriving a new component from
|
|
60
|
+
others, splitting one into many) must be hand-written to your upgrade algorithm:
|
|
61
|
+
query the input components, compute, and write the outputs directly on the store.
|
|
62
|
+
Add a test case either way (rule 6).
|
|
63
|
+
5. **Removing a component drops its data.** If the data still matters, migrate it in a
|
|
64
|
+
handler BEFORE the removal entry.
|
|
65
|
+
6. **Versioned schemas must be REAL** — declare a `type` (or `enum`/`const`), and the
|
|
66
|
+
`default` must be fully described by that schema (no extra keys/structure). Only
|
|
67
|
+
SESSION values (`nonPersistent` AND `nonShared`, e.g. a GPU buffer) may be untyped,
|
|
68
|
+
and those are never versioned — leave them out of the history entirely.
|
|
69
|
+
7. **A handler touches ONE quadrant.** Persisted quadrants (shared+persistent = the
|
|
70
|
+
cloud document; nonShared+persistent = local settings) can be saved separately and
|
|
71
|
+
drift to DIFFERENT versions. On load they are merged into one store and the upgrader
|
|
72
|
+
replays EACH quadrant from its own stamp — so a handler that changes schemas in more
|
|
73
|
+
than one quadrant fails the guard (when one quadrant is behind, staging its handler
|
|
74
|
+
must not touch the other). Split it into one version per quadrant — the upgrader reads
|
|
75
|
+
each quadrant's saved version from the blob metadata automatically.
|
|
76
|
+
|
|
77
|
+
## When a schema change makes a test fail — the fix is in the error
|
|
78
|
+
|
|
79
|
+
`assertVersionsMatchSchema` fails whenever the live schema no longer folds from the
|
|
80
|
+
history. Its message is a literal recipe. To fix it:
|
|
81
|
+
|
|
82
|
+
1. **Append one new entry** to the `versions` array with `version:` = the next index
|
|
83
|
+
and the `changes` block the error printed verbatim. `db.version` follows the history
|
|
84
|
+
automatically (`= versions.length - 1`) — there is no resource default to update.
|
|
85
|
+
2. If the error marks a change **BREAKING — needs a handler**, add a `handler` to that
|
|
86
|
+
entry AND add a matching test case under `testUpgradeHandlers` (rule 6). The error
|
|
87
|
+
may point at `Store.remapComponent(...)` for an isolated single-component change, but
|
|
88
|
+
that is only a suggestion — write whatever upgrade code the change actually needs.
|
|
89
|
+
|
|
90
|
+
Do exactly what the message says; do not touch existing entries.
|
|
91
|
+
|
|
92
|
+
## The one guard test
|
|
93
|
+
|
|
94
|
+
A single `assertVersioning(...)` runs BOTH checks — the history folds to the current
|
|
95
|
+
schema (schema coverage) AND every entry with a `handler` has a passing test case
|
|
96
|
+
(handler coverage + behavior). Keep this one test.
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
import { Database, assertVersioning, createVersionUpgrader } from "@adobe/data/ecs";
|
|
100
|
+
import { MainService } from "../main-service.js";
|
|
101
|
+
import { versions } from "./versions.js";
|
|
102
|
+
|
|
103
|
+
describe("database schema versions", () => {
|
|
104
|
+
it("the version history is consistent (schema folds + every handler tested)", () =>
|
|
105
|
+
assertVersioning({
|
|
106
|
+
// the version-configured db, so db.version = the history's current version
|
|
107
|
+
database: Database.create(MainService.plugin, { versioning: createVersionUpgrader(versions) }),
|
|
108
|
+
entries: versions,
|
|
109
|
+
handlers: {
|
|
110
|
+
// Append a case per version that adds a `handler` (rule 6):
|
|
111
|
+
// <version>: { setup: (store) => <populate the version-(v-1) store, return entities>,
|
|
112
|
+
// expect: (store, setup) => <assert the version-v result> },
|
|
113
|
+
},
|
|
114
|
+
}));
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
(`assertVersionsMatchSchema` and `testUpgradeHandlers` remain exported for bespoke use.)
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Example: a breaking change (number → object) at version 1
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
// versions.ts — append (do NOT edit version 0)
|
|
125
|
+
import { Store } from "@adobe/data/ecs";
|
|
126
|
+
const HealthObject = { type: "object", properties: { current: { type: "number", precision: 1, default: 0 }, max: { type: "number", precision: 1, default: 0 } } };
|
|
127
|
+
export const versions = [
|
|
128
|
+
{ version: 0, changes: { components: { /* …frozen… */ hp: { type: "number", precision: 1, default: 0 } } } },
|
|
129
|
+
{ version: 1,
|
|
130
|
+
// MERGE patch: add the object shape, DELETE the number-only fields it drops.
|
|
131
|
+
changes: { components: { hp: { type: "object", properties: HealthObject.properties, precision: undefined, default: undefined } } },
|
|
132
|
+
handler: (store) => Store.remapComponent(store, "hp", HealthObject, (old: number) => ({ current: old, max: old })) },
|
|
133
|
+
];
|
|
134
|
+
// `db.version` becomes 1 automatically (= versions.length - 1) — nothing else to set.
|
|
135
|
+
|
|
136
|
+
// versions.test.ts — add the required case to the guard's `handlers`
|
|
137
|
+
assertVersioning({ database: db, entries: versions, handlers: {
|
|
138
|
+
1: {
|
|
139
|
+
setup: (store) => store.ensureArchetype(["hp"]).insert({ hp: 42 }),
|
|
140
|
+
expect: (store, e) => expect(store.read(e)?.hp).toEqual({ current: 42, max: 42 }),
|
|
141
|
+
},
|
|
142
|
+
} });
|
|
143
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/data-ai",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.3",
|
|
4
4
|
"description": "Cross-agent architecture skills for @adobe/data — installable as a Claude Code plugin or copied into any Agent-Skills-compatible agent (Cursor, Codex).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
"skills"
|
|
30
30
|
],
|
|
31
31
|
"peerDependencies": {
|
|
32
|
-
"@adobe/data": "0.10.
|
|
32
|
+
"@adobe/data": "0.10.3"
|
|
33
33
|
},
|
|
34
34
|
"devDependencies": {
|
|
35
35
|
"typescript": "^5.8.3",
|
|
36
36
|
"vitest": "^1.6.0",
|
|
37
|
-
"@adobe/data": "0.10.
|
|
37
|
+
"@adobe/data": "0.10.3"
|
|
38
38
|
},
|
|
39
39
|
"license": "MIT",
|
|
40
40
|
"scripts": {
|