@adobe/ecs 0.2.3 → 0.2.4

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 (179) hide show
  1. package/dist/implementation/compiler/body-access.d.ts +18 -1
  2. package/dist/implementation/compiler/body-access.js +104 -15
  3. package/dist/implementation/compiler/body-access.js.map +1 -1
  4. package/dist/implementation/compiler/estree.d.ts +5 -2
  5. package/dist/implementation/compiler/estree.js +8 -5
  6. package/dist/implementation/compiler/estree.js.map +1 -1
  7. package/dist/implementation/compiler/inferred.js +4 -2
  8. package/dist/implementation/compiler/inferred.js.map +1 -1
  9. package/dist/implementation/compiler/transform.js +5 -4
  10. package/dist/implementation/compiler/transform.js.map +1 -1
  11. package/dist/implementation/world/activate.js +14 -2
  12. package/dist/implementation/world/activate.js.map +1 -1
  13. package/dist/implementation/world/buffer.d.ts +7 -0
  14. package/dist/implementation/world/buffer.js +7 -0
  15. package/dist/implementation/world/buffer.js.map +1 -1
  16. package/dist/implementation/world/cascade.d.ts +10 -3
  17. package/dist/implementation/world/cascade.js +48 -23
  18. package/dist/implementation/world/cascade.js.map +1 -1
  19. package/dist/implementation/world/commands.js +40 -5
  20. package/dist/implementation/world/commands.js.map +1 -1
  21. package/dist/implementation/world/create.js +53 -2
  22. package/dist/implementation/world/create.js.map +1 -1
  23. package/dist/implementation/world/execute.d.ts +4 -4
  24. package/dist/implementation/world/execute.js +132 -83
  25. package/dist/implementation/world/execute.js.map +1 -1
  26. package/dist/implementation/world/finalizers.d.ts +2 -0
  27. package/dist/implementation/world/finalizers.js +24 -3
  28. package/dist/implementation/world/finalizers.js.map +1 -1
  29. package/dist/implementation/world/join.js +12 -2
  30. package/dist/implementation/world/join.js.map +1 -1
  31. package/dist/implementation/world/resource-changes.d.ts +18 -0
  32. package/dist/implementation/world/resource-changes.js +66 -0
  33. package/dist/implementation/world/resource-changes.js.map +1 -0
  34. package/dist/implementation/world/rows.js +21 -16
  35. package/dist/implementation/world/rows.js.map +1 -1
  36. package/dist/implementation/world/run-changes.d.ts +38 -0
  37. package/dist/implementation/world/run-changes.js +138 -0
  38. package/dist/implementation/world/run-changes.js.map +1 -0
  39. package/dist/implementation/world/runner.js +16 -12
  40. package/dist/implementation/world/runner.js.map +1 -1
  41. package/dist/implementation/world/state.d.ts +13 -0
  42. package/dist/implementation/world/state.js +13 -1
  43. package/dist/implementation/world/state.js.map +1 -1
  44. package/dist/implementation/world/synchronize.js +72 -22
  45. package/dist/implementation/world/synchronize.js.map +1 -1
  46. package/dist/implementation/world/tables-read.d.ts +5 -3
  47. package/dist/implementation/world/tables-read.js +16 -4
  48. package/dist/implementation/world/tables-read.js.map +1 -1
  49. package/dist/implementation/world/tables.d.ts +3 -2
  50. package/dist/implementation/world/tables.js +83 -37
  51. package/dist/implementation/world/tables.js.map +1 -1
  52. package/dist/implementation/world/thread-observer.js +1 -1
  53. package/dist/implementation/world/thread-observer.js.map +1 -1
  54. package/dist/implementation/world/thread-systems.js +10 -6
  55. package/dist/implementation/world/thread-systems.js.map +1 -1
  56. package/dist/implementation/world/up.js +7 -1
  57. package/dist/implementation/world/up.js.map +1 -1
  58. package/dist/implementation/world/where.d.ts +8 -0
  59. package/dist/implementation/world/where.js +40 -11
  60. package/dist/implementation/world/where.js.map +1 -1
  61. package/dist/interface/finalizer/body.d.ts +18 -3
  62. package/dist/interface/finalizer/create.d.ts +7 -5
  63. package/dist/interface/finalizer/create.js +8 -5
  64. package/dist/interface/finalizer/create.js.map +1 -1
  65. package/dist/interface/finalizer/declaration.d.ts +4 -2
  66. package/dist/interface/indexes/declaration-of.d.ts +17 -0
  67. package/dist/interface/indexes/declaration-of.js +20 -0
  68. package/dist/interface/indexes/declaration-of.js.map +1 -0
  69. package/dist/interface/indexes/index.d.ts +1 -0
  70. package/dist/interface/indexes/index.js +1 -0
  71. package/dist/interface/indexes/index.js.map +1 -1
  72. package/dist/interface/registry/schedule.d.ts +28 -1
  73. package/dist/interface/registry/schedule.js +182 -21
  74. package/dist/interface/registry/schedule.js.map +1 -1
  75. package/dist/interface/system/access.d.ts +15 -0
  76. package/dist/interface/system/check.d.ts +7 -0
  77. package/dist/interface/system/check.js +17 -3
  78. package/dist/interface/system/check.js.map +1 -1
  79. package/dist/interface/system/commands.d.ts +26 -5
  80. package/dist/interface/system/create-resolved.js +1 -1
  81. package/dist/interface/system/create-resolved.js.map +1 -1
  82. package/dist/interface/system/create.d.ts +10 -6
  83. package/dist/interface/system/create.js +5 -4
  84. package/dist/interface/system/create.js.map +1 -1
  85. package/dist/interface/system/describe.d.ts +120 -0
  86. package/dist/interface/system/describe.js +116 -0
  87. package/dist/interface/system/describe.js.map +1 -0
  88. package/dist/interface/system/index.d.ts +1 -0
  89. package/dist/interface/system/index.js +1 -0
  90. package/dist/interface/system/index.js.map +1 -1
  91. package/dist/interface/system/leaf.d.ts +6 -0
  92. package/dist/interface/system/render.js +1 -0
  93. package/dist/interface/system/render.js.map +1 -1
  94. package/dist/interface/system/resolve.d.ts +5 -1
  95. package/dist/interface/system/resolve.js +19 -1
  96. package/dist/interface/system/resolve.js.map +1 -1
  97. package/dist/interface/system/shared-only.d.ts +7 -0
  98. package/dist/interface/system/shared-only.js +2 -0
  99. package/dist/interface/system/shared-only.js.map +1 -0
  100. package/dist/interface/system/tables.d.ts +9 -1
  101. package/dist/interface/world/table/index.d.ts +3 -2
  102. package/dist/interface/world/world.d.ts +15 -0
  103. package/package.json +11 -2
  104. package/src/guide/03-systems.out +1 -1
  105. package/src/guide/06-sync-points.md +12 -8
  106. package/src/guide/06-sync-points.out +1 -2
  107. package/src/guide/06-sync-points.ts +9 -5
  108. package/src/guide/18-identity.out +1 -1
  109. package/src/guide/guide.test.ts +3 -150
  110. package/src/implementation/compiler/body-access.ts +102 -12
  111. package/src/implementation/compiler/estree.ts +9 -6
  112. package/src/implementation/compiler/inferred.test.ts +1 -1
  113. package/src/implementation/compiler/inferred.ts +4 -2
  114. package/src/implementation/compiler/transform.test.ts +41 -21
  115. package/src/implementation/compiler/transform.ts +5 -3
  116. package/src/implementation/world/activate.ts +11 -2
  117. package/src/implementation/world/batches.test.ts +3 -3
  118. package/src/implementation/world/buffer.ts +7 -0
  119. package/src/implementation/world/cascade.ts +46 -22
  120. package/src/implementation/world/commands.ts +36 -5
  121. package/src/implementation/world/create.test.ts +8 -8
  122. package/src/implementation/world/create.ts +57 -2
  123. package/src/implementation/world/despawn-where.test.ts +287 -0
  124. package/src/implementation/world/execute.ts +125 -78
  125. package/src/implementation/world/finalizers.ts +27 -4
  126. package/src/implementation/world/ids.test.ts +1 -1
  127. package/src/implementation/world/indexes.test.ts +2 -1
  128. package/src/implementation/world/join.test.ts +19 -0
  129. package/src/implementation/world/join.ts +16 -2
  130. package/src/implementation/world/names.test.ts +1 -2
  131. package/src/implementation/world/perf.md +3 -1
  132. package/src/implementation/world/resource-changes.test.ts +61 -0
  133. package/src/implementation/world/resource-changes.ts +67 -0
  134. package/src/implementation/world/rows.ts +21 -14
  135. package/src/implementation/world/rules.test.ts +37 -0
  136. package/src/implementation/world/run-changes.test.ts +88 -0
  137. package/src/implementation/world/run-changes.ts +160 -0
  138. package/src/implementation/world/runner.ts +17 -13
  139. package/src/implementation/world/shared.test.ts +2 -2
  140. package/src/implementation/world/state.ts +19 -0
  141. package/src/implementation/world/sync-points.test.ts +135 -0
  142. package/src/implementation/world/synchronize.ts +65 -18
  143. package/src/implementation/world/tables-read.ts +16 -5
  144. package/src/implementation/world/tables.test.ts +106 -4
  145. package/src/implementation/world/tables.ts +71 -33
  146. package/src/implementation/world/thread-observer.ts +1 -1
  147. package/src/implementation/world/thread-systems.ts +10 -6
  148. package/src/implementation/world/up.test.ts +13 -1
  149. package/src/implementation/world/up.ts +6 -1
  150. package/src/implementation/world/where.ts +38 -10
  151. package/src/interface/finalizer/body.ts +13 -3
  152. package/src/interface/finalizer/create.test.ts +3 -1
  153. package/src/interface/finalizer/create.ts +12 -7
  154. package/src/interface/finalizer/declaration.ts +4 -2
  155. package/src/interface/indexes/create.test.ts +10 -0
  156. package/src/interface/indexes/declaration-of.ts +34 -0
  157. package/src/interface/indexes/index.ts +1 -0
  158. package/src/interface/registry/schedule.ts +196 -23
  159. package/src/interface/system/access.ts +16 -0
  160. package/src/interface/system/check.ts +15 -2
  161. package/src/interface/system/commands.ts +23 -4
  162. package/src/interface/system/commands.type-test.ts +31 -0
  163. package/src/interface/system/create-resolved.ts +1 -1
  164. package/src/interface/system/create.test.ts +8 -0
  165. package/src/interface/system/create.ts +9 -5
  166. package/src/interface/system/describe.test.ts +79 -0
  167. package/src/interface/system/describe.ts +122 -0
  168. package/src/interface/system/index.ts +1 -0
  169. package/src/interface/system/leaf.ts +6 -0
  170. package/src/interface/system/render.ts +1 -0
  171. package/src/interface/system/resolve.ts +25 -2
  172. package/src/interface/system/shared-only.ts +5 -0
  173. package/src/interface/system/tables.ts +7 -1
  174. package/src/interface/world/run.type-test.ts +11 -1
  175. package/src/interface/world/table/index.ts +3 -2
  176. package/src/interface/world/world.ts +13 -0
  177. package/src/perftest/benchmarks.ts +18 -0
  178. package/src/perftest/structural.ts +20 -0
  179. package/src/perftest/tables.ts +107 -0
