@adobe/ecs 0.2.4 → 0.3.0

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 (120) hide show
  1. package/dist/implementation/compiler/augmentations.js +8 -1
  2. package/dist/implementation/compiler/augmentations.js.map +1 -1
  3. package/dist/implementation/compiler/inferred.js +3 -2
  4. package/dist/implementation/compiler/inferred.js.map +1 -1
  5. package/dist/implementation/compiler/type-schemas.d.ts +1 -0
  6. package/dist/implementation/compiler/type-schemas.js +35 -2
  7. package/dist/implementation/compiler/type-schemas.js.map +1 -1
  8. package/dist/implementation/world/activate.d.ts +14 -3
  9. package/dist/implementation/world/activate.js +19 -13
  10. package/dist/implementation/world/activate.js.map +1 -1
  11. package/dist/implementation/world/commands.js +3 -2
  12. package/dist/implementation/world/commands.js.map +1 -1
  13. package/dist/implementation/world/create.js +4 -4
  14. package/dist/implementation/world/create.js.map +1 -1
  15. package/dist/implementation/world/execute.js +97 -11
  16. package/dist/implementation/world/execute.js.map +1 -1
  17. package/dist/implementation/world/finalizers.d.ts +10 -2
  18. package/dist/implementation/world/finalizers.js +42 -13
  19. package/dist/implementation/world/finalizers.js.map +1 -1
  20. package/dist/implementation/world/program.js +5 -1
  21. package/dist/implementation/world/program.js.map +1 -1
  22. package/dist/implementation/world/state.d.ts +2 -0
  23. package/dist/implementation/world/state.js +9 -0
  24. package/dist/implementation/world/state.js.map +1 -1
  25. package/dist/implementation/world/synchronize.js +16 -9
  26. package/dist/implementation/world/synchronize.js.map +1 -1
  27. package/dist/implementation/world/tables.d.ts +2 -1
  28. package/dist/implementation/world/tables.js +55 -2
  29. package/dist/implementation/world/tables.js.map +1 -1
  30. package/dist/interface/finalizer/body.d.ts +24 -3
  31. package/dist/interface/finalizer/create.d.ts +3 -3
  32. package/dist/interface/finalizer/create.js +43 -11
  33. package/dist/interface/finalizer/create.js.map +1 -1
  34. package/dist/interface/finalizer/declaration.d.ts +25 -5
  35. package/dist/interface/registry/index.d.ts +1 -1
  36. package/dist/interface/registry/index.js +1 -1
  37. package/dist/interface/registry/index.js.map +1 -1
  38. package/dist/interface/registry/names.d.ts +4 -0
  39. package/dist/interface/registry/names.js +20 -0
  40. package/dist/interface/registry/names.js.map +1 -1
  41. package/dist/interface/registry/schedule.d.ts +1 -0
  42. package/dist/interface/registry/schedule.js +86 -11
  43. package/dist/interface/registry/schedule.js.map +1 -1
  44. package/dist/interface/system/check.js +4 -0
  45. package/dist/interface/system/check.js.map +1 -1
  46. package/dist/interface/system/commands.d.ts +8 -3
  47. package/dist/interface/system/create-resolved.js +2 -1
  48. package/dist/interface/system/create-resolved.js.map +1 -1
  49. package/dist/interface/system/create.js +2 -2
  50. package/dist/interface/system/create.js.map +1 -1
  51. package/dist/interface/system/declaration.d.ts +11 -4
  52. package/dist/interface/system/describe.js +1 -1
  53. package/dist/interface/system/describe.js.map +1 -1
  54. package/dist/interface/system/leaf.d.ts +4 -3
  55. package/dist/interface/system/render.js +2 -2
  56. package/dist/interface/system/render.js.map +1 -1
  57. package/dist/interface/system/resolve.d.ts +12 -3
  58. package/dist/interface/system/resolve.js +35 -4
  59. package/dist/interface/system/resolve.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/guide/01-resources.md +4 -3
  62. package/src/guide/03-systems.out +1 -1
  63. package/src/guide/05-scheduling.md +12 -6
  64. package/src/guide/05-scheduling.out +1 -0
  65. package/src/guide/05-scheduling.ts +17 -0
  66. package/src/guide/06-sync-points.md +14 -6
  67. package/src/guide/06-sync-points.out +2 -0
  68. package/src/guide/06-sync-points.ts +15 -0
  69. package/src/guide/10-finalizers.md +5 -0
  70. package/src/guide/18-identity.out +1 -1
  71. package/src/guide/guide.test.ts +4 -4
  72. package/src/implementation/compiler/augmentations.test.ts +28 -1
  73. package/src/implementation/compiler/augmentations.ts +5 -1
  74. package/src/implementation/compiler/inferred.ts +3 -2
  75. package/src/implementation/compiler/transform.test.ts +4 -4
  76. package/src/implementation/compiler/type-schemas.ts +28 -2
  77. package/src/implementation/world/activate.ts +24 -11
  78. package/src/implementation/world/commands.ts +3 -2
  79. package/src/implementation/world/create.ts +4 -4
  80. package/src/implementation/world/defaults.test.ts +24 -1
  81. package/src/implementation/world/despawn-where.test.ts +31 -8
  82. package/src/implementation/world/execute.ts +98 -9
  83. package/src/implementation/world/finalizers.ts +43 -14
  84. package/src/implementation/world/lifetime.test-helper.ts +1 -1
  85. package/src/implementation/world/passes.test.ts +61 -0
  86. package/src/implementation/world/perf.md +9 -0
  87. package/src/implementation/world/program.ts +4 -1
  88. package/src/implementation/world/run-results.test.ts +17 -0
  89. package/src/implementation/world/schedule.test.ts +42 -1
  90. package/src/implementation/world/state.ts +8 -0
  91. package/src/implementation/world/sync-points.test.ts +12 -0
  92. package/src/implementation/world/synchronize.ts +15 -9
  93. package/src/implementation/world/tables.test.ts +36 -3
  94. package/src/implementation/world/tables.ts +48 -3
  95. package/src/implementation/world/watch-traits-late.test-helper.ts +14 -0
  96. package/src/implementation/world/watch-traits.test-helper.ts +10 -0
  97. package/src/implementation/world/watch-traits.test.ts +62 -0
  98. package/src/interface/finalizer/body.ts +22 -3
  99. package/src/interface/finalizer/create.test.ts +4 -1
  100. package/src/interface/finalizer/create.ts +40 -12
  101. package/src/interface/finalizer/declaration.ts +30 -5
  102. package/src/interface/registry/index.ts +1 -1
  103. package/src/interface/registry/names.ts +19 -0
  104. package/src/interface/registry/schedule.ts +89 -16
  105. package/src/interface/rule/rule.type-test.ts +23 -1
  106. package/src/interface/system/check.ts +2 -0
  107. package/src/interface/system/commands.ts +8 -3
  108. package/src/interface/system/create-resolved.ts +2 -1
  109. package/src/interface/system/create.test.ts +3 -3
  110. package/src/interface/system/create.ts +2 -2
  111. package/src/interface/system/declaration.ts +10 -4
  112. package/src/interface/system/describe.test.ts +19 -0
  113. package/src/interface/system/describe.ts +1 -1
  114. package/src/interface/system/leaf.ts +4 -3
  115. package/src/interface/system/render.ts +2 -2
  116. package/src/interface/system/resolve.test.ts +17 -1
  117. package/src/interface/system/resolve.ts +40 -5
  118. package/src/interface/system/system.type-test.ts +6 -0
  119. package/src/perftest/benchmarks.ts +2 -0
  120. package/src/perftest/chained.ts +108 -0
