@adobe/ecs 0.2.3 → 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 (218) 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/body-access.d.ts +18 -1
  4. package/dist/implementation/compiler/body-access.js +104 -15
  5. package/dist/implementation/compiler/body-access.js.map +1 -1
  6. package/dist/implementation/compiler/estree.d.ts +5 -2
  7. package/dist/implementation/compiler/estree.js +8 -5
  8. package/dist/implementation/compiler/estree.js.map +1 -1
  9. package/dist/implementation/compiler/inferred.js +5 -2
  10. package/dist/implementation/compiler/inferred.js.map +1 -1
  11. package/dist/implementation/compiler/transform.js +5 -4
  12. package/dist/implementation/compiler/transform.js.map +1 -1
  13. package/dist/implementation/compiler/type-schemas.d.ts +1 -0
  14. package/dist/implementation/compiler/type-schemas.js +35 -2
  15. package/dist/implementation/compiler/type-schemas.js.map +1 -1
  16. package/dist/implementation/world/activate.d.ts +14 -3
  17. package/dist/implementation/world/activate.js +28 -10
  18. package/dist/implementation/world/activate.js.map +1 -1
  19. package/dist/implementation/world/buffer.d.ts +7 -0
  20. package/dist/implementation/world/buffer.js +7 -0
  21. package/dist/implementation/world/buffer.js.map +1 -1
  22. package/dist/implementation/world/cascade.d.ts +10 -3
  23. package/dist/implementation/world/cascade.js +48 -23
  24. package/dist/implementation/world/cascade.js.map +1 -1
  25. package/dist/implementation/world/commands.js +41 -5
  26. package/dist/implementation/world/commands.js.map +1 -1
  27. package/dist/implementation/world/create.js +57 -6
  28. package/dist/implementation/world/create.js.map +1 -1
  29. package/dist/implementation/world/execute.d.ts +4 -4
  30. package/dist/implementation/world/execute.js +228 -93
  31. package/dist/implementation/world/execute.js.map +1 -1
  32. package/dist/implementation/world/finalizers.d.ts +10 -0
  33. package/dist/implementation/world/finalizers.js +60 -10
  34. package/dist/implementation/world/finalizers.js.map +1 -1
  35. package/dist/implementation/world/join.js +12 -2
  36. package/dist/implementation/world/join.js.map +1 -1
  37. package/dist/implementation/world/program.js +5 -1
  38. package/dist/implementation/world/program.js.map +1 -1
  39. package/dist/implementation/world/resource-changes.d.ts +18 -0
  40. package/dist/implementation/world/resource-changes.js +66 -0
  41. package/dist/implementation/world/resource-changes.js.map +1 -0
  42. package/dist/implementation/world/rows.js +21 -16
  43. package/dist/implementation/world/rows.js.map +1 -1
  44. package/dist/implementation/world/run-changes.d.ts +38 -0
  45. package/dist/implementation/world/run-changes.js +138 -0
  46. package/dist/implementation/world/run-changes.js.map +1 -0
  47. package/dist/implementation/world/runner.js +16 -12
  48. package/dist/implementation/world/runner.js.map +1 -1
  49. package/dist/implementation/world/state.d.ts +15 -0
  50. package/dist/implementation/world/state.js +22 -1
  51. package/dist/implementation/world/state.js.map +1 -1
  52. package/dist/implementation/world/synchronize.js +81 -24
  53. package/dist/implementation/world/synchronize.js.map +1 -1
  54. package/dist/implementation/world/tables-read.d.ts +5 -3
  55. package/dist/implementation/world/tables-read.js +16 -4
  56. package/dist/implementation/world/tables-read.js.map +1 -1
  57. package/dist/implementation/world/tables.d.ts +4 -2
  58. package/dist/implementation/world/tables.js +137 -38
  59. package/dist/implementation/world/tables.js.map +1 -1
  60. package/dist/implementation/world/thread-observer.js +1 -1
  61. package/dist/implementation/world/thread-observer.js.map +1 -1
  62. package/dist/implementation/world/thread-systems.js +10 -6
  63. package/dist/implementation/world/thread-systems.js.map +1 -1
  64. package/dist/implementation/world/up.js +7 -1
  65. package/dist/implementation/world/up.js.map +1 -1
  66. package/dist/implementation/world/where.d.ts +8 -0
  67. package/dist/implementation/world/where.js +40 -11
  68. package/dist/implementation/world/where.js.map +1 -1
  69. package/dist/interface/finalizer/body.d.ts +40 -4
  70. package/dist/interface/finalizer/create.d.ts +8 -6
  71. package/dist/interface/finalizer/create.js +45 -10
  72. package/dist/interface/finalizer/create.js.map +1 -1
  73. package/dist/interface/finalizer/declaration.d.ts +26 -4
  74. package/dist/interface/indexes/declaration-of.d.ts +17 -0
  75. package/dist/interface/indexes/declaration-of.js +20 -0
  76. package/dist/interface/indexes/declaration-of.js.map +1 -0
  77. package/dist/interface/indexes/index.d.ts +1 -0
  78. package/dist/interface/indexes/index.js +1 -0
  79. package/dist/interface/indexes/index.js.map +1 -1
  80. package/dist/interface/registry/index.d.ts +1 -1
  81. package/dist/interface/registry/index.js +1 -1
  82. package/dist/interface/registry/index.js.map +1 -1
  83. package/dist/interface/registry/names.d.ts +4 -0
  84. package/dist/interface/registry/names.js +20 -0
  85. package/dist/interface/registry/names.js.map +1 -1
  86. package/dist/interface/registry/schedule.d.ts +29 -1
  87. package/dist/interface/registry/schedule.js +261 -25
  88. package/dist/interface/registry/schedule.js.map +1 -1
  89. package/dist/interface/system/access.d.ts +15 -0
  90. package/dist/interface/system/check.d.ts +7 -0
  91. package/dist/interface/system/check.js +21 -3
  92. package/dist/interface/system/check.js.map +1 -1
  93. package/dist/interface/system/commands.d.ts +31 -5
  94. package/dist/interface/system/create-resolved.js +2 -1
  95. package/dist/interface/system/create-resolved.js.map +1 -1
  96. package/dist/interface/system/create.d.ts +10 -6
  97. package/dist/interface/system/create.js +5 -4
  98. package/dist/interface/system/create.js.map +1 -1
  99. package/dist/interface/system/declaration.d.ts +11 -4
  100. package/dist/interface/system/describe.d.ts +120 -0
  101. package/dist/interface/system/describe.js +116 -0
  102. package/dist/interface/system/describe.js.map +1 -0
  103. package/dist/interface/system/index.d.ts +1 -0
  104. package/dist/interface/system/index.js +1 -0
  105. package/dist/interface/system/index.js.map +1 -1
  106. package/dist/interface/system/leaf.d.ts +7 -0
  107. package/dist/interface/system/render.js +2 -1
  108. package/dist/interface/system/render.js.map +1 -1
  109. package/dist/interface/system/resolve.d.ts +14 -1
  110. package/dist/interface/system/resolve.js +50 -1
  111. package/dist/interface/system/resolve.js.map +1 -1
  112. package/dist/interface/system/shared-only.d.ts +7 -0
  113. package/dist/interface/system/shared-only.js +2 -0
  114. package/dist/interface/system/shared-only.js.map +1 -0
  115. package/dist/interface/system/tables.d.ts +9 -1
  116. package/dist/interface/world/table/index.d.ts +3 -2
  117. package/dist/interface/world/world.d.ts +15 -0
  118. package/package.json +11 -2
  119. package/src/guide/01-resources.md +4 -3
  120. package/src/guide/03-systems.out +1 -1
  121. package/src/guide/05-scheduling.md +12 -6
  122. package/src/guide/05-scheduling.out +1 -0
  123. package/src/guide/05-scheduling.ts +17 -0
  124. package/src/guide/06-sync-points.md +20 -8
  125. package/src/guide/06-sync-points.out +3 -2
  126. package/src/guide/06-sync-points.ts +24 -5
  127. package/src/guide/10-finalizers.md +5 -0
  128. package/src/guide/18-identity.out +1 -1
  129. package/src/guide/guide.test.ts +6 -153
  130. package/src/implementation/compiler/augmentations.test.ts +28 -1
  131. package/src/implementation/compiler/augmentations.ts +5 -1
  132. package/src/implementation/compiler/body-access.ts +102 -12
  133. package/src/implementation/compiler/estree.ts +9 -6
  134. package/src/implementation/compiler/inferred.test.ts +1 -1
  135. package/src/implementation/compiler/inferred.ts +5 -2
  136. package/src/implementation/compiler/transform.test.ts +41 -21
  137. package/src/implementation/compiler/transform.ts +5 -3
  138. package/src/implementation/compiler/type-schemas.ts +28 -2
  139. package/src/implementation/world/activate.ts +32 -10
  140. package/src/implementation/world/batches.test.ts +3 -3
  141. package/src/implementation/world/buffer.ts +7 -0
  142. package/src/implementation/world/cascade.ts +46 -22
  143. package/src/implementation/world/commands.ts +37 -5
  144. package/src/implementation/world/create.test.ts +8 -8
  145. package/src/implementation/world/create.ts +61 -6
  146. package/src/implementation/world/defaults.test.ts +24 -1
  147. package/src/implementation/world/despawn-where.test.ts +310 -0
  148. package/src/implementation/world/execute.ts +222 -86
  149. package/src/implementation/world/finalizers.ts +63 -11
  150. package/src/implementation/world/ids.test.ts +1 -1
  151. package/src/implementation/world/indexes.test.ts +2 -1
  152. package/src/implementation/world/join.test.ts +19 -0
  153. package/src/implementation/world/join.ts +16 -2
  154. package/src/implementation/world/lifetime.test-helper.ts +1 -1
  155. package/src/implementation/world/names.test.ts +1 -2
  156. package/src/implementation/world/passes.test.ts +61 -0
  157. package/src/implementation/world/perf.md +12 -1
  158. package/src/implementation/world/program.ts +4 -1
  159. package/src/implementation/world/resource-changes.test.ts +61 -0
  160. package/src/implementation/world/resource-changes.ts +67 -0
  161. package/src/implementation/world/rows.ts +21 -14
  162. package/src/implementation/world/rules.test.ts +37 -0
  163. package/src/implementation/world/run-changes.test.ts +88 -0
  164. package/src/implementation/world/run-changes.ts +160 -0
  165. package/src/implementation/world/run-results.test.ts +17 -0
  166. package/src/implementation/world/runner.ts +17 -13
  167. package/src/implementation/world/schedule.test.ts +42 -1
  168. package/src/implementation/world/shared.test.ts +2 -2
  169. package/src/implementation/world/state.ts +27 -0
  170. package/src/implementation/world/sync-points.test.ts +147 -0
  171. package/src/implementation/world/synchronize.ts +73 -20
  172. package/src/implementation/world/tables-read.ts +16 -5
  173. package/src/implementation/world/tables.test.ts +139 -4
  174. package/src/implementation/world/tables.ts +118 -35
  175. package/src/implementation/world/thread-observer.ts +1 -1
  176. package/src/implementation/world/thread-systems.ts +10 -6
  177. package/src/implementation/world/up.test.ts +13 -1
  178. package/src/implementation/world/up.ts +6 -1
  179. package/src/implementation/world/watch-traits-late.test-helper.ts +14 -0
  180. package/src/implementation/world/watch-traits.test-helper.ts +10 -0
  181. package/src/implementation/world/watch-traits.test.ts +62 -0
  182. package/src/implementation/world/where.ts +38 -10
  183. package/src/interface/finalizer/body.ts +33 -4
  184. package/src/interface/finalizer/create.test.ts +6 -1
  185. package/src/interface/finalizer/create.ts +45 -12
  186. package/src/interface/finalizer/declaration.ts +31 -4
  187. package/src/interface/indexes/create.test.ts +10 -0
  188. package/src/interface/indexes/declaration-of.ts +34 -0
  189. package/src/interface/indexes/index.ts +1 -0
  190. package/src/interface/registry/index.ts +1 -1
  191. package/src/interface/registry/names.ts +19 -0
  192. package/src/interface/registry/schedule.ts +275 -29
  193. package/src/interface/rule/rule.type-test.ts +23 -1
  194. package/src/interface/system/access.ts +16 -0
  195. package/src/interface/system/check.ts +17 -2
  196. package/src/interface/system/commands.ts +28 -4
  197. package/src/interface/system/commands.type-test.ts +31 -0
  198. package/src/interface/system/create-resolved.ts +2 -1
  199. package/src/interface/system/create.test.ts +10 -2
  200. package/src/interface/system/create.ts +9 -5
  201. package/src/interface/system/declaration.ts +10 -4
  202. package/src/interface/system/describe.test.ts +98 -0
  203. package/src/interface/system/describe.ts +122 -0
  204. package/src/interface/system/index.ts +1 -0
  205. package/src/interface/system/leaf.ts +7 -0
  206. package/src/interface/system/render.ts +2 -1
  207. package/src/interface/system/resolve.test.ts +17 -1
  208. package/src/interface/system/resolve.ts +60 -2
  209. package/src/interface/system/shared-only.ts +5 -0
  210. package/src/interface/system/system.type-test.ts +6 -0
  211. package/src/interface/system/tables.ts +7 -1
  212. package/src/interface/world/run.type-test.ts +11 -1
  213. package/src/interface/world/table/index.ts +3 -2
  214. package/src/interface/world/world.ts +13 -0
  215. package/src/perftest/benchmarks.ts +20 -0
  216. package/src/perftest/chained.ts +108 -0
  217. package/src/perftest/structural.ts +20 -0
  218. 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();