@@ -0,0 +1,67 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { readCell } from './cells.js';
3
+ import { indexOf } from './entities.js';
4
+ import type { Storage } from './archetype.js';
5
+ import type { State } from './state.js';
6
+
7
+ /** A watched resource written to a value that isn't equal to the one its finalizers last saw. */
8
+ export type ResourceChange = { readonly id: number; readonly previous: unknown; readonly next: unknown };
9
+
10
+ const noChanges: readonly ResourceChange[] = [];
11
+
12
+ /** Where resource `id` lives now, or null before the world has it. */
13
+ const cellOf = (state: State, id: number): { readonly storage: Storage; readonly row: number } | null => {
14
+ if (state.resourceEntity < 0) return null;
15
+ const index = indexOf(state.resourceEntity);
16
+ const storage = state.archetypes[state.entities.archetype[index]]?.storageById[id];
17
+ return storage === undefined ? null : { storage, row: state.entities.row[index] };
18
+ };
19
+
20
+ /** Starts following resource `id` for a changes finalizer watching it, from its value now. */
21
+ export const watchResource = (state: State, id: number): void => {
22
+ if (state.resourcesSeen.has(id)) return;
23
+ const cell = cellOf(state, id);
24
+ state.resourcesSeen.set(id, cell === null ? { value: state.starts[id], tick: 0 } : { value: readCell(cell.storage, cell.row), tick: cell.storage.changed[cell.row] });
25
+ };
26
+
27
+ /** Whether a watched resource was written since its finalizers last ran, so a sync point is due for them to see it. */
28
+ export const resourcesWritten = (state: State): boolean => {
29
+ for (const [id, seen] of state.resourcesSeen) {
30
+ const cell = cellOf(state, id);
31
+ if (cell !== null && cell.storage.changed[cell.row] > seen.tick) return true;
32
+ }
33
+ return false;
34
+ };
35
+
36
+ /**
37
+ * Each watched resource written since its finalizers last ran, to a value that isn't equal to the one they saw then,
38
+ * with that value and its value now, which they see from now on.
39
+ */
40
+ export const takeResourceChanges = (state: State): readonly ResourceChange[] => {
41
+ let changes: ResourceChange[] | null = null;
42
+ for (const [id, seen] of state.resourcesSeen) {
43
+ const cell = cellOf(state, id);
44
+ if (cell === null) continue;
45
+ const tick = cell.storage.changed[cell.row];
46
+ if (tick <= seen.tick) continue;
47
+ const next = readCell(cell.storage, cell.row);
48
+ if (!sameValue(seen.value, next)) (changes ??= []).push({ id, previous: seen.value, next });
49
+ state.resourcesSeen.set(id, { value: next, tick });
50
+ }
51
+ return changes ?? noChanges;
52
+ };
53
+
54
+ const isPlain = (value: object): boolean => {
55
+ const prototype: unknown = Object.getPrototypeOf(value);
56
+ return prototype === Object.prototype || prototype === null;
57
+ };
58
+
59
+ /** Whether two values are equal: data leaf by leaf, as `Object.is` compares leaves, and any other object by identity. */
60
+ export const sameValue = (a: unknown, b: unknown): boolean => {
61
+ if (Object.is(a, b)) return true;
62
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
63
+ if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((item, i) => sameValue(item, b[i]));
64
+ if (Array.isArray(b) || !isPlain(a) || !isPlain(b)) return false;
65
+ const keys = Object.keys(a);
66
+ return keys.length === Object.keys(b).length && keys.every(key => Object.hasOwn(b, key) && sameValue(Reflect.get(a, key), Reflect.get(b, key)));
67
+ };
@@ -12,7 +12,7 @@ import { ownTag } from './move-log.js';
12
12
  import { resourceCell } from './resources.js';