@@ -0,0 +1,61 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, expect, it } from "vitest";
3
+ import { System } from "../../interface/system/system.js";
4
+ import { workers } from "../../interface/resources/workers.js";
5
+ import { createWorld } from "./create.js";
6
+ import { runRan, runReported } from "./outcomes.test-helper.js";
7
+
8
+ const seed = Symbol("passesTest/seed");
9
+ const event = Symbol("passesTest/event");
10
+
11
+ declare module "@adobe/ecs" {
12
+ export interface Components {
13
+ [seed]: { type: number };
14
+ [event]: { type: number };
15
+ }
16
+ }
17
+
18
+ /** What the systems below ran, in order. */
19
+ const order: string[] = [];
20
+ let firing = false;
21
+
22
+ const plant = System.create(({ commands }) => { commands.spawn([seed])({ [seed]: 1 }); });
23
+ const emit = System.create({ components: { with: [seed] } }, (row, { commands }) => {
24
+ order.push("emit");
25
+ if (firing) commands.spawn([event])({ [event]: row[seed] });
26
+ });
27
+ const handle = System.create({ components: { with: [event] }, after: [emit] }, row => { order.push(`handle ${row[event]}`); });
28
+ const finish = System.create({ after: [handle] }, () => { order.push("finish"); });
29
+ const chain = System.create([emit, handle, finish]);
30
+
31
+ const fail = System.create({ components: { with: [seed] }, after: [emit] }, () => {
32
+ order.push("fail");
33
+ throw new Error("passesTest: fail");
34
+ });
35
+ const afterFail = System.create({ after: [fail] }, () => { order.push("after fail"); });
36
+ const failing = System.create([emit, fail, afterFail]);
37
+
38
+ describe("tiers on the world's thread", () => {
39
+ it("run one after another past sync points that apply nothing, and apply one with commands before the tier after it", async () => {
40
+ expect(System.describe(chain).tiers.map(tier => tier.sync === null)).toEqual([false, true, true]);
41
+ await using world = createWorld({ [workers]: 0 });
42
+ await world.run(plant);
43
+ order.length = 0;
44
+ firing = false;
45
+ expect((await runRan(world, chain)).systems).toBe(3);
46
+ firing = true;
47
+ expect((await runRan(world, chain)).systems).toBe(3);
48
+ expect(order).toEqual(["emit", "finish", "emit", "handle 1", "finish"]);
49
+ });
50
+
51
+ it("stop at a tier whose system fails, skipping those after it", async () => {
52
+ await using world = createWorld({ [workers]: 0 });
53
+ await world.run(plant);
54
+ order.length = 0;
55
+ firing = false;
56
+ const reported = await runReported(world, failing);
57
+ expect([reported.failures.map(failure => failure.message), reported.skipped]).toEqual([["passesTest: fail"], [afterFail.name]]);
58
+ expect(order).toEqual(["emit", "fail"]);
59
+ expect((await runRan(world, failing)).systems).toBe(2);
60
+ });
61
+ });
@@ -229,6 +229,15 @@ Review fixes to shared components and `where`, node, alternating against the com
229
229
  - **A spawner's guard** against a component given in both calls, one check of each constant's key a spawn: `spawn singles shared` 323 → 323 ns, `spawn bulk shared` 18.0 → 18.1 ns.