@@ -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
+ });
@@ -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);
@@ -5,8 +5,12 @@ 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
+ import type { Resource } from '../resource/resource.js';
11
+ import type { Resources } from '../resource/types.js';
12
+ import type { Components } from '../component/types.js';
13
+ import type { ReadonlyType as ResourceType } from '../resource/value.js';
10
14
  import type { Elsewhere, ThreadsOf } from '../system/threads.js';
11
15
 
12
16
  /**
@@ -28,8 +32,28 @@ export type Context<D extends Declaration | ChangesDeclaration> =
28
32
  })
29
33
  & (true extends Elsewhere<ThreadsOf<D>> ? {} : { readonly commands: Commands });
30
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
+
31
51
  /** The components a changes finalizer sees. */
32
- export type Watched<D extends ChangesDeclaration> = Property<D, "watch"> extends readonly Essential[] ? 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;
54
+
55
+ /** The resources a changes finalizer sees: those it watches. */
56
+ export type WatchedResources<D extends ChangesDeclaration> = ResourcesOf<WatchListed<D, "resources">>;
33
57
 
34
58
  /** Entities whose components changed the same way: `from` and `to` are null for those spawned and despawned. */
35
59
  export type Group<C extends Component.Key> = {
@@ -42,9 +66,14 @@ export type Group<C extends Component.Key> = {
42
66
  readonly entities: readonly Entity[];
43
67
  };
44
68
 
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> = {
69
+ /**
70
+ * A sync point's structural changes to the watched components, and the values adds replace, before they apply; and each
71
+ * watched resource written since the last sync point's finalizers ran, to a value that isn't equal to its last one,
72
+ * leaf by leaf for data and by identity for objects, with its value before and after.
73
+ */
74
+ export type ChangeSet<C extends Component.Key, R extends Resource.Key = never> = {
47
75
  readonly groups: readonly Group<C>[];
76
+ readonly resources: readonly { readonly [K in R]: { readonly key: K; readonly previous: ResourceType<K>; readonly next: ResourceType<K> } }[R][];
48
77
  /** The value before the sync point, or null if the entity didn't have it. */
49
78
  previous<K extends C>(entity: Entity, component: K): ReadonlyType<K> | null;
50
79
  /** The value after it, or null if the entity won't have it. */
@@ -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: "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, 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);
@@ -93,6 +96,8 @@ describe("Finalizer.create and Finalizer.changes", () => {
93
96
  .toThrow('uses "typesFinalizerTest/tags" on "world" and "typesFinalizerTest/page" on "main"');
94
97
  expect(() => untyped(Finalizer.changes, {}, { resources: { read: [page] } }))
95
98
  .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`);
99
+ expect(() => untyped(Finalizer.changes, { name: "savePage", watch: [page] }))
100
+ .toThrow(`watches or reads values on thread "main"; a changes finalizer runs on the world's thread, in the sync point.`);
96
101
  // Reading another thread's values, without writing or looking up, it's created.
97
102
  const detach = Finalizer.create({ components: { with: [node] } }, row => { void row[node]; });
98
103
  expect(programFinalizers()).toHaveLength(8);
@@ -6,11 +6,13 @@ import { addFinalizer, programFinalizers } from '../registry/program.js';
6
6
  import { requireRegistered, threadOf } from '../registry/threads.js';
7
7
  import { checkWorldWritten } from '../registry/written.js';
8
8
  import { checkLookup } from '../registry/indexes.js';
9
- import type { Listed, Property } from '../system/property.js';
9
+ import type { 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, WatchListed } 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 extends ChangesDeclaration> = true extends Elsewhere<ThreadsOf<D> | ThreadOfComponent<Extract<WatchListed<D, "components">, Component.Key>> | ThreadOfResource<Extract<WatchListed<D, "resources">, Resource.Key>>>
30
32
  ? { readonly "error: a changes finalizer watches and reads only the world's thread's objects": true }
31
33
  : {};
32
34
 
@@ -125,19 +127,52 @@ export const create = <const D extends Declaration>(
125
127
  return addFinalizer(finalizer, id);
126
128
  };
127
129
 
130
+ const isList = (watch: NonNullable<ChangesDeclaration["watch"]>): watch is Extract<ChangesDeclaration["watch"], readonly unknown[]> => Array.isArray(watch);
131
+
132
+ /** What a changes finalizer watches, sorted by kind. Throws for a key that's neither of the kinds it's listed as, or derived. */
133
+ const watchOf = (name: string, watch: ChangesDeclaration["watch"]): ResolvedChanges["watch"] => {
134
+ const components: symbol[] = [];
135
+ const componentTraits: symbol[] = [];
136
+ const resources: symbol[] = [];
137
+ const resourceTraits: symbol[] = [];
138
+ const sort = (key: unknown, kinds: readonly ("component" | "resource")[]): void => {
139
+ const kind = kindOf(key);
140
+ if (typeof key !== "symbol" || kind === undefined || (kind !== "trait" && !kinds.includes(kind))) {
141
+ const among = kinds.length === 2 ? "" : ` among its ${kinds[0]}s`;
142
+ throw new Error(`Finalizer "${name}" watches "${labelOf(key)}"${among}, which isn't a registered ${kinds.length === 2 ? "component, resource" : kinds[0]} or trait: ${whyUnregistered(key)}.`);
143
+ }
144
+ if (kind === "trait") {
145
+ if (kinds.includes("component")) componentTraits.push(key);
146
+ if (kinds.includes("resource")) resourceTraits.push(key);
147
+ return;
148
+ }
149
+ 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.`);
150
+ (kind === "component" ? components : resources).push(key);
151
+ };
152
+ if (watch === undefined) return { components: true, componentTraits, resources, resourceTraits };
153
+ if (isList(watch)) {
154
+ for (const key of watch) sort(key, ["component", "resource"]);
155
+ return { components, componentTraits, resources, resourceTraits };
156
+ }
157
+ const every = watch.components === true;
158
+ if (!every) for (const key of watch.components) sort(key, ["component"]);
159
+ for (const key of watch.resources) sort(key, ["resource"]);
160
+ return { components: every ? true : components, componentTraits: every ? [] : componentTraits, resources, resourceTraits };
161
+ };
162
+
128
163
  /**
129
164
  * 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.
165
+ * on the world's thread. Throws as `create` does, for a watched component that isn't registered or is derived, a
166
+ * watched key that's neither a component nor a resource, and for one watching or reading another thread's values.
132
167
  */
133
168
  export const changes = <const D extends ChangesDeclaration>(
134
169
  declared: D & Inferred & OnWorld<D>,
135
- body: (changes: ChangeSet<Watched<D>>, context: Context<D>) => void,
170
+ body: (changes: ChangeSet<Watched<D>, WatchedResources<D>>, context: Context<D>) => void,
136
171
  ): Finalizer => {
137
172
  const id = takeIdOfDeclaration(declared);
138
173
  const name = nameOf(declared, id, "finalizer");
139
174
  const what = `Finalizer "${name}"`;
140
- const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), ...declared.watch === undefined ? {} : { watch: declared.watch } };
175
+ const declaration: ResolvedChanges = { ...resolveShared(what, declared, accessOf(what, declared)), watch: watchOf(name, declared.watch) };
141
176
  const finalizer: Changes = { kind: "changes", name, declaration, body };
142
177
  checkResources(name, declaration.resources);
143
178
  requireRegistered("Finalizer", name, "resource", declaration.resources?.read);
@@ -147,11 +182,9 @@ export const changes = <const D extends ChangesDeclaration>(
147
182
  checkLookup("Finalizer", name, lookupName, lookup);
148
183
  for (const keys of [lookup.read, "has" in lookup ? lookup.has : undefined, "not" in lookup ? lookup.not : undefined]) requireRegistered("Finalizer", name, "component", keys);
149
184
  }
150
- 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)}.`);
152
- 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
- }
154
- const thread = threadOf({ components: { read: declaration.watch }, resources: declaration.resources, lookup: declaration.lookup }, "Finalizer", name);
185
+ const { watch } = declaration;
186
+ const read = [...declaration.resources?.read ?? [], ...watch.resources];
187
+ const thread = threadOf({ components: { read: watch.components === true ? [] : watch.components }, resources: { ...declaration.resources, read }, lookup: declaration.lookup }, "Finalizer", name);
155
188
  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
189
  checkOrder(name, "world", declaration);
157
190
  return addFinalizer(finalizer, id);
@@ -4,6 +4,9 @@ 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';
8
+ import type { Traits } from '../trait/types.js';
9
+ import type { BuiltIn } from '../trait/key.js';
7
10
 
8
11
  /**
9
12
  * What every finalizer declares. What its body reads of its row and lookups, and reads and writes of resources, the
@@ -24,11 +27,23 @@ export type Declaration = Shared & {
24
27
  readonly components: { readonly with?: readonly Component.Key[]; readonly optional?: readonly Component.Key[]; readonly not?: readonly Component.Key[] };
25
28
  };
26
29
 
30
+ /** A trait of your own, which watches every component and resource declaring it. */
31
+ export type OwnTrait = Exclude<keyof Traits<never>, BuiltIn>;
32
+
27
33
  /**
28
- * 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.
34
+ * A finalizer that sees each sync point's structural changes to the components it watches, and the resources it
35
+ * watches written since the last sync point's finalizers ran. By default it watches every component that isn't derived
36
+ * or an object a joined thread fills, and no resource. `watch` lists components, resources and traits of your own, or,
37
+ * as an object, every component (`components: true`) or those listed, beside the resources listed. A trait watches each
38
+ * component and resource declaring it, as each registers, but those the default leaves out and resources on another
39
+ * thread.
30
40
  */
31
- export type ChangesDeclaration = Shared & { readonly watch?: readonly Essential[] };
41
+ export type ChangesDeclaration = Shared & {
42
+ readonly watch?: readonly (Essential | Resource.Key | OwnTrait)[] | {
43
+ readonly components: true | readonly (Essential | OwnTrait)[];
44
+ readonly resources: readonly (Resource.Key | OwnTrait)[];
45
+ };
46
+ };
32
47
 
33
48
  type Key = symbol;
34
49
 
@@ -45,4 +60,16 @@ export type Resolved = ResolvedShared & {
45
60
  readonly components: { readonly read?: readonly Key[]; readonly has?: readonly Key[]; readonly optional?: readonly Key[]; readonly not?: readonly Key[] };
46
61
  };
47
62
 
48
- export type ResolvedChanges = ResolvedShared & { readonly watch?: readonly Key[] };
63
+ /**
64
+ * A changes finalizer's declaration as the world takes it. It watches the components listed, or every one (`true`),
65
+ * and those declaring a trait of `componentTraits`; the resources listed, and those declaring a trait of
66
+ * `resourceTraits`.
67
+ */
68
+ export type ResolvedChanges = ResolvedShared & {
69
+ readonly watch: {
70
+ readonly components: true | readonly Key[];
71
+ readonly componentTraits: readonly Key[];
72
+ readonly resources: readonly Key[];
73
+ readonly resourceTraits: readonly Key[];
74
+ };
75
+ };
@@ -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';
@@ -3,7 +3,7 @@
3
3
  export type { AnyDeclaration } from './declarations.js';
4
4
  export { schemaOf, threadOfDeclaration } from './declarations.js';
5
5
  export type { Kind } from './names.js';
6
- export { declarationOf, firstKeyOf, keyOfId, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js';
6
+ export { declarationOf, declaresTrait, declaredOf, firstKeyOf, keyOfId, keysVersion, kindOf, labelOf, newestKeyOf, whyUnregistered } from './names.js';
7
7
  export type { Item } from './program.js';
8
8
  export { definitionOf, programFinalizers, programRules, programVersion, requireDefinition } from './program.js';
9
9
  export type { Schedule } from './schedule.js';
@@ -22,6 +22,9 @@ type Entry = {
22
22
  /** Every component, resource and user trait, by symbol identity: symbols of one id share an entry. */
23
23
  const entries = new Map<symbol, Entry>();
24
24
 
25
+ /** How many keys were registered, counting a symbol registered under another's id. */
26
+ let registered = 0;
27
+
25
28
  /** Entries by the ids the plugin gave their keys. */
26
29
  const identified = new Map<string, Entry>();
27
30
 
@@ -155,12 +158,28 @@ export const claimKey = (key: unknown, kind: Kind, declaration?: AnyDeclaration)
155
158
  }
156
159
  named.newest = symbol;
157
160
  entries.set(symbol, named);
161
+ registered++;
158
162
  return;
159
163
  }
160
164
  const description = descriptionOf(symbol);
161
165
  const entry: Entry = { kind, declaration, description, id, first: symbol, newest: symbol };
162
166
  if (id !== undefined) identified.set(id, entry);
163
167
  entries.set(symbol, entry);
168
+ registered++;
169
+ };
170
+
171
+ /** How many keys are registered so far, so a world can tell when one registered since it last looked. */
172
+ export const keysVersion = (): number => {
173
+ catchUp();
174
+ return registered;
175
+ };
176
+
177
+ /** Whether a component's or resource's declaration declares one of `traits`, keys of traits of your own. */
178
+ export const declaresTrait = (key: unknown, traits: readonly symbol[]): boolean => {
179
+ const declaration = declarationOf(key);
180
+ if (declaration === undefined) return false;
181
+ for (const declared of Object.getOwnPropertySymbols(declaration)) for (const trait of traits) if (sameKey(declared, trait)) return true;
182
+ return false;
164
183
  };
165
184
 
166
185
  /** The symbol last registered under an id, by which a world gives values, or undefined where none was. */