@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
@@ -280,6 +280,7 @@ export const createThreadSystems = (host: ThreadSystemsHost) => {
280
280
  },
281
281
  despawn: entity => beginCommand(writer, despawnCommand, entity),
282
282
  spawnTables: () => { throw new Error("commands.spawnTables runs on the world's thread: run the system there."); },
283
+ despawnWhere: () => { throw new Error("commands.despawnWhere runs on the world's thread: run the system there."); },
283
284
  add: (entity: number, components: symbol | readonly symbol[], values?: unknown) => {
284
285
  if (typeof components === "symbol") {
285
286
  const key = givenKeyOf(components);
@@ -990,18 +991,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon
990
991
  const row = bindRow(archetype);
991
992
  const subject = row.value;
992
993
  const input = runInput;
994
+ const { body } = system;
993
995
  const filters = filtersOf(archetype);
994
996
  if (since < 0 || filters.length === 0) {
995
997
  for (let index = 0; index < count; index++) {
996
998
  row.index = index;
997
- system.body(subject, value, input);
999
+ body(subject, value, input);
998
1000
  }
999
1001
  return;
1000
1002
  }
1001
1003
  forEachPassingRange(filters, since, 0, count, (start, end) => {
1002
1004
  for (let index = start; index < end; index++) {
1003
1005
  row.index = index;
1004
- system.body(subject, value, input);
1006
+ body(subject, value, input);
1005
1007
  }
1006
1008
  });
1007
1009
  };
@@ -1103,18 +1105,19 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon
1103
1105
  const row = bindRow(archetype);
1104
1106
  const subject = row.value;
1105
1107
  const input = runInput;
1108
+ const { body } = system;
1106
1109
  const filters = filtersOf(archetype);
1107
1110
  if (since < 0 || filters.length === 0) {
1108
1111
  for (let index = start; index < end && !halted; index++) {
1109
1112
  row.index = index;
1110
- system.body(subject, tailValue, input);
1113
+ body(subject, tailValue, input);
1111
1114
  }
1112
1115
  return;
1113
1116
  }
1114
1117
  forEachPassingRange(filters, since, start, end, (first, last) => {
1115
1118
  for (let index = first; index < last && !halted; index++) {
1116
1119
  row.index = index;
1117
- system.body(subject, tailValue, input);
1120
+ body(subject, tailValue, input);
1118
1121
  }
1119
1122
  });
1120
1123
  };
@@ -1233,6 +1236,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon
1233
1236
  const copied = (storages: readonly (ThreadStorage | undefined)[]): ThreadArchetype =>
1234
1237
  ({ id: -1, entities, storageById: storages, record: -1, version: -1, mirrored: null, components: [], resource: false, reclaimed: false, generation: 0 });
1235
1238
  const ownedInput = runInput;