230
230
  - **Reclaiming** (`reclaim.ts`): an entity walking one cell a run made an archetype, an interned value, a batch and spawner levels per cell, for good: after 20K cells 20,002 archetypes, 185 MB of heap and 9.1 MB of arena, and `one row after 100K cells walked`, a system over one row, 2.76 ms a run against 250 ns before the walk. Reclaimed and reused, after 100K cells: 11 archetypes, 9 values, 9 batches, 18 spawner levels, heap 29.7–29.9 MB from 1K cells on, arena 71,616 B throughout; `one row after 100K cells walked` 250 ns; `walk to a new cell` 10.3 → 2.8 µs a run. Waiting out empty sync points (`emptyFor`), `circle 8 cells`, round 8 cells, each emptied 7 sync points: 2.67–2.69 µs a run at 1, 2 and 4, reclaiming and reusing one archetype a run, 1.00 at 8 and 16, reclaiming nothing; `walk to a new cell` 2.69–2.73 µs at 1 to 4, 2.81 at 8, 3.06 at 16, scanning more candidates; `flicker between 2 cells` 958 ns at all, against 916 before reclaiming, the sync point's scan of its emptied archetypes. 8, so a cell left for up to 7 sync points keeps its archetype. Edges cached to an archetype of shared values are kept on it to clear, but a batch's archetype is checked by its generation instead: an edge object per spawned archetype, allocated between archetypes and their storages, made `where 1K cells moving` 10% slower, its 1K predicates walking archetypes spread over more memory; checked, +1.8%. Worlds without shared components, five rounds against the commit before: within noise (`run idle` 208 → 208 ns, `run observer` 125 → 125, `loop tick idle`, `move 1K`, `main thread 1K`, `sync point one spawn` 708 → 708, `create` +0.5%, `create flat`, `spawn bulk`, `despawn one`, `churn`, `add value`, `remote run`, `joined observer resource`, `joined observer 10K`, `handoff`, `tail prompt` +0.5% over eight rounds, `log replay`, `read run`); `run 50 idle` was +5% a system until the run's check for reclaimed archetypes kept the scan for new ones apart, out of what its runner inlines: 71.2 → 71.2 ns.
231
231
 
232
+ Sync points and tiers that apply nothing (adobe/data#245), node, alternating eight rounds against `244f952` with the
233
+ new benchmarks added there: eight systems over rows no entity has, each with a command it never issues. `chained
234
+ unordered`, one tier, 538 → 538 ns a run; `chained quiet`, eight tiers by `after` with no commands, 717 → 636;
235
+ `chained noting`, eight tiers whose commands no later system depends on, 723 → 636; `chained commanding`, eight tiers
236
+ and seven sync points, 859 → 713. A tier boundary on the world's thread costs about 14 ns, from 26, the tiers running
237
+ as one pass; a sync point between them with nothing to apply about 11 ns, from 20, its check of what's pending standing
238
+ for its activation, rewinds and publishing. The other node benchmarks are within noise of `244f952` over three
239
+ rounds, and `read run` (+3.7%) and `loop tick add remove again` (+3.9%), which make a schedule each run, over ten.
240
+
232
241
  ## Where the gains came from
233
242
 
234
243
  Step figures marked (vitest) are from the node vitest lane: read them as relative.
@@ -10,5 +10,8 @@ import type { Key } from './state.js';
10
10
  export const triggersOf = (item: Rule | Finalizer): { readonly keys: readonly Key[]; readonly any: boolean } => {
11
11
  if (item.kind === "rule") return { keys: item.declaration.components.has ?? [], any: false };
12
12
  if (item.kind === "finalizer") return { keys: [...item.declaration.components.read ?? [], ...item.declaration.components.has ?? []], any: false };
13
- return { keys: item.declaration.watch ?? [], any: true };
13
+ const { watch } = item.declaration;
14
+ // Every component, or those declaring a trait, may be met at any sync point.
15
+ if (watch.components === true || watch.componentTraits.length + watch.resourceTraits.length > 0) return { keys: [], any: true };
16
+ return { keys: [...watch.components, ...watch.resources], any: true };
14
17
  };
@@ -354,6 +354,23 @@ describe("what a run gathers for tests", () => {
354
354
  expect(await runRan(world, System.create([nothing, raise]))).toEqual({ systems: 2, workers: 0, joined: 0, main: true, gpu: false });
355
355
  expect(await runRan(world, readLevel)).toEqual({ systems: 0, workers: 0, joined: 0, main: false, gpu: false });
356
356
  });
357
+
358
+ it("counts each system of a chain once, run whole or entered after a sync point that applies something", async () => {
359
+ await using world = createWorld({ [workers]: 0 });
360
+ /** Systems each after the one before, the first after `first`. */
361
+ const chainOf = (length: number, first: readonly System<unknown>[]): System<unknown>[] => {
362
+ const links: System<unknown>[] = [];
363
+ for (let k = 0; k < length; k++) links.push(System.create({ after: links.length === 0 ? first : links.slice(-1) }, ({ resources }) => {
364
+ resources[frames] = k;
365
+ }));
366
+ return links;
367
+ };
368
+ expect(await runRan(world, System.create(chainOf(64, [])))).toEqual({ systems: 64, workers: 0, joined: 0, main: true, gpu: false });
369
+ const reads = System.create({ after: [spawn], components: { with: [amount] } }, row => {
370
+ counted += row[amount];
371
+ });
372
+ expect(await runRan(world, System.create([spawn, reads, ...chainOf(8, [reads])]))).toEqual({ systems: 10, workers: 0, joined: 0, main: true, gpu: false });
373
+ });
357
374
  });
358
375
 
359
376
  describe("a value a body returned", () => {
@@ -8,17 +8,25 @@ import { scheduleOf } from "../../interface/registry/schedule.js";
8
8
 
9
9
  const value = Symbol("scheduleTest/value");
10
10
  const other = Symbol("scheduleTest/other");
11
+ const voxel = Symbol("scheduleTest/voxel");
12
+ const mark = Symbol("scheduleTest/mark");
13
+ const size = Symbol("scheduleTest/size");
11
14
 
12
15
  declare module "@adobe/ecs" {
13
16
  export interface Components {
14
17
  [value]: { type: number };
15
18
  [other]: { type: number };
19
+ [voxel]: { type: true };
20
+ [mark]: { type: true };
21
+ }
22
+ export interface Resources {
23
+ [size]: { type: number };
16
24
  }
17
25
  }
18
26
 
19
27
 
20
28
  /** Systems built as values, not registered, so each test's are its own. */
21
- const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {} });
29
+ const leaf = (declaration: Resolved & { readonly name: string }): Leaf => ({ kind: "system", name: declaration.name, declaration, input: undefined, body: () => {}, commands: () => undefined });
22
30
  const composite = (declaration: Composite["declaration"] & { readonly name: string }): Composite => ({ kind: "composite", name: declaration.name, declaration });