13
13
  import { labelOf } from '../../interface/registry/names.js';
14
14
  import { idOf, type Key, type State } from './state.js';
15
- import { readTables } from './tables-read.js';
15
+ import { tablesReader } from './tables-read.js';
16
16
  import { combine, contributed, identityOf, type ReductionTarget } from './reductions.js';
17
17
  import { createIndexLookup, type Since } from './index-lookups.js';
18
18
  import type { WorldIndex } from './indexes.js';
@@ -131,13 +131,26 @@ const viewOf = (storage: RowStorage): TypedArray => {
131
131
  return layout.kind === "linear" && storage.write !== writeGeneral ? storage.views[layout.leaves[0].view] : storage.views.f32;
132
132
  };
133
133
 
134
- const slotOf = (archetype: RowArchetype, id: number): Slot => {
134
+ const storageOf = (archetype: RowArchetype, id: number): RowStorage => {
135
135
  const storage = archetype.storageById[id];
136
136
  // A system matches only archetypes with every component it reads or writes.
137
137
  if (storage === undefined) throw new Error(`Archetype ${archetype.id} lacks component ${id}.`);
138
+ return storage;
139
+ };
140
+
141
+ const slotOf = (archetype: RowArchetype, id: number): Slot => {
142
+ const storage = storageOf(archetype, id);
138
143
  return { storage, view: viewOf(storage), value: constantOf(storage) };
139
144
  };
140
145
 
146
+ /** Points `slot` at `archetype`'s storage of component `id`, allocating nothing, since rows bind once per archetype a run. */
147
+ const bindSlot = (slot: Slot, archetype: RowArchetype, id: number): void => {
148
+ const storage = storageOf(archetype, id);
149
+ slot.storage = storage;
150
+ slot.view = viewOf(storage);
151
+ slot.value = constantOf(storage);
152
+ };
153
+
141
154
  /** An optional component's getter where the archetype lacks it, and its setter, which fails its system. */
142
155
  const absentGetter = (): undefined => undefined;
143
156
  const absentSetterOf = (key: Key): (value: unknown) => void => () => {
@@ -168,11 +181,7 @@ export const createRow = (host: RowHost, archetype: RowArchetype, keys: readonly
168
181
  row.entities = next.entities;
169
182
  for (let i = 0; i < ids.length; i++) {
170
183
  const slot = slots[i];
171
- if (slot === undefined) continue;
172
- const { storage, view, value } = slotOf(next, ids[i]);
173
- slot.storage = storage;
174
- slot.view = view;
175
- slot.value = value;
184
+ if (slot !== undefined) bindSlot(slot, next, ids[i]);
176
185
  }
177
186
  },
178
187
  };
@@ -271,12 +280,7 @@ export const sharedRows = (host: RowHost, keys: readonly Key[], ids: readonly nu
271
280
  slots: ids.map(id => slotOf(archetype, id)),
272
281
  bind: next => {
273
282
  row.entities = next.entities;
274
- for (let i = 0; i < ids.length; i++) {
275
- const { storage, view, value } = slotOf(next, ids[i]);
276
- row.slots[i].storage = storage;
277
- row.slots[i].view = view;
278
- row.slots[i].value = value;
279
- }
283
+ for (let i = 0; i < ids.length; i++) bindSlot(row.slots[i], next, ids[i]);
280
284
  },
281
285
  };
282
286
  Object.defineProperty(value, rowOfValue, { value: row });
@@ -511,7 +515,10 @@ export const createContext = (state: State, declaration: Pick<Resolved, "lookup"
511
515
  const value: { up?: unknown } = own();
512
516
  const { tables } = declaration;
513
517
  // Each read copies the tables as they are then.
514
- if (tables !== undefined) Object.defineProperty(value, "tables", { get: () => readTables(state, tables), enumerable: true });
518
+ if (tables !== undefined) {
519
+ const read = tablesReader(state, tables);
520
+ Object.defineProperty(value, "tables", { get: read, enumerable: true });
521
+ }
515
522
  const { lookup, resources } = declaration;
516
523
  const names = Object.keys(lookup ?? {});
517
524
  if (names.length > 0) {
@@ -226,6 +226,43 @@ describe("references", () => {
226
226
  await world.dispose();
227
227
  });
228
228
 
229
+ it("cascade through entities spawned together, whichever archetype each is spawned into first", async () => {
230
+ const world = createWorld({ [workers]: 0 });
231
+ const step = at(world);
232
+ let gone = Entity.none, owner = Entity.none, child = Entity.none, watcher = Entity.none;
233
+ await step(commands => { gone = commands.spawn([mark])({ [mark]: 0 }); });
234
+ await step(commands => commands.despawn(gone));
235
+ await step(commands => {
236
+ // The archetypes of the child and the watcher take their first spawns before the owner's does.
237
+ commands.spawn([mark, owned])({ [mark]: 1, [owned]: Entity.none });
238
+ commands.spawn([mark, follows])({ [mark]: 2, [follows]: Entity.none });
239
+ owner = commands.spawn([mark, owned, sprite])({ [mark]: 3, [owned]: gone });
240
+ child = commands.spawn([mark, owned])({ [mark]: 4, [owned]: owner });
241
+ watcher = commands.spawn([mark, follows])({ [mark]: 5, [follows]: owner });
242
+ });
243
+ expect(await read(world, owner, mark)).toBeNull();
244
+ expect(await read(world, child, mark)).toBeNull();
245
+ expect(await components(world, watcher)).toEqual([mark]);
246
+ await world.dispose();
247
+ });
248
+
249
+ it("cascade to entities spawned in the sync point their owner despawns in", async () => {
250
+ const world = createWorld({ [workers]: 0 });
251
+ const step = at(world);
252
+ let owner = Entity.none, child = Entity.none, grandchild = Entity.none;
253
+ await step(commands => { owner = commands.spawn([mark])({ [mark]: 0 }); });
254
+ await step(commands => {
255
+ // The grandchild's archetype takes its first spawn before the child's does.
256
+ commands.spawn([mark, owned, sprite])({ [mark]: 1, [owned]: Entity.none });
257
+ child = commands.spawn([mark, owned])({ [mark]: 2, [owned]: owner });
258
+ grandchild = commands.spawn([mark, owned, sprite])({ [mark]: 3, [owned]: child });
259
+ commands.despawn(owner);
260
+ });
261
+ expect(await read(world, child, mark)).toBeNull();
262
+ expect(await read(world, grandchild, mark)).toBeNull();
263
+ await world.dispose();
264
+ });
265
+
229
266
  it("move a holder whose weak reference is dropped to an archetype no entity had, closed by the rules", async () => {
230
267
  const world = createWorld({ [workers]: 0 });
231
268
  const step = at(world);
@@ -0,0 +1,88 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { expect, it } from "vitest";
3
+ import { Entity, Finalizer, System } from "../../index.js";
4
+ import { createWorld } from "../index.js";
5
+ import type { Tables } from "../../interface/system/tables.js";
6
+ import type { Table } from "../../interface/world/table/table.js";
7
+
8
+ const name = Symbol("runChanges/name");
9
+ const score = Symbol("runChanges/score");
10
+ const spent = Symbol("runChanges/spent");
11
+ const echo = Symbol("runChanges/echo");
12
+ const budget = Symbol("runChanges/budget");
13
+
14
+ declare module "@adobe/ecs" {
15
+ interface Components {
16
+ [name]: { type: string };
17
+ [score]: { type: number };
18
+ [spent]: { type: true };
19
+ [echo]: { type: string };
20
+ }
21
+ interface Resources {
22
+ [budget]: { type: number; default: 10 };
23
+ }
24
+ }
25
+
26
+ // An entity spent leaves an echo, which its finalizer spawns at the next sync point.
27
+ Finalizer.create({ components: { with: [name, spent] } }, (row, { commands }) => { commands.spawn([echo])({ [echo]: row[name] }); });
28
+
29
+ const spawn = System.create({}, ({ commands }, input: { readonly who: string }) => { commands.spawn([name])({ [name]: input.who }); });
30
+ const score10 = System.create({ components: { with: [name], not: [score] }, after: [spawn] }, (row, { commands }) => { commands.add(row.entity, score, 10); });
31
+ const enter = System.create([spawn, score10]);
32
+ const rescore = System.create({ components: { with: [score] } }, (row, { commands }) => { commands.add(row.entity, score, row[score] + 1); });
33
+ const spend = System.create({ components: { with: [name] } }, (row, { commands, resources }) => {
34
+ commands.add(row.entity, spent);
35
+ commands.despawn(row.entity);
36
+ resources[budget] -= 1;
37
+ });
38
+ const raise = System.create({}, ({ resources }) => { resources[budget] = resources[budget]; });
39
+ const readNamed = System.create({ readonly: true, tables: { components: [name] } }, ({ tables }): Tables => tables);
40
+ const copied: { tables: readonly Table[] } = { tables: [] };
41
+ const copy = System.create(({ commands }) => { commands.spawnTables(copied.tables); });
42
+ const clear = System.create(({ commands }) => { commands.despawnWhere({ with: [name] }); });
43
+
44
+ const describe = (changes: Awaited<ReturnType<typeof run>>) => changes.groups.map(group => ({
45
+ from: group.from?.map(key => key.description) ?? null, to: group.to?.map(key => key.description) ?? null,
46
+ replaced: group.replaced.map(key => key.description),
47
+ values: group.entities.map(entity => [changes.previous(entity, score), changes.next(entity, score), changes.previous(entity, name) ?? changes.next(entity, name)]),
48
+ }));
49
+ const run = (world: ReturnType<typeof createWorld>) => world.run(rescore, { changes: true });
50
+
51
+ it("resolves to the run's own changes, merged across its sync points, with values before and after it", async () => {
52
+ await using world = createWorld();
53
+ const entered = await world.run(enter, { who: "ada" }, { changes: true });
54
+ expect(describe(entered)).toEqual([{ from: null, to: ["runChanges/name", "runChanges/score"], replaced: [], values: [[null, 10, "ada"]] }]);
55
+ expect(entered.resources).toEqual([]);
56
+ expect(describe(await run(world))).toEqual([{ from: ["runChanges/name", "runChanges/score"], to: ["runChanges/name", "runChanges/score"], replaced: ["runChanges/score"], values: [[10, 11, "ada"]] }]);
57
+ });
58
+
59
+ it("leaves out what finalizers of an earlier sync point queued, and gives what its systems wrote of resources", async () => {
60
+ await using world = createWorld();
61
+ await world.run(enter, { who: "bo" });
62
+ // Its finalizer queues an echo for the next sync point, which applies before the next run's own changes.
63
+ const spent = await world.run(spend, { changes: true });
64
+ expect(describe(spent)).toEqual([{ from: ["runChanges/name", "runChanges/score"], to: null, replaced: [], values: [[10, null, "bo"]] }]);
65
+ expect(spent.resources).toEqual([{ key: budget, previous: 10, next: 9 }]);
66
+ const raised = await world.run(raise, { changes: true });
67
+ expect([raised.groups, raised.resources]).toEqual([[], []]);
68
+ expect(raised.previous(Entity.none, name)).toBe(null);
69
+ });
70
+
71
+ it("runs as usual without the option, and with input that only looks like it", async () => {
72
+ await using world = createWorld();
73
+ const echoing = System.create({}, (_context, input: { readonly changes: boolean }) => input.changes);
74
+ expect(await world.run(echoing, { changes: true })).toBe(true);
75
+ expect(await world.run(raise)).toBe(undefined);
76
+ });
77
+
78
+ it("gives the rows spawnTables spawns and the entities despawnWhere despawns as a run's changes", async () => {
79
+ await using world = createWorld();
80
+ await world.run(enter, { who: "cy" });
81
+ copied.tables = (await world.run(readNamed)).archetypes;
82
+ // The tables copy names alone.
83
+ expect(describe(await world.run(copy, { changes: true }))).toEqual([{ from: null, to: ["runChanges/name"], replaced: [], values: [[null, null, "cy"]] }]);
84
+ expect(describe(await world.run(clear, { changes: true }))).toEqual([
85
+ { from: ["runChanges/name"], to: null, replaced: [], values: [[null, null, "cy"]] },
86
+ { from: ["runChanges/name", "runChanges/score"], to: null, replaced: [], values: [[10, null, "cy"]] },
87
+ ]);
88
+ });
@@ -0,0 +1,160 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Entity } from '../../interface/entity/entity.js';
3
+ import type { Essential, ReadonlyType } from '../../interface/component/value.js';
4
+ import type { ChangeSet } from '../../interface/finalizer/body.js';
5
+ import type { Resource } from '../../interface/resource/resource.js';
6
+ import { isLeaf, newestKeyOf, scheduleOf, type Definition, type Leaf } from '../../interface/registry/index.js';
7
+ import type { Archetype } from './archetype.js';
8
+ import { readCell } from './cells.js';
9
+ import { asEntity, indexOf, locate } from './entities.js';
10
+ import { sameValue } from './resource-changes.js';
11
+ import { findResource, initialOf } from './resources.js';
12
+ import { metIdOf, type Key, type State } from './state.js';
13
+ import type { Queued } from './synchronize.js';
14
+
15
+ /**
16
+ * What a run asked for its changes holds while it runs: by entity its sync points planned, the watched components it
17
+ * had before the run, none for one born in it, with their values then, and those its adds gave a value; and the
18
+ * resources its systems write or reduce into, with their values before it.
19
+ */
20
+ export type Recording = {
21
+ readonly before: Map<number, { readonly ids: readonly number[] | null; readonly values: ReadonlyMap<number, unknown> }>;
22
+ readonly given: Map<number, Set<number>>;
23
+ readonly resources: ReadonlyMap<Key, unknown>;
24
+ };
25
+
26
+ /** Every component but derived ones and the objects a joined thread fills, as a changes finalizer watches by default. */
27
+ const watched = (state: State, id: number): boolean => !state.derived[id] && !state.resources.has(id) && state.remote[id] === undefined;
28
+
29
+ const noValues: ReadonlyMap<number, unknown> = new Map();
30
+
31
+ /** The values of components `ids` of an archetype's row. */
32
+ const valuesAt = (archetype: Archetype, ids: readonly number[], row: number): ReadonlyMap<number, unknown> => {
33
+ const values = new Map<number, unknown>();
34
+ for (const id of ids) {
35
+ const storage = archetype.storageById[id];
36
+ if (storage !== undefined) values.set(id, readCell(storage, row));
37
+ }
38
+ return values;
39
+ };
40
+
41
+ /** Starts recording a run whose systems write or reduce into `resources`, from their values now. */
42
+ export const startRecording = (state: State, resources: Iterable<Key>): Recording => {
43
+ const values = new Map<Key, unknown>();
44
+ for (const key of resources) {
45
+ const cell = findResource(state, key);
46
+ values.set(key, cell === undefined ? initialOf(state, key) : readCell(cell.storage, cell.row));
47
+ }
48
+ return { before: new Map(), given: new Map(), resources: values };
49
+ };
50
+
51
+ /**
52
+ * At a sync point of the run, before it applies: each entity its plan names that no earlier one did, with what it has
53
+ * now, and the components its adds give a value. `born` holds the rows committed from batches here, by archetype.
54
+ */
55
+ export const recordPlan = (state: State, recording: Recording, queued: readonly Queued[], born: ReadonlyMap<number, number>): void => {
56
+ const { plan, archetypes, entities } = state;
57
+ const note = (entity: number, from: Archetype | null): void => {
58
+ if (recording.before.has(entity) || from?.resource === true) return;
59
+ if (from === null) {
60
+ recording.before.set(entity, { ids: null, values: noValues });
61
+ return;
62
+ }
63
+ const row = entities.row[indexOf(entity)];
64
+ const ids = from.components.filter(id => watched(state, id));
65
+ recording.before.set(entity, { ids, values: valuesAt(from, ids, row) });
66
+ };
67
+ const give = (entity: number, buffer: number, start: number, end: number): void => {
68
+ const { ids } = queued[buffer].buffer;
69
+ for (let k = start; k < end; k++) {
70
+ if (!watched(state, ids[k])) continue;
71
+ let given = recording.given.get(entity);
72
+ if (given === undefined) recording.given.set(entity, given = new Set());
73
+ given.add(ids[k]);
74
+ }
75
+ };
76
+ for (let entry = 0; entry < plan.count; entry++) {
77
+ const entity = plan.entity[entry];
78
+ const fromId = plan.from[entry];
79
+ const first = fromId < 0 ? undefined : born.get(fromId);
80
+ note(entity, fromId < 0 || (first !== undefined && entities.row[indexOf(entity)] >= first) ? null : archetypes[fromId]);
81
+ if (plan.start[entry] < 0) continue;
82
+ give(entity, plan.buffer[entry], plan.start[entry], plan.end[entry]);
83
+ const more = plan.more.size === 0 ? undefined : plan.more.get(entry);
84
+ if (more !== undefined) for (let r = 0; r < more.length; r += 3) give(entity, more[r], more[r + 1], more[r + 2]);
85
+ }
86
+ // Rows committed from batches no command touched have no entry.
87
+ for (const [id, first] of born) {
88
+ const archetype = archetypes[id];
89
+ for (let row = first; row < archetype.count; row++) note(archetype.entities[row], null);
90
+ }
91
+ };
92
+
93
+ /** A watched component's newest key: watched components are registered and not derived, so they're essential. */
94
+ const keyOf = (state: State, id: number): Essential => newestKeyOf(state.keys[id]) as Essential;
95
+
96
+ /** A group of a change set, as it's built. */
97
+ type Grouped = { from: Essential[] | null; to: Essential[] | null; added: Essential[]; removed: Essential[]; replaced: Essential[]; entities: Entity[] };
98
+
99
+ /**
100
+ * The run's change set once its last sync point applied: each entity grouped by the watched components it had before
101
+ * the run and has after, those it gained and lost, and those adds gave a value it had before and after, with their
102
+ * values then and now; and each resource its systems write or reduce into whose value changed.
103
+ */
104
+ export const changesOf = (state: State, recording: Recording): ChangeSet<Essential, Resource.Key> => {
105
+ const { archetypes, entities } = state;
106
+ const groups = new Map<string, Grouped>();
107
+ const after = new Map<number, ReadonlyMap<number, unknown>>();
108
+ for (const [entity, { ids: before }] of recording.before) {
109
+ const index = locate(entities, entity);
110
+ const to = index < 0 ? null : archetypes[entities.archetype[index]];
111
+ const ids = to === null ? null : to.components.filter(id => watched(state, id));
112
+ if (before === null && ids === null) continue;
113
+ const added = (ids ?? []).filter(id => !(before ?? []).includes(id));
114
+ const removed = (before ?? []).filter(id => !(ids ?? []).includes(id));
115
+ const replaced = [...recording.given.get(entity) ?? []].filter(id => before?.includes(id) === true && ids?.includes(id) === true).sort((a, b) => a - b);
116
+ if (added.length === 0 && removed.length === 0 && replaced.length === 0) continue;
117
+ if (to !== null && ids !== null) {
118
+ after.set(entity, valuesAt(to, ids, entities.row[index]));
119
+ }
120
+ const name = `${before?.join(",") ?? "-"}|${ids?.join(",") ?? "-"}|${replaced.join(",")}`;
121
+ let group = groups.get(name);
122
+ const keys = (list: readonly number[] | null): Essential[] | null => list === null ? null : list.map(id => keyOf(state, id));
123
+ if (group === undefined) {
124
+ groups.set(name, group = { from: keys(before), to: keys(ids), added: added.map(id => keyOf(state, id)), removed: removed.map(id => keyOf(state, id)), replaced: replaced.map(id => keyOf(state, id)), entities: [] });
125
+ }
126
+ group.entities.push(asEntity(entity));
127
+ }
128
+ const resources: { key: Resource.Key; previous: unknown; next: unknown }[] = [];
129
+ for (const [key, previous] of recording.resources) {
130
+ const cell = findResource(state, key);
131
+ const next = cell === undefined ? previous : readCell(cell.storage, cell.row);
132
+ // A key the run's systems name is a resource's.
133
+ if (!sameValue(previous, next)) resources.push({ key: newestKeyOf(key) as Resource.Key, previous, next });
134
+ }
135
+ const valueOf = (values: ReadonlyMap<number, unknown> | undefined, key: Essential): unknown => {
136
+ const id = metIdOf(state, key);
137
+ return id !== undefined && values?.has(id) === true ? values.get(id) : null;
138
+ };
139
+ return {
140
+ groups: [...groups.values()],
141
+ // Values read from storage match each key's registered schema, so they have its type.
142
+ resources: resources as ChangeSet<Essential, Resource.Key>["resources"],
143
+ previous: <K extends Essential>(entity: Entity, key: K) => valueOf(recording.before.get(entity)?.values, key) as ReadonlyType<K> | null,
144
+ next: <K extends Essential>(entity: Entity, key: K) => valueOf(after.get(entity), key) as ReadonlyType<K> | null,
145
+ };
146
+ };
147
+
148
+ /** A system's leaves that run in tiers, or the system itself. */
149
+ const leavesOf = (definition: Definition): readonly Leaf[] => isLeaf(definition) ? [definition] : scheduleOf(definition).tiers.flat();
150
+
151
+ /** The resources a system's leaves write or reduce into. */
152
+ export const resourcesWrittenBy = (definition: Definition): ReadonlySet<Key> =>
153
+ new Set(leavesOf(definition).flatMap(leaf => [...leaf.declaration.resources?.write ?? [], ...leaf.declaration.resources?.reduce ?? []]));
154
+
155
+ /** Whether a system or composite takes input, so a run's options follow it. */
156
+ export const takesInput = (definition: Definition): boolean =>
157
+ isLeaf(definition) ? definition.input !== undefined : [...scheduleOf(definition).tiers.flat(), ...scheduleOf(definition).observer].some(leaf => leaf.input !== undefined);
158
+
159
+ /** Whether a run's options ask for its changes. */
160
+ export const asksForChanges = (options: unknown): boolean => typeof options === "object" && options !== null && Reflect.get(options, "changes") === true;
@@ -63,7 +63,7 @@ const noIndexParameters: readonly number[] = [];
63
63
  const noAddresses: readonly number[] = [];
64
64
 
65
65
  /** An archetype's change filters for a runner, and the WASM addresses of its columns and filters (`filterCache`). */
66
- type Filtered = { readonly version: number; readonly filters: readonly Filter[]; addresses: readonly number[]; addressed: WasmKernel | null };
66
+ type Filtered = { readonly archetype: Archetype; readonly version: number; readonly filters: readonly Filter[]; addresses: readonly number[]; addressed: WasmKernel | null };
67
67
 
68
68
  /**
69
69
  * Runs one leaf system. Each run takes a new tick, which becomes the system's `lastRun`. `start` does main-thread work
@@ -470,7 +470,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
470
470
 
471
471
  /** Drops match `m`, its last taking its place; with `where`, one evaluated stays among those evaluated. */
472
472
  const removeMatch = (m: number): void => {
473
- filterCache.delete(matches[m]);
473
+ filterCache[matches[m].id] = undefined;
474
474
  matchOf[matches[m].id] = -1;
475
475
  let hole = m;
476
476
  if (where !== null && m < where.evaluated) {
@@ -487,7 +487,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
487
487
  /** Matches `m` anew, reused for other values: its point is its last run's, and `where` evaluates it again. */
488
488
  const renewMatch = (m: number): void => {
489
489
  const archetype = matches[m];
490
- filterCache.delete(archetype);
490
+ filterCache[archetype.id] = undefined;
491
491
  points[m] = lastRun;
492
492
  generations[m] = archetype.generation;
493
493
  if (where !== null && m < where.evaluated) {
@@ -563,12 +563,12 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
563
563
  * and the WASM addresses of its columns and filters, for the WASM kernel `addressed` they were taken for, since a
564
564
  * kernel loaded again may stamp a tick column the last one muted.
565
565
  */
566
- const filterCache = new Map<Archetype, Filtered>();
566
+ const filterCache: (Filtered | undefined)[] = [];
567
567
  const noFilters: readonly Filter[] = [];
568
568
 
569
569
  const filteredOf = (archetype: Archetype) => {
570
- const cached = filterCache.get(archetype);
571
- if (cached !== undefined && cached.version === archetype.version) return cached;
570
+ const cached = filterCache[archetype.id];
571
+ if (cached !== undefined && cached.archetype === archetype && cached.version === archetype.version) return cached;
572
572
  const filters = addedIds.length + changedIds.length === 0 ? noFilters : [
573
573
  ...addedIds.flatMap(id => {
574
574
  const storage = archetype.storageById[id];
@@ -579,8 +579,8 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
579
579
  return storage === undefined ? [] : [{ ticks: storage.changed, chunks: storage.chunkChanged, blocks: storage.blockChanged }];
580
580
  }),
581
581
  ];
582
- const entry: Filtered = { version: archetype.version, filters, addresses: noAddresses, addressed: null };
583
- filterCache.set(archetype, entry);
582
+ const entry: Filtered = { archetype, version: archetype.version, filters, addresses: noAddresses, addressed: null };
583
+ filterCache[archetype.id] = entry;
584
584
  return entry;
585
585
  };
586
586
 
@@ -614,13 +614,14 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
614
614
  const { value } = row;
615
615
  const values = context.value;
616
616
  const input = runInput;
617
+ const { body } = system;
617
618
  const filters = filtersOf(archetype);
618
619
  // Every row passes, so nothing is filtered.
619
620
  // A body may remove its own system, after which no row of it runs.
620
621
  if (since < 0 || filters.length === 0) {
621
622
  for (let index = 0, count = archetype.count; index < count && !holdings.removed; index++) {
622
623
  row.index = index;
623
- const returned = system.body(value, values, input);
624
+ const returned = body(value, values, input);
624
625
  if (returned !== undefined) noPromise(returned);
625
626
  }
626
627
  return;
@@ -628,7 +629,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
628
629
  forEachPassingRange(filters, since, 0, archetype.count, (start, end) => {
629
630
  for (let index = start; index < end && !holdings.removed; index++) {
630
631
  row.index = index;
631
- const returned = system.body(value, values, input);
632
+ const returned = body(value, values, input);
632
633
  if (returned !== undefined) noPromise(returned);
633
634
  }
634
635
  });
@@ -714,10 +715,11 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
714
715
  const upContext = context.value;
715
716
  const input = runInput;
716
717
  upContext.up = parentRow === null ? null : parentRow.value;
718
+ const { body } = system;
717
719
  for (let n = start; n < end && !holdings.removed; n++) {
718
720
  row.index = rows[n];
719
721
  if (parentRow !== null) parentRow.index = parents[n];
720
- const returned = system.body(row.value, upContext, input);
722
+ const returned = body(row.value, upContext, input);
721
723
  if (returned !== undefined) noPromise(returned);
722
724
  }
723
725
  };
@@ -748,9 +750,10 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
748
750
  }
749
751
  const row = bindRow(archetype);
750
752
  const { value } = row;
753
+ const { body } = system;
751
754
  for (let n = run.start; n < run.end && !holdings.removed; n++) {
752
755
  row.index = run.rows[n];
753
- const returned = system.body(value, contextValue, input);
756
+ const returned = body(value, contextValue, input);
754
757
  if (returned !== undefined) noPromise(returned);
755
758
  }
756
759
  }
@@ -762,6 +765,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
762
765
  const { entries, stride, words, lengths, blocks } = sorted;
763
766
  const contextValue = context.value;
764
767
  const input = runInput;
768
+ const { body } = system;
765
769
  let current = -1;
766
770
  let row: ReturnType<typeof bindRow> | null = null;
767
771
  let value: object | null = null;
@@ -782,7 +786,7 @@ export const createRunner = (state: State, leaf: Leaf, since = 0): Runner => {
782
786
  current = owner;
783
787
  }
784
788
  row.index = entries[at + 2];
785
- const returned = system.body(value, contextValue, input);
789
+ const returned = body(value, contextValue, input);
786
790
  if (returned !== undefined) noPromise(returned);
787
791
  }
788
792
  }
@@ -195,10 +195,10 @@ describe("a shared component", () => {
195
195
  }));
196
196
  expect(looked.sort()).toEqual(["1 sees red", "2 sees red"]);
197
197
  const assign: (row: object) => void = row => { Reflect.set(row, team, "blue"); };
198
- const assigning = System.create({ kernel: false, components: { with: [team, health] } }, row => {
198
+ // Taken whole, the row is written: every member but shared ones and references.
199
+ const assigning = System.create({ kernel: false, components: { with: [team, health], whole: true } }, row => {
199
200
  if (row[health] === 1) assign(row);
200
201
  });
201
- // The build takes the row as written whole, since it can't follow it: every member but shared ones and references.
202
202
  expect((await runReported(world, assigning)).failures.map(failure => failure.message)).toEqual([
203
203
  expect.stringContaining(`"sharedTest/team" is shared: its value is its archetype's, so give it another with commands.add rather than assigning it`),
204
204
  ]);
@@ -1,4 +1,5 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Recording } from './run-changes.js';
2
3
  import { createHolds, type Holds } from './stop-lists.js';
3
4
  import type { Entity } from '../../interface/entity/entity.js';
4
5
  import type { Leaf } from '../../interface/system/leaf.js';
@@ -126,6 +127,13 @@ export type State = {
126
127
  reshapes: number;
127
128
  /** Sync points applied, which reclaiming counts. */
128
129
  syncs: number;
130
+ /**
131
+ * By id, each resource a changes finalizer watches: its value and change tick when its finalizers last ran, which a
132
+ * later write's report gives as its previous value. Empty while none watches one.
133
+ */
134
+ readonly resourcesSeen: Map<number, { value: unknown; tick: number }>;
135
+ /** What the run in progress records of its changes, when it was asked for them; null otherwise. */
136
+ recording: Recording | null;
129
137
  /** The records strings' columns name, in shared memory; null without it, where strings are held as objects. */
130
138
  readonly strings: StringStore | null;
131
139
  /** Whether each component is derived, so only rules add and remove it. */
@@ -317,6 +325,8 @@ export const createState = (shared?: boolean, wasm = true, simd = true, gpu = fa
317
325
  membership: { log: [], base: 0 },
318
326
  reshapes: 0,
319
327
  syncs: 0,
328
+ resourcesSeen: new Map(),
329
+ recording: null,
320
330
  strings: memory.shared ? createStrings(memory) : null,
321
331
  derived: [],
322
332
  transfers: [],
@@ -504,6 +514,15 @@ export const commandIdOf = (state: State, key: unknown): number => {
504
514
  return id;
505
515
  };
506
516
 
517
+ /** The id of a component a query names, derived ones included. Throws for a key that isn't a registered component. */
518
+ export const queryIdOf = (state: State, key: unknown): number => {
519
+ const known = typeof key === "symbol" ? state.ids.get(key) : undefined;
520
+ if (known !== undefined && !state.resources.has(known)) return known;
521
+ if (typeof key !== "symbol") throw new Error(`"${labelOf(key)}" isn't a component: a component's key is a symbol.`);
522
+ if (kindOf(key) === "resource") throw new Error(`"${labelOf(key)}" is a resource, not a component.`);
523
+ return idOf(state, key);
524
+ };
525
+
507
526
  /**
508
527
  * An archetype's key: its sorted component ids, each shared one with the number of its value, so archetypes of one
509
528
  * component set but other values are others.