1239
+ const { body } = system;
1236
1240
  try {
1237
1241
  if (optionalNumbers.length === 0 && sharedReads.length === 0) {
1238
1242
  const row = bindRow(copied(storageById));
@@ -1240,7 +1244,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon
1240
1244
  for (let index = first; index < rows && !halted; index++) {
1241
1245
  if (skipped !== null && skipped.has(archetypeIds[index])) continue;
1242
1246
  row.index = index;
1243
- system.body(subject, tailValue, ownedInput);
1247
+ body(subject, tailValue, ownedInput);
1244
1248
  }
1245
1249
  }
1246
1250
  else {
@@ -1275,7 +1279,7 @@ export const createThreadRunner = (host: RunnerHost, leaf: Leaf, records: Readon
1275
1279
  const subject = row.value;
1276
1280
  for (; index < end && !halted; index++) {
1277
1281
  row.index = index;
1278
- system.body(subject, tailValue, ownedInput);
1282
+ body(subject, tailValue, ownedInput);
1279
1283
  }
1280
1284
  }
1281
1285
  }
@@ -190,7 +190,7 @@ describe("up", () => {
190
190
  expect(await read(ecs, entities.grandchild, world)).toBe(100);
191
191
  });
192
192
 
193
- it("runs every row after a spawn outruns the journal, with change filters, then only the rows that changed", async () => {
193
+ it("runs every row after spawning more than a patch takes, with change filters, then only the rows that changed", async () => {
194
194
  await using ecs = createWorld({ [workers]: 0 });
195
195
  await ecs.run(spawn);
196
196
  await ecs.run(nest);
@@ -211,6 +211,18 @@ describe("up", () => {
211
211
  expect(ran).toEqual([]);
212
212
  });
213
213
 
214
+ it("runs only the rows spawned in bulk, which the journal holds once each, with change filters", async () => {
215
+ await using ecs = createWorld({ [workers]: 0 });
216
+ await ecs.run(spawn);
217
+ await ecs.run(nest);
218
+ await ecs.run(propagateChanged);
219
+ await ecs.run(bulkUnder, { count: 3000 });
220
+ ran.length = 0;
221
+ await ecs.run(propagateChanged);
222
+ expect(ran.length).toBe(3000);
223
+ expect(await readEach(ecs, bulk.slice(0, 3), world)).toEqual([112, 112, 112]);
224
+ });
225
+
214
226
  it("finds a cycle a reparent closes after the tree was built, and runs its rows again once it opens", async () => {
215
227
  await using ecs = createWorld({ [workers]: 0 });
216
228
  await ecs.run(spawn);
@@ -22,6 +22,9 @@ const cyclic = -1;
22
22
  const denseShare = 1 / 8;
23
23
  /** Rows below this many are sorted rather than scanned. */
24
24
  const denseMinimum = 4096;
25
+ /** Past this many journal entries, and past this share of the matched rows, the tree is built again rather than patched. */
26
+ const patchMinimum = 4096;
27
+ const patchShare = 1 / 8;
25
28
  /** Sort keys pack a row with its parent's archetype: `(parent + 1) * rowLimit + row`. */
26
29
  const rowLimit = 2 ** 27;
27
30
 
@@ -375,7 +378,9 @@ const createShared = (state: State, through: number, read: readonly number[], ma
375
378
  const { journal } = state;
376
379
  const end = journalEnd(journal);
377
380
  if (position === end) return;
378
- if (position < journal.first) {
381
+ let matched = 0;
382
+ for (let k = 0; k < matches.length; k++) matched += matches[k].count;
383
+ if (position < journal.first || end - position > Math.max(patchMinimum, matched * patchShare)) {
379
384
  build(matches);
380
385
  position = end;
381
386
  rebuiltAll();
@@ -115,22 +115,50 @@ const predicateOf = (given: unknown): ((value: unknown, context: object) => unkn
115
115
  typeof given === "function" ? (value, context) => Reflect.apply(given, undefined, [value, context]) : null;
116
116
 
117
117
  /**
118
- * A system's `where` in a world: its values interned there, by number, its predicates' context reading `reads`,
119
- * evaluated again once one of them changed since they last were.
118
+ * `where`'s conditions in a world, each value interned there, by number, and with `pinned`, pinned, so its number stays
119
+ * while no archetype holds it and one that comes to hold it again matches; or the key of a component that isn't shared.
120
+ * A value interned unpinned keeps its number to the end of the sync point.
120
121
  */
121
- export const createWhere = <A extends Valued>(state: State, where: { readonly [key: symbol]: unknown }, reads: readonly symbol[], leaf: Leaf): Where<A> => {
122
- const conditions: Condition[] = Object.getOwnPropertySymbols(where).map(key => {
122
+ const conditionsOf = (state: State, where: { readonly [key: symbol]: unknown }, pinned: boolean): Condition[] | symbol => {
123
+ const conditions: Condition[] = [];
124
+ for (const key of Object.getOwnPropertySymbols(where)) {
123
125
  const id = idOf(state, key);
124
126
  const given: unknown = Reflect.get(where, key);
125
127
  const predicate = predicateOf(given);
126
- if (predicate !== null) return { id, number: -1, predicate };
128
+ if (predicate !== null) {
129
+ conditions.push({ id, number: -1, predicate });
130
+ continue;
131
+ }
127
132
  const values = state.shared[id];
128
- if (values === undefined) throw new Error(`System "${leaf.name}": where names "${labelOf(key)}", which isn't a shared component.`);
133
+ if (values === undefined) return key;
129
134
  const number = internShared(values, given);
130
- // Pinned, so its number stays while no archetype holds it: one that comes to hold it again matches.
131
- if (number >= 0) holdShared(values, number);
132
- return { id, number, predicate: null };
133
- });
135
+ if (pinned && number >= 0) holdShared(values, number);
136
+ conditions.push({ id, number, predicate: null });
137
+ }
138
+ return conditions;
139
+ };
140
+
141
+ /**
142
+ * Whether an archetype holds each of `where`'s values, as a system's `where` of values passes it, for a `where` naming
143
+ * only shared components and giving no predicate; or the key of one that isn't shared. Its values are pinned with
144
+ * `pinned`, and otherwise only interned, for a `where` used before the sync point ends.
145
+ */
146
+ export const holdsValuesOf = (state: State, where: { readonly [key: symbol]: unknown }, pinned: boolean): ((archetype: Valued) => boolean) | symbol => {
147
+ const conditions = conditionsOf(state, where, pinned);
148
+ if (typeof conditions === "symbol") return conditions;
149
+ return archetype => {
150
+ for (let c = 0; c < conditions.length; c++) if (archetype.shared[conditions[c].id] !== conditions[c].number) return false;
151
+ return true;
152
+ };
153
+ };
154
+
155
+ /**
156
+ * A system's `where` in a world: its values interned there, by number, its predicates' context reading `reads`,
157
+ * evaluated again once one of them changed since they last were.
158
+ */
159
+ export const createWhere = <A extends Valued>(state: State, where: { readonly [key: symbol]: unknown }, reads: readonly symbol[], leaf: Leaf): Where<A> => {
160
+ const conditions = conditionsOf(state, where, true);
161
+ if (typeof conditions === "symbol") throw new Error(`System "${leaf.name}": where names "${labelOf(conditions)}", which isn't a shared component.`);
134
162
  const readIds = reads.map(key => idOf(state, key));
135
163
  // Its predicates' context: each resource read once per evaluation, since a struct's read copies it out.
136
164
  const cached: unknown[] = reads.map(() => undefined);
@@ -7,6 +7,8 @@ import type { LookupFunction } from '../system/values.js';
7
7
  import type { WriteResources } from '../system/body.js';
8
8
  import type { ChangesDeclaration, Declaration } from './declaration.js';
9
9
  import type { Essential, ReadonlyType } from '../component/value.js';
10
+ import type { Resource } from '../resource/resource.js';
11
+ import type { ReadonlyType as ResourceType } from '../resource/value.js';
10
12
  import type { Elsewhere, ThreadsOf } from '../system/threads.js';
11
13
 
12
14
  /**
@@ -29,7 +31,10 @@ export type Context<D extends Declaration | ChangesDeclaration> =
29
31
  & (true extends Elsewhere<ThreadsOf<D>> ? {} : { readonly commands: Commands });
30
32
 
31
33
  /** The components a changes finalizer sees. */
32
- export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly Essential[] ? Listed<D, "watch"> & Essential : Essential;
34
+ export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly unknown[] ? Listed<D, "watch"> & Essential : Essential;
35
+
36
+ /** The resources a changes finalizer sees: those it watches. */
37
+ export type WatchedResources<D extends ChangesDeclaration> = Listed<D, "watch"> & Resource.Key;
33
38
 
34
39
  /** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */
35
40
  export type Group<C extends Component.Key> = {
@@ -42,9 +47,14 @@ export type Group<C extends Component.Key> = {
42
47
  readonly entities: readonly Entity[];
43
48
  };
44
49
 
45
- /** A sync point's structural changes to the watched components, and the values adds replace, before they apply. */
46
- export type ChangeSet<C extends Component.Key> = {
50
+ /**
51
+ * A sync point's structural changes to the watched components, and the values adds replace, before they apply; and each
52
+ * watched resource written since the last sync point's finalizers ran, to a value that isn't equal to its last one,
53
+ * leaf by leaf for data and by identity for objects, with its value before and after.
54
+ */
55
+ export type ChangeSet<C extends Component.Key, R extends Resource.Key = never> = {
47
56
  readonly groups: readonly Group<C>[];
57
+ readonly resources: readonly { readonly [K in R]: { readonly key: K; readonly previous: ResourceType<K>; readonly next: ResourceType<K> } }[R][];
48
58
  /** The value before the sync point, or null if the entity didn't have it. */
49
59
  previous<K extends C>(entity: Entity, component: K): ReadonlyType<K> | null;
50
60
  /** The value after it, or null if the entity won't have it. */
@@ -75,7 +75,7 @@ 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: "resource", watch: [time] })).toThrow("which isn't a registered component");
78
+ expect(() => untyped(Finalizer.changes, { name: "unregistered", watch: [Symbol("typesFinalizerTest/unregistered")] })).toThrow("which isn't a registered component or resource");
79
79
  expect(() => untyped(Finalizer.create, { components: { with: [time] } })).toThrow(`uses component "typesFinalizerTest/time", which isn't a registered component`);
80
80
  expect(() => untyped(Finalizer.create, { components: { with: [label] }, lookup: { others: { not: [Symbol("typesFinalizerTest/unregistered")] } } }, { read: [label] })).toThrow("which isn't a registered component");
81
81
  expect(programFinalizers()).toHaveLength(7);
@@ -93,6 +93,8 @@ describe("Finalizer.create and Finalizer.changes", () => {
93
93
  .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"');
94
94
  expect(() => untyped(Finalizer.changes, {}, { resources: { read: [page] } }))
95
95
  .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`);
96
+ expect(() => untyped(Finalizer.changes, { name: "savePage", watch: [page] }))
97
+ .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`);
96
98
  // Reading another thread's values, without writing or looking up, it's created.
97
99
  const detach = Finalizer.create({ components: { with: [node] } }, row => { void row[node]; });
98
100
  expect(programFinalizers()).toHaveLength(8);
@@ -8,9 +8,11 @@ import { checkWorldWritten } from '../registry/written.js';
8
8
  import { checkLookup } from '../registry/indexes.js';
9
9
  import type { Listed, Property } from '../system/property.js';
10
10
  import type { ThreadOfComponent } from '../component/value.js';
11
+ import type { ThreadOfResource } from '../resource/value.js';
12
+ import type { Resource } from '../resource/resource.js';
11
13
  import type { Component } from '../component/component.js';
12
14
  import type { Elsewhere, OneThread, ThreadedBy, ThreadsOf } from '../system/threads.js';
13
- import type { ChangeSet, Context, Row, Watched } from './body.js';
15
+ import type { ChangeSet, Context, Row, Watched, WatchedResources } from './body.js';
14
16
  import type { Changes, Leaf } from './created.js';
15
17
  import type { ChangesDeclaration, Declaration, Resolved, ResolvedChanges } from './declaration.js';
16
18
  import { accessOf, compact, lookupsLists, resourceUses, type Access } from '../system/resolve.js';
@@ -26,7 +28,7 @@ type Remote<D> = true extends Elsewhere<ThreadsOf<D>>
26
28
  : {};
27
29
 
28
30
  /** A changes finalizer runs on the world's thread, in the sync point: it watches and reads no other thread's objects. */
29
- type OnWorld<D> = true extends Elsewhere<ThreadsOf<D> | ThreadOfComponent<Listed<D, "watch"> & Component.Key>>
31
+ type OnWorld<D> = true extends Elsewhere<ThreadsOf<D> | ThreadOfComponent<Listed<D, "watch"> & Component.Key> | ThreadOfResource<Listed<D, "watch"> & Resource.Key>>
30
32
  ? { readonly "error: a changes finalizer watches and reads only the world's thread's objects": true }
31
33
  : {};
32
34
 
@@ -127,12 +129,12 @@ export const create = <const D extends Declaration>(
127
129
 
128
130
  /**
129
131
  * Declares a finalizer of each sync point's structural changes, which applies in every world, and returns it. It runs
130
- * on the world's thread. Throws as `create` does, for a watched component that isn't registered or is derived, and for
131
- * one watching or reading another thread's values.
132
+ * on the world's thread. Throws as `create` does, for a watched component that isn't registered or is derived, a
133
+ * watched key that's neither a component nor a resource, and for one watching or reading another thread's values.
132
134
  */
133
135
  export const changes = <const D extends ChangesDeclaration>(
134
136
  declared: D & Inferred & OnWorld<D>,
135
- body: (changes: ChangeSet<Watched<D>>, context: Context<D>) => void,
137
+ body: (changes: ChangeSet<Watched<D>, WatchedResources<D>>, context: Context<D>) => void,
136
138
  ): Finalizer => {
137
139
  const id = takeIdOfDeclaration(declared);
138
140
  const name = nameOf(declared, id, "finalizer");
@@ -148,10 +150,13 @@ export const changes = <const D extends ChangesDeclaration>(
148
150
  for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys);
149
151
  }
150
152
  for (const key of declaration.watch ?? []) {
151
- if (kindOf(key) !== "component") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component: ${whyUnregistered(key)}.`);
153
+ const kind = kindOf(key);
154
+ if (kind !== "component" && kind !== "resource") throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which isn't a registered component or resource: ${whyUnregistered(key)}.`);
152
155
  if (declarationOf(key)?.derived === true) throw new Error(`Finalizer "${name}" watches "${labelOf(key)}", which is derived; it sees only components user code adds and removes.`);
153
156
  }
154
- const thread = threadOf({ components: { read: declaration.watch }, resources: declaration.resources, lookup: declaration.lookup }, "Finalizer", name);
157
+ const watched = (kind: "component" | "resource"): readonly symbol[] => (declaration.watch ?? []).filter(key => kindOf(key) === kind);
158
+ const read = [...declaration.resources?.read ?? [], ...watched("resource")];
159
+ const thread = threadOf({ components: { read: watched("component") }, resources: { ...declaration.resources, read }, lookup: declaration.lookup }, "Finalizer", name);
155
160
  if (thread !== null && thread !== "world") throw new Error(`Finalizer "${name}" watches or reads values on thread "${thread}"; a changes finalizer runs on the world's thread, in the sync point.`);
156
161
  checkOrder(name, "world", declaration);
157
162
  return addFinalizer(finalizer, id);
@@ -4,6 +4,7 @@ import type { SystemLookupLists, ResourceLists } from '../system/access.js';
4
4
  import type { Lookup } from '../system/access.js';
5
5
  import type { Finalizer } from './finalizer.js';
6
6
  import type { Essential } from '../component/value.js';
7
+ import type { Resource } from '../resource/resource.js';
7
8
 
8
9
  /**
9
10
  * What every finalizer declares. What its body reads of its row and lookups, and reads and writes of resources, the
@@ -26,9 +27,10 @@ export type Declaration = Shared & {
26
27
 
27
28
  /**
28
29
  * A finalizer that sees each sync point's structural changes to the components it watches: by default, every one that
29
- * isn't derived or an object a joined thread fills.
30
+ * isn't derived or an object a joined thread fills; and the resources it watches, none by default, written since the
31
+ * last sync point's finalizers ran.
30
32
  */
31
- export type ChangesDeclaration = Shared & { readonly watch?: readonly Essential[] };
33
+ export type ChangesDeclaration = Shared & { readonly watch?: readonly (Essential | Resource.Key)[] };
32
34
 
33
35
  type Key = symbol;
34
36
 
@@ -144,3 +144,13 @@ describe("systems ordering their rows by an index", () => {
144
144
  expect(() => untypedRender(Index.create({ key: [amount] }))).toThrow(/orders its rows by index "index #\d+", which isn't sorted/);
145
145
  });
146
146
  });
147
+
148
+ describe("Index.declarationOf", () => {
149
+ it("gives an index's declaration as registered, its with and not as declared, and nothing for a value Index.create didn't return", () => {
150
+ const byName = Index.create({ key: [name], unique: true, components: { with: [shown], not: [parent] } });
151
+ expect(Index.declarationOf(byName)).toEqual({ key: [name], components: { with: [shown], not: [parent] }, unique: true });
152
+ const byCell = Index.create({ key: [cell, amount], sorted: true });
153
+ expect(Index.declarationOf(byCell)).toEqual({ key: [cell, amount], components: {}, sorted: true });
154
+ expect(Index.declarationOf({ name: "not an index" })).toBeUndefined();
155
+ });
156
+ });
@@ -0,0 +1,34 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import type { Component } from '../component/component.js';
3
+ import { indexDefinitionOf } from '../registry/indexes.js';
4
+ import { kindOf } from '../registry/names.js';
5
+ import type { Index } from './index-type.js';
6
+
7
+ /**
8
+ * An index's declaration as `Index.create` registered it: its key, the components it holds by besides its key and those
9
+ * it excludes, and whether it's sorted or unique.
10
+ */
11
+ export type Declared = {
12
+ readonly key: readonly Component.Key[];
13
+ readonly components: { readonly with?: readonly Component.Key[]; readonly not?: readonly Component.Key[] };
14
+ readonly sorted?: boolean;
15
+ readonly unique?: boolean;
16
+ };
17
+
18
+ /** Registration takes only components for a key part, `with` and `not`. */
19
+ const isComponent = (key: symbol): key is Component.Key => kindOf(key) === "component";
20
+
21
+ /** The declaration `index` was created from, as registered, or undefined for a value `Index.create` didn't return. */
22
+ export const declarationOf = <D extends object>(index: Index<D>): Declared | undefined => {
23
+ const created = indexDefinitionOf(index);
24
+ if (created === undefined) return undefined;
25
+ const { key, components, sorted, unique } = created.declaration;
26
+ const held = components?.has?.filter(isComponent);
27
+ const excluded = components?.not?.filter(isComponent);
28
+ return {
29
+ key: key.filter(isComponent),
30
+ components: { ...held === undefined ? {} : { with: held }, ...excluded === undefined ? {} : { not: excluded } },
31
+ ...sorted === undefined ? {} : { sorted },
32
+ ...unique === undefined ? {} : { unique },
33
+ };
34
+ };
@@ -1,2 +1,3 @@
1
1
  // © 2026 Adobe. MIT License. See /LICENSE for details.
2
2
  export * from './create.js';
3
+ export { declarationOf, type Declared } from './declaration-of.js';