23
31
 
24
32
  const names = (system: Definition): string[][] => scheduleOf(system).tiers.map(tier => tier.map(leaf => leaf.name));
@@ -113,6 +121,39 @@ describe("scheduleOf", () => {
113
121
  .toEqual([["a", "c"], ["b"]]);
114
122
  });
115
123
 
124
+ it("doesn't order two systems on a key both reach through rows that never match one entity", () => {
125
+ const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel], not: [mark] } });
126
+ const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } });
127
+ const watching = leaf({ name: "watching", components: { read: [value], changed: [value], has: [mark] } });
128
+ expect(names(composite({ name: "root", systems: [voxels, marks] }))).toEqual([["voxels", "marks"]]);
129
+ expect(names(composite({ name: "root", systems: [voxels, watching] }))).toEqual([["voxels", "watching"]]);
130
+ });
131
+ it("orders two systems whose rows may match one entity, or that reach the key other than through their rows", () => {
132
+ const voxels = leaf({ name: "voxels", components: { write: [value], has: [voxel] } });
133
+ const marks = leaf({ name: "marks", components: { write: [value], has: [mark] } });
134
+ expect(() => scheduleOf(composite({ name: "root", systems: [voxels, marks] })))
135
+ .toThrow(`Systems "voxels" and "marks" both access "scheduleTest/value" and one writes it; order them with before or after, or, if they never match one entity, add a component of one to the other's not.`);
136
+ const apart = leaf({ name: "apart", components: { write: [value], has: [voxel], not: [mark] } });
137
+ const looking = leaf({ name: "looking", components: { has: [mark] }, lookup: { others: { read: [value] } } });
138
+ expect(() => scheduleOf(composite({ name: "root", systems: [apart, looking] })))
139
+ .toThrow(`Systems "apart" and "looking" both access "scheduleTest/value" and one writes it; order them with before or after.`);
140
+ // An optional component is read where an entity has it, so it excludes nothing it doesn't require.
141
+ const optional = leaf({ name: "optional", components: { read: [value, mark], optional: [mark] } });
142
+ expect(() => scheduleOf(composite({ name: "root", systems: [apart, optional] }))).toThrow('"apart" and "optional"');
143
+ });
144
+
145
+ it("orders a system before or after every other that uses a resource it lists, but those already ordered and those listing it too", () => {
146
+ const resize = leaf({ name: "resize", resources: { write: [size] }, before: [size] });
147
+ const frame = leaf({ name: "frame", resources: { read: [size] } });
148
+ const pick = leaf({ name: "pick", components: { read: [value] }, resources: { is: [size] } });
149
+ const old = leaf({ name: "old", resources: { read: [size] }, before: [resize] });
150
+ expect(names(composite({ name: "root", systems: [frame, pick, resize, old] }))).toEqual([["old"], ["resize"], ["frame", "pick"]]);
151
+ const last = leaf({ name: "last", resources: { read: [size] }, after: [size] });
152
+ expect(names(composite({ name: "root", systems: [last, frame, resize] }))).toEqual([["resize"], ["frame"], ["last"]]);
153
+ const again = leaf({ name: "again", resources: { write: [size] }, before: [size] });
154
+ expect(() => scheduleOf(composite({ name: "root", systems: [resize, again, frame] }))).toThrow(`Systems "resize" and "again" both access "scheduleTest/size"`);
155
+ });
156
+
116
157
  it("schedules two systems of one name as two, and one listed twice as one", () => {
117
158
  const a = leaf({ name: "a", components: { write: [value] } });
118
159
  const copy = leaf({ name: "a", components: { write: [value] }, after: [a] });
@@ -78,6 +78,14 @@ export const countRan = (state: State, runners: readonly RanRunner[]): void => {
78
78
  for (let k = 0; k < tier.logs.length; k++) ran.joined.add(tier.logs[k]);
79
79
  };
80
80
 
81
+ /** Counts runners into `ran` that all ran here, none on a joined thread: on `main` unless a joined thread claims it. */
82
+ export const countRanHere = (state: State, systems: number, gpu: boolean): void => {
83
+ const { ran } = state;
84
+ ran.systems += systems;
85
+ if (systems !== 0 && state.claims.get("main") === undefined) ran.main = true;
86
+ if (gpu) ran.gpu = true;
87
+ };
88
+
81
89
  /** Starts gathering what a run ran. */
82
90
  export const startRan = (ran: Ran): void => {
83
91
  ran.systems = 0;
@@ -79,6 +79,11 @@ const tally = System.create({ components: { with: [grown] }, after: [sprout] },
79
79
  const garden = System.create([plant, sprout, tally]);
80
80
  const readChildren = System.create({ readonly: true }, ({ resources }) => resources[children]);
81
81
 
82
+ const spawnSeedlings = System.create(({ commands }) => { commands.spawn(Seedling)({ [seed]: 1, [grown]: 2 }); });
83
+ const countSeedlings = System.create({ components: { with: [seed, grown] }, after: [spawnSeedlings] }, (row, { resources }) => { resources[children] += row[grown]; });
84
+ /** Declared below the systems whose commands name it. */
85
+ const Seedling = [seed, grown] as const;
86
+
82
87
  describe("sync points within a run", () => {
83
88
  it("apply commands before a later system that depends on what they change, which then sees them in the same run", async () => {
84
89
  const world = createWorld({ [workers]: 0 });
@@ -122,6 +127,13 @@ describe("sync points within a run", () => {
122
127
  }
123
128
  });
124
129
 
130
+ it("follow commands naming an archetype declared below their system", async () => {
131
+ expect(System.describe(System.create([spawnSeedlings, countSeedlings])).tiers.map(tier => tier.sync?.map(reason => reason.keys) ?? null)).toEqual([[[seed, grown]], null]);
132
+ await using world = createWorld({ [workers]: 0 });
133
+ await world.run(System.create([spawnSeedlings, countSeedlings]));
134
+ expect(await world.run(readChildren)).toBe(2);
135
+ });
136
+
125
137
  it("commit a kernel's staged spawns at a sync point within the run, for a later tier", async () => {
126
138
  expect(System.describe(garden).tiers.map(tier => tier.sync === null)).toEqual([false, false, true]);
127
139
  await using world = createWorld({ [workers]: 0 });
@@ -147,10 +147,11 @@ const closedOf = (state: State, archetype: Archetype): Archetype => {
147
147
  };
148
148
 
149
149
  /**
150
- * Despawns each entity a `despawnWhere` command selects given the plan so far: those planned, by where they're going,
151
- * then the rest, by the archetype they're in. Never the resource entity.
150
+ * Despawns each entity alive before the sync point that a `despawnWhere` command selects given the plan so far: those
151
+ * planned, by where they're going, then the rest, by the archetype they're in. Never the resource entity, nor an
152
+ * entity the sync point spawns: `born` holds the first row each archetype gained at it.
152
153
  */
153
- const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean): void => {
154
+ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, within: boolean, born: ReadonlyMap<number, number>): void => {
154
155
  const { plan, archetypes } = state;
155
156
  const { ids } = buffer;
156
157
  const start = buffer.starts[command];
@@ -159,8 +160,12 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
159
160
  const where = ids[last - 1] === despawnWhereHolds ? buffer.values[last - 1] : null;
160
161
  const holds = typeof where === "function" ? where : null;
161
162
  const end = holds === null ? last : last - 1;
163
+ const { row } = state.entities;
162
164
  for (let entry = 0; entry < plan.count; entry++) {
163
- if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity) continue;
165
+ const from = plan.from[entry];
166
+ if (plan.despawned[entry] === 1 || plan.entity[entry] === state.resourceEntity || from < 0) continue;
167
+ const first = born.get(from);
168
+ if (first !== undefined && row[indexOf(plan.entity[entry])] >= first) continue;
164
169
  const to = archetypes[plan.to[entry]];
165
170
  if (matches(within && plan.to[entry] !== plan.from[entry] ? closedOf(state, to) : to, ids, start, middle, end, holds)) plan.despawned[entry] = 1;
166
171
  }
@@ -171,8 +176,9 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
171
176
  if (archetype.resource || archetype.count === 0 || !matches(archetype, ids, start, middle, end, holds)) continue;
172
177
  ensurePlanCapacity(state, plan.count + archetype.count);
173
178
  const { entities } = archetype;
174
- for (let row = 0; row < archetype.count; row++) {
175
- const entity = entities[row];
179
+ const alive = born.get(id) ?? archetype.count;
180
+ for (let at = 0; at < alive; at++) {
181
+ const entity = entities[at];
176
182
  const index = indexOf(entity);
177
183
  if (slots[index] < 0) plan.despawned[addPlanned(state, entity, index, id, id)] = 1;
178
184
  }
@@ -183,7 +189,7 @@ const despawnWhere = (state: State, buffer: CommandBuffer, command: number, with
183
189
  * Plan: the net change for each entity, in issue order. Commands against dead entities do nothing. Reference values
184
190
  * written by commands go to `written`, to check once despawns are known.
185
191
  */
186
- const planOf = (state: State, queued: readonly Queued[], written: Written): void => {
192
+ const planOf = (state: State, queued: readonly Queued[], written: Written, born: ReadonlyMap<number, number>): void => {
187
193
  const { plan, structure } = state;
188
194
  if (plan.slots.length < state.entities.length) plan.slots = new Int32Array(Math.max(state.entities.length, plan.slots.length * 2)).fill(-1);
189
195
  const { slots } = plan;
@@ -199,7 +205,7 @@ const planOf = (state: State, queued: readonly Queued[], written: Written): void
199
205
  for (let command = 0; command < buffer.count; command++) {
200
206
  const kind = kinds[command];
201
207
  if (kind === despawnWhereCommand) {
202
- despawnWhere(state, buffer, command, within);
208
+ despawnWhere(state, buffer, command, within, born);
203
209
  ensurePlanCapacity(state, plan.count + buffer.count - command);
204
210
  continue;
205
211
  }
@@ -742,7 +748,7 @@ const preparePending = (state: State, queued: readonly Queued[], isolated: boole
742
748
  if (!isolated) plan.spawned += commitBatches(state, tick, born, deriving ? close : null);
743
749
  plan.spawned += commitStaged(state, queued, tick, born, deriving ? close : null);
744
750
  const written: Written = [];
745
- planOf(state, queued, written);
751
+ planOf(state, queued, written, born);
746
752
  if (structure.rederive && !isolated) rederive(state);
747
753
  if (deriving) for (let entry = 0; entry < plan.count; entry++) if (plan.despawned[entry] === 0) plan.to[entry] = close(state.archetypes[plan.to[entry]]).id;
748
754
  let index: IndexChanges | null = null;
@@ -46,18 +46,20 @@ const tablesOf = (components: readonly Component.Key[], resources: readonly Reso
46
46
  System.create({ readonly: true, tables: { components, resources, not } }, ({ tables }): Tables => tables);
47
47
 
48
48
  const spawnedBy: { spawned: Table.Spawned } = { spawned: { entities: [], dropped: 0 } };
49
- const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap<Entity, Entity> } = { tables: [] };
49
+ const given: { tables: readonly Table[]; resources?: { readonly [key: symbol]: unknown }; existing?: ReadonlyMap<Entity, Entity>; update?: boolean } = { tables: [] };
50
50
  const spawnGiven = System.create(({ commands }) => {
51
- spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing });
51
+ spawnedBy.spawned = commands.spawnTables(given.tables, { resources: given.resources, existing: given.existing, update: given.update });
52
52
  });
53
- const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[], existing?: ReadonlyMap<Entity, Entity>): Promise<Table.Spawned> => {
53
+ const spawnTables = async (world: ReturnType<typeof inline>, tables: readonly Table[], existing?: ReadonlyMap<Entity, Entity>, update?: boolean): Promise<Table.Spawned> => {
54
54
  given.tables = tables;
55
55
  given.existing = existing;
56
+ given.update = update;
56
57
  try {
57
58
  await world.run(spawnGiven);
58
59
  }
59
60
  finally {
60
61
  given.existing = undefined;
62
+ given.update = undefined;
61
63
  }
62
64
  return spawnedBy.spawned;
63
65
  };
@@ -247,6 +249,37 @@ describe("tables", () => {
247
249
  expect((await readAll(other)).find(entry => entry.label === "owned")).toMatchObject({ parent: existingSteel, target: existingSteel });
248
250
  });
249
251
 
252
+ it("write a row existing maps onto its entity with update, as an add would, entity values rewritten as a spawned row's", async () => {
253
+ await using world = inline();
254
+ let steel = Entity.none;
255
+ await world.run(System.create(({ commands }) => {
256
+ const inside = commands.spawn([label])({ [label]: "inside" });
257
+ const outside = commands.spawn([pinned])();
258
+ steel = commands.spawn([label, position, buddy, target, link, team], { [team]: "red" })({ [label]: "steel", [position]: [1, 2, 3], [buddy]: inside, [target]: outside, [link]: { to: inside, weight: 4 } });
259
+ }));
260
+ const { archetypes } = await world.run(tablesOf([label, position, buddy, target, link, team]));
261
+ await using other = inline();
262
+ let mine = Entity.none, kept = Entity.none;
263
+ await other.run(System.create(({ commands }) => {
264
+ kept = commands.spawn([label])({ [label]: "kept" });
265
+ mine = commands.spawn([label, hidden, target, team], { [team]: "blue" })({ [label]: "mine", [target]: kept });
266
+ }));
267
+ const changed: string[] = [];
268
+ const watch = System.create({ components: { with: [label], changed: [label] } }, row => { changed.push(row[label]); });
269
+ await other.run(watch);
270
+ changed.length = 0;
271
+ const { entities, dropped } = await spawnTables(other, archetypes, new Map([[steel, mine]]), true);
272
+ expect([dropped, entities.flatMap(list => [...list]).includes(mine)]).toEqual([0, true]);
273
+ const read = await readAll(other);
274
+ const inside = read.find(entry => entry.label === "inside")?.entity;
275
+ // The weak reference to an entity outside the tables is left out, so the entity's own stands.
276
+ expect(read.find(entry => entry.entity === mine)).toMatchObject({ label: "steel", position: [1, 2, 3], buddy: inside, target: kept, link: { to: inside, weight: 4 } });
277
+ const teams = await other.run(System.create({ readonly: true, tables: { components: [team, hidden] } }, ({ tables }) => tables.archetypes.map(table => [table.columns[team], table.columns[hidden] !== undefined])));
278
+ expect(teams).toEqual([[{ kind: "value", value: "red" }, true]]);
279
+ await other.run(watch);
280
+ expect(changed.sort()).toEqual(["inside", "steel"]);
281
+ });
282
+
250
283
  it("refuse existing mapping to an entity that isn't alive, spawning nothing", async () => {
251
284
  await using world = inline();
252
285
  const gone = (await spawnLabels(world, ["gone"])).get("gone") ?? Entity.none;
@@ -1,5 +1,5 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
- import type { Layout, Path } from '../../interface/registry/layout.js';
2
+ import { viewsOf, type Layout, type Path } from '../../interface/registry/layout.js';
3
3
  import { declarationOf, labelOf } from '../../interface/registry/names.js';
4
4
  import { isEntity } from '../../interface/registry/schemas.js';
5
5
  import type { Schema } from '../../interface/schema/schema.js';
@@ -8,7 +8,8 @@ import { batchOf } from './batches.js';
8
8
  import { indexOf, locate, reserveRun } from './entities.js';
9
9
  import { internShared } from './shared.js';
10
10
  import { claimRows, openRows, writerOf, type Column as BatchColumn } from './staging.js';
11
- import { commandIdOf, type Key, type State } from './state.js';
11
+ import { commandIdOf, storedShared, type Key, type State } from './state.js';
12
+ import { addCommand, pushCommand, pushComponent, type CommandBuffer } from './buffer.js';
12
13
 
13
14
  /** Whether a value of `schema` may hold an entity anywhere in it. */
14
15
  const holdsEntity = (schema: Schema): boolean => {
@@ -209,6 +210,45 @@ const spawnRows = (state: State, { table, keys, ids, shared }: Planned, rows: re
209
210
  claimRows(batch, entities);
210
211
  };
211
212
 
213
+ /** A linear value read out of a table's bytes, copied to row 0 here first; grown for larger layouts. */
214
+ let scratch = viewsOf(new ArrayBuffer(256), 0, 256);
215
+
216
+ /** A table's value of a component in a row: its column's one value, its value there, or one read out of its bytes. */
217
+ const valueIn = (layout: Layout, column: Table.Column, row: number): unknown => {
218
+ if (column.kind === "value") return column.value;
219
+ if (column.kind === "values") return column.values[row];
220
+ if (layout.kind !== "linear") return undefined;
221
+ const { stride } = layout;
222
+ if (stride > scratch.u8.length) scratch = viewsOf(new ArrayBuffer(stride * 2), 0, stride * 2);
223
+ scratch.u8.set(column.bytes.subarray(row * stride, (row + 1) * stride));
224
+ return layout.read(scratch, 0);
225
+ };
226
+
227
+ /**
228
+ * Queues an add of each of its table's components onto the entity standing for a row `existing` maps, its entity
229
+ * values remapped, but a reference whose target isn't there.
230
+ */
231
+ const updateRows = (state: State, { table, keys, ids }: Planned, rows: readonly number[], standing: Float64Array, buffer: CommandBuffer, remap: Remap): void => {
232
+ const columns: Table.Column[] = keys.map(key => Reflect.get(table.columns, key));
233
+ const schemas = keys.map(key => declarationOf(key)?.type);
234
+ const references = keys.map(key => declarationOf(key)?.reference !== undefined);
235
+ for (const row of rows) {
236
+ pushCommand(buffer, addCommand, standing[row]);
237
+ for (let k = 0; k < keys.length; k++) {
238
+ const id = ids[k];
239
+ const schema = schemas[k];
240
+ let value = valueIn(state.layouts[id], columns[k], row);
241
+ if (references[k]) {
242
+ const to = remap(Number(value));
243
+ if (to === 0 && value !== 0) continue;
244
+ value = to;
245
+ }
246
+ else if (schema !== undefined && holdsEntity(schema)) value = remapValue(schema, value, remap);
247
+ pushComponent(buffer, id, state.shared[id] === undefined ? value : storedShared(state, id, value));
248
+ }
249
+ }
250
+ };
251
+
212
252
  /** Throws for an entity `existing` maps to that isn't alive in this world. */
213
253
  const checkExisting = (state: State, existing: ReadonlyMap<number, number>): void => {
214
254
  for (const [source, target] of existing) {
@@ -223,7 +263,7 @@ const checkExisting = (state: State, existing: ReadonlyMap<number, number>): voi
223
263
  * value naming an entity spawned names the one spawned for it. A row whose entity `existing` maps isn't spawned: the
224
264
  * entity it maps to stands for it, as it does for an entity outside the tables that `existing` maps.
225
265
  */
226
- export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap<number, number> = new Map()): Table.Spawned => {
266
+ export const spawnTables = (state: State, tables: readonly Table[], existing: ReadonlyMap<number, number> = new Map(), updates: CommandBuffer | null = null): Table.Spawned => {
227
267
  const plans = tables.map(table => planOf(state, table));
228
268
  checkExisting(state, existing);
229
269
  // Entities are mapped only where a value of the tables may hold one, or a row is one that exists.
@@ -342,6 +382,11 @@ export const spawnTables = (state: State, tables: readonly Table[], existing: Re
342
382
  const target = targetAt(row);
343
383
  return target !== 0 && !isSpawned(target);
344
384
  });
385
+ if (updates !== null && !wholly[t]) {
386
+ const mapped: number[] = [];
387
+ for (let row = 0; row < table.count; row++) if (existingAt[first + row] !== 0) mapped.push(row);
388
+ if (mapped.length > 0) updateRows(state, plan, mapped, entities[t], updates, remap);
389
+ }
345
390
  let whole = wholly[t];
346
391
  if (weak.length > 0) for (let row = 0; row < table.count && whole; row++) if (loses(row)) whole = false;
347
392
  if (whole) return spawnRows(state, plan, null, none, entities[t], remap);
@@ -0,0 +1,14 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { System } from "../../interface/system/system.js";
3
+ import { inDocument } from "./watch-traits.test-helper.js";
4
+
5
+ /** A resource of the document's, registered once this module is imported. */
6
+ export const margin = Symbol("watchTraits/margin");
7
+
8
+ declare module "@adobe/ecs" {
9
+ export interface Resources {
10
+ [margin]: { type: number; [inDocument]: true };
11
+ }
12
+ }
13
+
14
+ export const widen = System.create(({ resources }) => { resources[margin] = 5; });
@@ -0,0 +1,10 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+
3
+ /** A trait of the document's: what changes finalizers watching it see. */
4
+ export const inDocument = Symbol("watchTraits/inDocument");
5
+
6
+ declare module "@adobe/ecs" {
7
+ export interface Traits<N> {
8
+ readonly [inDocument]: N extends unknown ? true : never;
9
+ }
10
+ }
@@ -0,0 +1,62 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { expect, it } from "vitest";
3
+ import { Finalizer } from "../../interface/finalizer/finalizer.js";
4
+ import { System } from "../../interface/system/system.js";
5
+ import { createWorld } from "../index.js";
6
+ import { readResource } from "./reads.test-helper.js";
7
+ import { inDocument } from "./watch-traits.test-helper.js";
8
+
9
+ const title = Symbol("watchTraits/title");
10
+ const cursor = Symbol("watchTraits/cursor");
11
+ const zoom = Symbol("watchTraits/zoom");
12
+ const theme = Symbol("watchTraits/theme");
13
+ const saved = Symbol("watchTraits/saved");
14
+ const recorded = Symbol("watchTraits/recorded");
15
+
16
+ declare module "@adobe/ecs" {
17
+ interface Components {
18
+ [title]: { type: string; [inDocument]: true };
19
+ [cursor]: { type: number };
20
+ }
21
+ interface Resources {
22
+ [zoom]: { type: number; default: 1; [inDocument]: true };
23
+ [theme]: { type: string };
24
+ [saved]: { type: readonly string[]; default: [] };
25
+ [recorded]: { type: readonly string[]; default: [] };
26
+ }
27
+ }
28
+
29
+ /** What a change set holds, as lines: each group's components added and removed, and each resource written. */
30
+ const linesOf = (changes: { readonly groups: readonly { readonly added: readonly symbol[]; readonly removed: readonly symbol[] }[]; readonly resources: readonly { readonly key: symbol; readonly previous: unknown; readonly next: unknown }[] }): string[] => [
31
+ ...changes.groups.map(group => [...group.added.map(key => `+${key.description}`), ...group.removed.map(key => `-${key.description}`)].join(" ")),
32
+ ...changes.resources.map(change => `${change.key.description} ${String(change.previous)} → ${String(change.next)}`),
33
+ ];
34
+
35
+ Finalizer.changes({ watch: [inDocument] }, (changes, { resources }) => {
36
+ resources[saved] = [...resources[saved], ...linesOf(changes)];
37
+ });
38
+ Finalizer.changes({ watch: { components: true, resources: [theme] } }, (changes, { resources }) => {
39
+ resources[recorded] = [...resources[recorded], ...linesOf(changes)];
40
+ });
41
+
42
+ const spawn = System.create(({ commands }) => { commands.spawn([title, cursor])({ [title]: "notes", [cursor]: 0 }); });
43
+ const edit = System.create(({ resources }) => {
44
+ resources[zoom] = 2;
45
+ resources[theme] = "dark";
46
+ });
47
+
48
+ it("watches each component and resource declaring a trait, and every component with the resources chosen", async () => {
49
+ await using world = createWorld();
50
+ await world.run(spawn);
51
+ await world.run(edit);
52
+ expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/zoom 1 → 2"]);
53
+ expect(await readResource(world, recorded)).toEqual(["+watchTraits/title +watchTraits/cursor", "watchTraits/theme → dark"]);
54
+ });
55
+
56
+ it("watches a resource declaring a trait it watches that registers after the world started", async () => {
57
+ await using world = createWorld();
58
+ await world.run(spawn);
59
+ const { widen } = await import("./watch-traits-late.test-helper.js");
60
+ await world.run(widen);
61
+ expect(await readResource(world, saved)).toEqual(["+watchTraits/title", "watchTraits/margin 0 → 5"]);
62
+ });
@@ -5,9 +5,11 @@ import type { Commands } from '../system/commands.js';
5
5
  import type { Listed, Property } from '../system/property.js';
6
6
  import type { LookupFunction } from '../system/values.js';
7
7
  import type { WriteResources } from '../system/body.js';
8
- import type { ChangesDeclaration, Declaration } from './declaration.js';
8
+ import type { ChangesDeclaration, Declaration, OwnTrait } from './declaration.js';
9
9
  import type { Essential, ReadonlyType } from '../component/value.js';
10
10
  import type { Resource } from '../resource/resource.js';
11
+ import type { Resources } from '../resource/types.js';
12
+ import type { Components } from '../component/types.js';
11
13
  import type { ReadonlyType as ResourceType } from '../resource/value.js';
12
14
  import type { Elsewhere, ThreadsOf } from '../system/threads.js';
13
15
 
@@ -30,11 +32,28 @@ export type Context<D extends Declaration | ChangesDeclaration> =
30
32
  })
31
33
  & (true extends Elsewhere<ThreadsOf<D>> ? {} : { readonly commands: Commands });
32
34
 
35
+ /** The components declaring any of the traits `T`. */
36
+ type ComponentsDeclaring<T> = { [K in Component.Key]: [Extract<keyof Components[K], T>] extends [never] ? never : K }[Component.Key];
37
+
38
+ /** The resources declaring any of the traits `T`. */
39
+ type ResourcesDeclaring<T> = { [K in Resource.Key]: [Extract<keyof Resources[K], T>] extends [never] ? never : K }[Resource.Key];
40
+
41
+ /** The components keys `L` name: each, and each declaring a trait among them. */
42
+ type ComponentsOf<L> = Extract<L, Essential> | Extract<ComponentsDeclaring<Extract<L, OwnTrait>>, Essential>;
43
+
44
+ /** The resources keys `L` name: each, and each declaring a trait among them. */
45
+ type ResourcesOf<L> = Extract<L, Resource.Key> | ResourcesDeclaring<Extract<L, OwnTrait>>;
46
+
47
+ /** The keys a changes finalizer's `watch` lists, by kind: those of its list, or of its object's lists. */
48
+ export type WatchListed<D extends ChangesDeclaration, Of extends "components" | "resources"> = Property<D, "watch"> extends readonly (infer E)[] ? E
49
+ : Property<D, "watch"> extends { readonly [P in Of]: readonly (infer E)[] } ? E : never;
50
+
33
51
  /** The components a changes finalizer sees. */
34
- export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly unknown[] ? Listed<D, "watch"> & Essential : Essential;
52
+ export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly unknown[] | { readonly components: readonly unknown[] }
53
+ ? ComponentsOf<WatchListed<D, "components">> : Essential;
35
54
 
36
55
  /** The resources a changes finalizer sees: those it watches. */
37
- export type WatchedResources<D extends ChangesDeclaration> = Listed<D, "watch"> & Resource.Key;
56
+ export type WatchedResources<D extends ChangesDeclaration> = ResourcesOf<WatchListed<D, "resources">>;
38
57
 
39
58
  /** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */
40
59
  export type Group<C extends Component.Key> = {
@@ -75,7 +75,10 @@ describe("Finalizer.create and Finalizer.changes", () => {
75
75
  expect(() => untyped(Finalizer.create, { components: {} }, { resources: { reduce: [total] } })).toThrow("only systems contribute to a reduction");
76
76
  expect(() => untyped(Finalizer.create, { components: { with: [node, tags] } }, { read: [node, tags] })).toThrow('uses "typesFinalizerTest/node" on "main" and "typesFinalizerTest/tags" on "world"');
77
77
  expect(() => untyped(Finalizer.changes, { name: "derived", watch: [slot] })).toThrow("which is derived");
78
- expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component or resource");
78
+ expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component, resource or trait");
79
+ expect(() => untyped(Finalizer.changes, { watch: { components: [time], resources: [] } })).toThrow(`watches "typesFinalizerTest/time" among its components, which isn't a registered component or trait`);
80
+ expect(() => untyped(Finalizer.changes, { watch: { components: true, resources: [label] } })).toThrow(`watches "typesFinalizerTest/label" among its resources, which isn't a registered resource or trait`);
81
+ expect(() => untyped(Finalizer.changes, { watch: { components: [slot], resources: [] } })).toThrow("which is derived");
79
82
  expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`);
80
83
  expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component");
81
84
  expect(programFinalizers()).toHaveLength(7);