@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
@@ -819,6 +819,9 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH
819
819
  if (error.notData.node && !thread) fail(module, member, `${key.name} is a DOM Node, which lives on "main": declare \`thread: "main"\``);
820
820
  }
821
821
  await checkBuiltIns(key.name, kind, typeNode, schema, builtIns);
822
+ const freshType = schema !== undefined ? null : await converter.freshOf(typeNode, module);
823
+ const emptied = schema !== undefined && "type" in schema && (schema.type === "map" || schema.type === "set") ? (schema.type === "map" ? "Map" : "Set") : freshType;
824
+ if (defaultNode !== undefined && emptied !== null) fail(module, defaultNode, `${key.name} is a Readonly${emptied}, which starts empty in each world: leave its default out`);
822
825
  const given = defaultNode === undefined ? undefined : await literalDefault(defaultNode, key.name);
823
826
  if (schema !== undefined && given !== undefined && !matchesSchema(schema, given)) fail(module, defaultNode ?? member, `${key.name}'s default isn't of its declared type`);
824
827
  let start = given === undefined ? undefined : codeOf(given);
@@ -838,7 +841,8 @@ export const augmentationInsertions = async (module: Module, host: AugmentationH
838
841
  const namespaced = await namespacedDefault(typeNode, key.name, undefined, oneObject);
839
842
  if (namespaced !== null) read({ text: namespaced.text, locals: namespaced.local === undefined ? [] : [namespaced.local], given: true });
840
843
  else if (needed && !await allowsNullType(typeNode, module, new Set())) {
841
- fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, or give its type a namespace with a deeply readonly \`default\`; a ReadonlyMap or ReadonlySet starts fresh in each world, a Map or Set doesn't`);
844
+ if (freshType !== null) fresh = freshType;
845
+ else fail(module, member, `${kind} ${key.name} holds a value that isn't data, so nothing gives it a start: let its type allow null, give its type a namespace with a deeply readonly \`default\`, or declare it a ReadonlyMap or ReadonlySet, which starts empty in each world`);
842
846
  }
843
847
  }
844
848
  else {
@@ -9,14 +9,23 @@ type Access = { readonly read: ReadonlySet<string>; readonly write: ReadonlySet<
9
9
  /** The keys a body reads, writes and contributes to, by module-scope binding name; null where the body doesn't show every use. */
10
10
  export type Used = {
11
11
  readonly query: Access | null;
12
+ /** Where and why the build lost track of the row, unless the declaration takes it whole on purpose. */
13
+ readonly rowLost?: { readonly at: Node; readonly why: string };
12
14
  readonly resources: (Access & { readonly reduce: ReadonlySet<string> }) | null;
13
15
  /** The components read from each lookup's results, by lookup name: null for one whose results aren't followed. */
14
16
  readonly lookups: ReadonlyMap<string, ReadonlySet<string> | null> | null;
15
17
  /** The components read from `up`. */
16
18
  readonly up: ReadonlySet<string> | null;
19
+ /**
20
+ * What its commands name, by kind: keys and lists of keys, as their module-scope bindings; whether it despawns.
21
+ * Null where the body passes its commands on, aliases them, or names what they take otherwise.
22
+ */
23
+ readonly commands: Commanded | null;
17
24
  };
18
25
 
19
- const unknown: Used = { query: null, resources: null, lookups: null, up: null };
26
+ type Commanded = { readonly spawn: ReadonlySet<string>; readonly add: ReadonlySet<string>; readonly remove: ReadonlySet<string>; readonly despawn: boolean };
27
+
28
+ const unknownAt = (at: Node, why: string): Used => ({ query: null, rowLost: { at, why }, resources: null, lookups: null, up: null, commands: null });
20
29
 
21
30
  /** Whether `node`, a child of `parent`, names a property or label, or declares a binding, rather than referring to one. */
22
31
  const isName = (node: Node, parent: Node | undefined): boolean => {
@@ -115,6 +124,13 @@ const indexLookupNames = (declaration: ObjectExpression): ReadonlySet<string> =>
115
124
 
116
125
  type Group = "resources" | "lookup" | "reduce" | "up";
117
126
 
127
+ /** Whether a declaration's `components` says `whole: true`: its body uses its row whole, on purpose. */
128
+ const takesRowWhole = (declaration: ObjectExpression): boolean => declaration.properties.some(property =>
129
+ property.type === "Property" && !property.computed && property.key.type === "Identifier" && property.key.name === "components"
130
+ && property.value.type === "ObjectExpression" && property.value.properties.some(member =>
131
+ member.type === "Property" && !member.computed && member.key.type === "Identifier" && member.key.name === "whole"
132
+ && member.value.type === "Literal" && member.value.value === true));
133
+
118
134
  const isGroup = (name: string): name is Group => name === "resources" || name === "lookup" || name === "reduce" || name === "up";
119
135
 
120
136
  /**
@@ -123,15 +139,27 @@ const isGroup = (name: string): name is Group => name === "resources" || name ==
123
139
  * context's `resources`, `lookup` and `reduce` are indexed only by module-scope keys the body doesn't shadow, or
124
140
  * destructured by them, and never passed on, aliased or spread. A lookup's results are followed where each is indexed
125
141
  * by such keys or tested, directly or through a `const` declared once; a lookup through an index's lists where they're
126
- * read for `length` and `changed`, and their rows from `at` as a lookup's results are.
142
+ * read for `length` and `changed`, and their rows from `at` as a lookup's results are. A row whose declaration takes it
143
+ * `whole` isn't followed.
127
144
  */
128
145
  export const usedBy = (declaration: ObjectExpression, body: Node, module: ModuleScope, positions: { readonly row: number; readonly context: number }): Used => {
129
- if (!isFunctionBody(body)) return unknown;
130
- if (body.type === "FunctionExpression" && mentions(body.body, "arguments")) return unknown;
146
+ const used = followedBy(declaration, body, module, positions);
147
+ if (takesRowWhole(declaration)) return { query: null, resources: used.resources, lookups: used.lookups, up: used.up, commands: used.commands };
148
+ // A body without a row loses none.
149
+ return positions.row < 0 && used.rowLost !== undefined ? { query: used.query, resources: used.resources, lookups: used.lookups, up: used.up, commands: used.commands } : used;
150
+ };
151
+
152
+ const followedBy = (declaration: ObjectExpression, body: Node, module: ModuleScope, positions: { readonly row: number; readonly context: number }): Used => {
153
+ if (!isFunctionBody(body)) return unknownAt(body, "its body isn't a function written in place");
154
+ if (body.type === "FunctionExpression" && mentions(body.body, "arguments")) return unknownAt(body, "it reads `arguments`");
131
155
  // Code `eval` runs can use anything.
132
- if (mentions(body, "eval")) return unknown;
156
+ if (mentions(body, "eval")) return unknownAt(body, "it calls `eval`");
133
157
  const counts = new Map<string, number>();
134
- declaredIn(body.body, name => counts.set(name, (counts.get(name) ?? 0) + 1));
158
+ const declaredAt = new Map<string, Node>();
159
+ declaredIn(body.body, (name, at) => {
160
+ counts.set(name, (counts.get(name) ?? 0) + 1);
161
+ if (!declaredAt.has(name)) declaredAt.set(name, at);
162
+ });
135
163
  const parameters = new Set(body.params.flatMap(parameter => patternNames(parameter)));
136
164
  const isKey = (name: string): boolean => module.has(name) && !counts.has(name) && !parameters.has(name);
137
165
  /** The key `node` names: a module-scope binding, or a namespace's member (`Shapes.circle`), as its source; null for any other. */
@@ -150,9 +178,15 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
150
178
  const up = new Set<string>();
151
179
  let upKnown = true;
152
180
  const lookupReads = new Map<string, Set<string> | null>((lookups ?? []).map(name => [name, new Set<string>()]));
153
- let queryKnown = true;
181
+ let rowLost: { readonly at: Node; readonly why: string } | undefined;
182
+ /** Stops following the row, at the first use it can't. */
183
+ const loseRow = (at: Node, why: string): void => { rowLost ??= { at, why }; };
154
184
  let resourcesKnown = true;
155
185
  let lookupsKnown = lookups !== null;
186
+ const commands = { spawn: new Set<string>(), add: new Set<string>(), remove: new Set<string>(), despawn: false };
187
+ let commandsKnown = true;
188
+ /** Names bound to the context's commands. */
189
+ const commandNames = new Set<string>();
156
190
  const unknownLookup = (lookup: string): void => { lookupReads.set(lookup, null); };
157
191
  const unknownGroup = (group: Group): void => {
158
192
  if (group === "lookup") lookupsKnown = false;
@@ -206,6 +240,11 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
206
240
  };
207
241
  /** Binds the context's groups from a destructuring of it; `declaredOnce` says whether a bound name is safe to follow. */
208
242
  const contextNamed = (declaredOnce: (name: string) => boolean) => (property: string, value: Pattern): boolean => {
243
+ if (property === "commands") {
244
+ if (value.type === "Identifier" && declaredOnce(value.name)) commandNames.add(value.name);
245
+ else commandsKnown = false;
246
+ return true;
247
+ }
209
248
  if (!isGroup(property)) return true;
210
249
  if (value.type === "Identifier" && declaredOnce(value.name)) groups.set(value.name, property);
211
250
  else if (value.type === "ObjectPattern") destructureGroup(property, value);
@@ -216,13 +255,15 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
216
255
  resourcesKnown = false;
217
256
  lookupsKnown = false;
218
257
  upKnown = false;
258
+ commandsKnown = false;
219
259
  };
220
260
 
221
261
  const row = positions.row < 0 ? undefined : body.params[positions.row];
222
262
  const context = positions.context < 0 ? undefined : body.params[positions.context];
223
263
  const rowName = row?.type === "Identifier" && !counts.has(row.name) ? row.name : null;
224
264
  const contextName = context?.type === "Identifier" && !counts.has(context.name) ? context.name : null;
225
- if (row !== undefined && rowName === null && !destructure(row, rowNamed, key => query.read.add(key))) queryKnown = false;
265
+ if (row?.type === "Identifier" && rowName === null) loseRow(declaredAt.get(row.name) ?? row, `it declares another \`${row.name}\` here, which hides its row: rename one`);
266
+ else if (row !== undefined && rowName === null && !destructure(row, rowNamed, key => query.read.add(key))) loseRow(row, "it destructures its row by something other than its keys and `entity`");
226
267
  if (context !== undefined && contextName === null && !destructure(context, contextNamed(name => !counts.has(name)), () => unknownContext())) unknownContext();
227
268
 
228
269
  /** A lookup's row `value`, a child of `ancestors[at]`: indexed by a key, read for `entity`, bound to a `const`, or tested. */
@@ -284,6 +325,44 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
284
325
  const lookup = list.type === "Identifier" ? results.get(list.name) : calledLookup(list);
285
326
  return lookup !== undefined && indexed.has(lookup) ? lookup : undefined;
286
327
  };
328
+ /** Whether `node` is `context.commands`. */
329
+ const isContextCommands = (node: Node): boolean => node.type === "MemberExpression" && !node.computed && node.object.type === "Identifier"
330
+ && node.object.name === contextName && node.property.type === "Identifier" && node.property.name === "commands";
331
+ /** The keys and lists of keys a command's argument names: a key or list by binding, or an array literal of them. */
332
+ const listOf = (node: Node | undefined): readonly string[] | null => {
333
+ if (node === undefined) return null;
334
+ const key = keyOf(node);
335
+ if (key !== null) return [key];
336
+ if (node.type !== "ArrayExpression") return null;
337
+ const names: string[] = [];
338
+ for (const element of node.elements) {
339
+ const named = element === null ? null : keyOf(element.type === "SpreadElement" ? element.argument : element);
340
+ if (named === null) return null;
341
+ names.push(named);
342
+ }
343
+ return names;
344
+ };
345
+ /** A use of the commands at `node`, a child of `ancestors[at]`: a call of one of them, whose keys are listed. */
346
+ const useCommands = (node: Node, ancestors: readonly Node[], at: number): void => {
347
+ const parent = ancestors[at];
348
+ const call = ancestors[at - 1];
349
+ if (parent?.type !== "MemberExpression" || parent.object !== node || parent.computed || parent.property.type !== "Identifier"
350
+ || call?.type !== "CallExpression" || call.callee !== parent) {
351
+ commandsKnown = false;
352
+ return;
353
+ }
354
+ const command = parent.property.name;
355
+ if (command === "despawn" || command === "despawnWhere") {
356
+ commands.despawn = true;
357
+ return;
358
+ }
359
+ const listed = command === "spawn" ? listOf(call.arguments[0]) : command === "add" || command === "remove" ? listOf(call.arguments[1]) : null;
360
+ if (listed === null || (command !== "spawn" && command !== "add" && command !== "remove")) {
361
+ commandsKnown = false;
362
+ return;
363
+ }
364
+ for (const name of listed) commands[command].add(name);
365
+ };
287
366
  /** Finds groups and lookups bound by `const` declarations declared once, and `const`s holding a lookup's result, before any reference is classified. */
288
367
  const collect = (node: Node, kind: string | undefined): void => {
289
368
  if (node.type === "VariableDeclarator" && kind === "const" && node.init) {
@@ -298,6 +377,10 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
298
377
  groups.set(node.id.name, group);
299
378
  followed.add(node.init);
300
379
  }
380
+ if (isContextCommands(node.init)) {
381
+ commandNames.add(node.id.name);
382
+ followed.add(node.init);
383
+ }
301
384
  }
302
385
  else if (node.init.type === "Identifier" && node.init.name === contextName) {
303
386
  if (destructure(node.id, contextNamed(once), () => unknownContext())) followed.add(node.init);
@@ -354,31 +437,36 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
354
437
  if (parent?.type === "MemberExpression" && parent.object === node) {
355
438
  const { property } = parent;
356
439
  if (!parent.computed) {
357
- if (property.type !== "Identifier" || !rowNamed(property.name)) queryKnown = false;
440
+ if (property.type !== "Identifier" || !rowNamed(property.name)) loseRow(parent, "it reads a member of its row by name, and a row names only `entity`");
358
441
  return;
359
442
  }
360
443
  const key = keyOf(property);
361
444
  // A part of a value written, `row[key][0] = …`, writes it, which the compiler rejects as it must.
362
445
  const use = key === null ? null : deepUseOf(parent, ancestors, ancestors.length - 2);
363
- if (use === null || key === null) queryKnown = false;
446
+ if (key === null) loseRow(parent, "it indexes its row by something other than a module-scope key");
447
+ else if (use === null) loseRow(parent, "it deletes a member of its row");
364
448
  else {
365
449
  if (use.read) query.read.add(key);
366
450
  if (use.write) query.write.add(key);
367
451
  if (use.mutate) query.mutate.add(key);
368
452
  }
369
453
  }
370
- else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) queryKnown = false;
454
+ else if (!(parent?.type === "VariableDeclarator" && parent.init === node && destructure(parent.id, rowNamed, key => query.read.add(key)))) {
455
+ loseRow(parent ?? node, "it passes its row on, aliases or spreads it");
456
+ }
371
457
  }
372
458
  else if (name === contextName) {
373
459
  if (parent?.type === "MemberExpression" && parent.object === node && !parent.computed && parent.property.type === "Identifier") {
374
460
  const property = parent.property.name;
375
461
  if (isGroup(property)) useGroup(property, parent, ancestors, ancestors.length - 2);
462
+ else if (property === "commands" && !followed.has(parent)) useCommands(parent, ancestors, ancestors.length - 2);
376
463
  return;
377
464
  }
378
465
  if (!followed.has(node)) unknownContext();
379
466
  }
380
467
  else {
381
468
  const at = ancestors.length - 1;
469
+ if (commandNames.has(name)) return useCommands(node, ancestors, at);
382
470
  const group = groups.get(name);
383
471
  if (group !== undefined) return useGroup(group, node, ancestors, at);
384
472
  const caller = callers.get(name);
@@ -426,9 +514,11 @@ export const usedBy = (declaration: ObjectExpression, body: Node, module: Module
426
514
  for (const parameter of body.params) visitPattern(parameter);
427
515
  visit(body.body);
428
516
  return {
429
- query: queryKnown ? query : null,
517
+ query: rowLost === undefined ? query : null,
518
+ ...rowLost === undefined ? {} : { rowLost },
430
519
  resources: resourcesKnown ? resources : null,
431
520
  lookups: lookupsKnown ? lookupReads : null,
432
521
  up: upKnown ? up : null,
522
+ commands: commandsKnown ? commands : null,
433
523
  };
434
524
  };
@@ -47,18 +47,21 @@ export const patternNames = (pattern: Pattern): readonly string[] => {
47
47
  }
48
48
  };
49
49
 
50
- /** Calls `declare` for each name declared anywhere inside `node`, once per declaration: variables, parameters, functions, classes and catch parameters. */
51
- export const declaredIn = (node: Node, declare: (name: string) => void): void => {
50
+ /**
51
+ * Calls `declare` for each name declared anywhere inside `node`, once per declaration, with the pattern or name that
52
+ * declares it: variables, parameters, functions, classes and catch parameters.
53
+ */
54
+ export const declaredIn = (node: Node, declare: (name: string, at: Node) => void): void => {
52
55
  switch (node.type) {
53
- case "VariableDeclarator": for (const name of patternNames(node.id)) declare(name); break;
56
+ case "VariableDeclarator": for (const name of patternNames(node.id)) declare(name, node.id); break;
54
57
  case "FunctionDeclaration": case "FunctionExpression": case "ClassDeclaration": case "ClassExpression":
55
- if (node.id) declare(node.id.name);
58
+ if (node.id) declare(node.id.name, node.id);
56
59
  break;
57
- case "CatchClause": if (node.param) for (const name of patternNames(node.param)) declare(name); break;
60
+ case "CatchClause": if (node.param) for (const name of patternNames(node.param)) declare(name, node.param); break;
58
61
  default: break;
59
62
  }
60
63
  if (node.type === "ArrowFunctionExpression" || node.type === "FunctionExpression" || node.type === "FunctionDeclaration") {
61
- for (const param of node.params) for (const name of patternNames(param)) declare(name);
64
+ for (const param of node.params) for (const name of patternNames(param)) declare(name, param);
62
65
  }
63
66
  for (const child of children(node)) declaredIn(child, declare);
64
67
  };
@@ -26,7 +26,7 @@ const readOf = (declaration: ObjectExpression, name: string): readonly string[]
26
26
  describe("resolvedDeclaration", () => {
27
27
  test("takes a lookup by entity whose rows the build didn't follow as reading all it requires", () => {
28
28
  const declaration = declarationOf("{ lookup: { targets: { with: [mass, position] } } }");
29
- const resolved = resolvedDeclaration(declaration, { query: null, resources: null, lookups: new Map([["targets", null]]), up: null });
29
+ const resolved = resolvedDeclaration(declaration, { query: null, resources: null, lookups: new Map([["targets", null]]), up: null, commands: null });
30
30
  expect(readOf(resolved, "targets")).toEqual(["mass", "position"]);
31
31
  });
32
32
  });
@@ -77,7 +77,7 @@ const list = (names: Iterable<string>): string => `[${[...names].join(", ")}]`;
77
77
  * module-scope bindings the body indexes by, null for a group it couldn't follow, and `at` where the body is.
78
78
  */
79
79
  export const accessText = (used: Used, at: string, where?: ReadonlySet<string> | null): string => {
80
- const { query, resources, lookups, up } = used;
80
+ const { query, resources, lookups, up, commands } = used;
81
81
  // Keys changed inside their values are listed only when there are some, for registration to judge by kind.
82
82
  const mutate = (keys: ReadonlySet<string>): string => keys.size === 0 ? "" : `, mutate: ${list(keys)}`;
83
83
  const components = query === null ? "null" : `{ read: ${list([...query.read].filter(key => !query.write.has(key)))}, write: ${list(query.write)}${mutate(query.mutate)} }`;
@@ -85,7 +85,10 @@ export const accessText = (used: Used, at: string, where?: ReadonlySet<string> |
85
85
  const lookupText = lookups === null ? "null" : `{ ${[...lookups].map(([name, read]) => `${JSON.stringify(name)}: ${read === null ? "null" : list(read)}`).join(", ")} }`;
86
86
  // The resources `where`'s predicates read, only with predicates.
87
87
  const whereText = where === undefined ? "" : `, where: ${where === null ? "null" : list(where)}`;
88
- return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}${whereText}, at: ${JSON.stringify(at)} }`;
88
+ // Lists of keys stay as their bindings name them, so a list imported from another module is read where it's made,
89
+ // and in a function, read at first need, so a list declared below the system is read once its module has loaded.
90
+ const commandText = commands === null ? "null" : `() => ({ spawn: ${list(commands.spawn)}, add: ${list(commands.add)}, remove: ${list(commands.remove)}, despawn: ${commands.despawn} })`;
91
+ return `{ components: ${components}, resources: ${resourceText}, lookup: ${lookupText}, up: ${up === null ? "null" : list(up)}${whereText}, commands: ${commandText}, at: ${JSON.stringify(at)} }`;
89
92
  };
90
93
 
91
94
  /** A declaration's parts the build makes, located at `at`, so a failure in them points at the declaration. */
@@ -924,10 +924,10 @@ describe("terminal bodies", () => {
924
924
  return code.includes("terminal$((") && code.includes("function terminal$(body)");
925
925
  };
926
926
  test.each([
927
- ["no context", "(row)", "console.log(row);", `{ components: { with: [mass] } }`],
927
+ ["no context", "(row)", "console.log(row);", `{ components: { with: [mass], whole: true } }`],
928
928
  ["a context destructured without commands", "({ resources })", "console.log(resources);", `{}`],
929
929
  ["a context used only through its other properties", "(context)", "console.log(context.resources);", `{ resources: {} }`],
930
- ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { with: [mass] } }`],
930
+ ["a context reaching resources and lookups", "(row, context)", "console.log(context.resources, context.lookup, row);", `{ components: { with: [mass], whole: true } }`],
931
931
  ["nested destructuring", "({ resources: { [delta]: d } })", "console.log(d);", `{ resources: {} }`],
932
932
  ])("mark a body with %s", async (_, parameters, body, declaration) => {
933
933
  expect(await marked(parameters, body, declaration)).toBe(true);
@@ -1412,34 +1412,54 @@ describe("inferred access", () => {
1412
1412
  .toContain("components: { read: [position], write: [] }");
1413
1413
  });
1414
1414
 
1415
- test("a row it can't follow as unknown, which the world takes as every member written, with a warning", async () => {
1416
- const result = await transform(system("components: { with: [position] }", "(row) => { helper(row); }"));
1417
- expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings.length] : null).toEqual([true, 1]);
1415
+ test("fails at the use of a system's row it can't follow, saying why, unless its declaration takes the row whole", async () => {
1416
+ const failure = async (source: string) => {
1417
+ const result = await transform(source);
1418
+ return result !== null && "failure" in result ? result.failure : null;
1419
+ };
1420
+ const passed = await failure(system("components: { with: [position] }", "(row) => { helper(row); }"));
1421
+ expect(passed?.message).toMatch(/^the build can't follow how this body uses its row, since it passes its row on, aliases or spreads it, so it would take every component in with, added and changed as written: .*components: \{ whole: true \}/);
1422
+ expect(passed?.position).toBe(header.length + "const system = System.create({ components: { with: [position] } }, (row) => { ".length);
1423
+ const shadowed = await failure(system("components: { with: [position] }", "(row, { commands }) => { for (const row of list) commands.despawn(row); }"));
1424
+ expect(shadowed?.message).toContain("it declares another `row` here, which hides its row: rename one");
1425
+ expect(shadowed?.position).toBe(header.length + "const system = System.create({ components: { with: [position] } }, (row, { commands }) => { for (const ".length);
1426
+ expect((await failure(system("components: { with: [position] }", "(row) => { console.log(row.size); }")))?.message).toContain("it reads a member of its row by name");
1427
+ expect((await failure(system("components: { with: [position] }", "(row) => { delete row[position]; }")))?.message).toContain("it deletes a member of its row");
1428
+ for (const readonly of ["readonly: false, ", "readonly: watching, "]) {
1429
+ expect(await failure(`const watching = true;\n${system(`${readonly}components: { with: [position] }`, "(row) => { helper(row); }")}`)).not.toBe(null);
1430
+ }
1431
+ const whole = await transform(system("components: { with: [position], whole: true }", "(row) => { helper(row); }"));
1432
+ expect(whole !== null && "code" in whole ? [whole.code.includes("components: null"), whole.warnings] : null).toEqual([true, []]);
1433
+ // Taken whole on purpose, a row the build could follow is still taken whole.
1434
+ expect(await inferred(system("components: { with: [position], whole: true }", "(row) => { console.log(row[position]); }"))).toContain("components: null");
1435
+ });
1436
+
1437
+ test("commands by kind, keys and lists as their bindings name them, however the context reaches them", async () => {
1438
+ const commanded = async (body: string, declaration = "components: { with: [position] }"): Promise<string> =>
1439
+ /commands: (null|\(\) => \(\{[^}]*\}\))/.exec(await inferred(`const Shapes = [mass];\nconst Listed = [velocity];\n${system(declaration, body)}`))?.[1] ?? "none";
1440
+ expect(await commanded("(row, { commands }) => { commands.spawn([mass, ...Shapes])({}); commands.add(row.entity, velocity, 1); commands.remove(row.entity, [mass]); }"))
1441
+ .toBe("() => ({ spawn: [mass, Shapes], add: [velocity], remove: [mass], despawn: false })");
1442
+ expect(await commanded("(row, context) => { const c = context.commands; c.despawn(row.entity); context.commands.add(row.entity, Listed, {}); }"))
1443
+ .toBe("() => ({ spawn: [], add: [Listed], remove: [], despawn: true })");
1444
+ expect(await commanded("(row, context) => { const { commands } = context; commands.despawnWhere({ with: [mass] }); }")).toContain("despawn: true");
1445
+ expect(await commanded("(row) => { row[position] = 1; }")).toBe("() => ({ spawn: [], add: [], remove: [], despawn: false })");
1446
+ for (const body of ["(row, { commands }) => { helper(commands); }", "(row, { commands }) => { commands.add(row.entity, keyOf(1), 1); }",
1447
+ "(row, { commands: { spawn } }) => { spawn([mass]); }", "(row, context) => { helper(context); }",
1448
+ "(row, { commands }) => { commands.spawnTables(tables); }"]) {
1449
+ expect(await commanded(body)).toBe("null");
1450
+ }
1418
1451
  });
1419
1452
 
1420
- test("an observer's row it can't follow as unknown without a warning, since the world takes an observer's as read", async () => {
1453
+ test("a readonly system's row it can't follow as unknown without a failure, since the world takes a readonly system's as read", async () => {
1421
1454
  const result = await transform(system("readonly: true, components: { with: [position] }", "(row) => { helper(row); }"));
1422
1455
  expect(result !== null && "code" in result ? [result.code.includes("components: null"), result.warnings] : null).toEqual([true, []]);
1423
1456
  });
1424
1457
 
1425
- test("a row it can't follow with a warning unless the declaration says readonly: true, as the world reads it", async () => {
1426
- for (const observer of ["observer: false, ", "observer: watching, "]) {
1427
- const result = await transform(`const watching = true;\n${system(`${observer}components: { with: [position] }`, "(row) => { helper(row); }")}`);
1428
- expect(result !== null && "code" in result ? result.warnings.length : null).toBe(1);
1429
- }
1430
- });
1431
-
1432
- test("a lookup's rows it can't follow without a warning, since what they hold is what the declaration says", async () => {
1458
+ test("a lookup's rows it can't follow without a failure, since what they hold is what the declaration says", async () => {
1433
1459
  const result = await transform(system("components: { with: [target] }, lookup: { targets: { with: [mass] } }", "(row, { lookup }) => { helper(lookup.targets(row[target])); }"));
1434
1460
  expect(result !== null && "code" in result ? [result.code.includes(`"targets": null`), result.warnings] : null).toEqual([true, []]);
1435
1461
  });
1436
1462
 
1437
- test("warnings name the module by the label given, with line and column", async () => {
1438
- const result = await transformKernels(`${header}${system("components: { with: [position] }", "(row) => { helper(row); }")}`, "/repo/app/src/features/a/model.ts", parse,
1439
- async specifier => specifier === "ecs" ? ownCopy : null, () => "", createLinker(host({}, reductions), parse), true, "src/features/a/model.ts");
1440
- expect(result !== null && "code" in result ? result.warnings.map(warning => /^[^ ]*:/.exec(warning)?.[0]) : null).toEqual([`src/features/a/model.ts:${header.split("\n").length}:${"const system = System.create({ components: { with: [position] } }, ".length + 1}:`]);
1441
- });
1442
-
1443
1463
  test("resources read, written and reduced, however its context reaches them", async () => {
1444
1464
  expect(await inferred(system("", "(context) => { context.resources[score] = context.resources[delta]; }")))
1445
1465
  .toContain("resources: { read: [delta], write: [score], reduce: [] }");
@@ -1519,7 +1539,7 @@ describe("inferred access", () => {
1519
1539
  });
1520
1540
 
1521
1541
  test("a body mentioning eval as one it can't follow", async () => {
1522
- expect(await inferred(system("components: { with: [position] }", "(row) => { eval(\"row[position] = 1\"); }"))).toContain("components: null");
1542
+ expect(await inferred(system("components: { with: [position] }", "(row) => { eval(\"row[position] = 1\"); }"))).toContain("since it calls `eval`");
1523
1543
  });
1524
1544
 
1525
1545
  test("fail the build for an async or generator body", async () => {
@@ -313,9 +313,11 @@ export const transformKernels = async (
313
313
  const used = kernel?.used ?? inOrder(usedBy(declared, body, module.bindings, positions), declared);
314
314
  requireRequired(used, declared, body);
315
315
  const where = at(body);
316
- // A readonly system's row, and a lookup's rows, it can't follow are taken as read whole, which is what their
317
- // declarations already say; only a row taken as written whole costs what the author should know of.
318
- if (used.query === null && !declaresTrue(declared, "readonly")) warnings.push(`${where}: the build can't follow how this body uses its row, so it takes every component in with, added and changed as written: index the row by key directly, or destructure it by key, to let it tell what the body reads and writes.`);
316
+ // A readonly system's row, a finalizer's, and a lookup's rows it can't follow are taken as read whole, which is
317
+ // what their declarations already say; a system's row taken as written whole would change its schedule unseen.
318
+ if (created === "system" && used.rowLost !== undefined && !declaresTrue(declared, "readonly")) {
319
+ fail(used.rowLost.at, `the build can't follow how this body uses its row, since ${used.rowLost.why}, so it would take every component in with, added and changed as written: index the row by key directly, or destructure it by key; or declare \`components: { whole: true }\` for a body that uses its row whole`);
320
+ }
319
321
  accessing = true;
320
322
  const access = accessText(used, where, whereReads.has(node) ? whereReads.get(node) ?? null : undefined);
321
323
  if (kernel !== undefined) {
@@ -424,11 +424,15 @@ export const createConverter = (host: TypeHost, root: string) => {
424
424
  case "TSBooleanKeyword": return { type: "boolean" };
425
425
  case "TSNullKeyword": return { type: "null" };
426
426
  case "TSUnknownKeyword": return notData(use, false);
427
+ // A symbol can't be cloned to another thread or saved.
428
+ case "TSSymbolKeyword": return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`);
427
429
  case "TSFunctionType": case "TSConstructorType":
428
430
  return use === "declaration" ? notData(use, false) : fail(module, type, `${a} is ${supported}`);
429
431
  case "TSParenthesizedType": return await convert(type.typeAnnotation, module, active, use);
430
432
  case "TSTypeOperator":
431
- return type.operator === "readonly" ? await convert(type.typeAnnotation, module, active, use) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`);
433
+ if (type.operator === "readonly") return await convert(type.typeAnnotation, module, active, use);
434
+ // Keys, as `Resource.Key` and `unique symbol` are, may be symbols.
435
+ return use === "declaration" ? notData(use, false) : fail(module, type, `\`${type.operator}\` isn't ${a}: ${a} is ${supported}`);
432
436
  case "TSLiteralType": {
433
437
  const { literal } = type;
434
438
  if (literal.type === "Literal" && (typeof literal.value === "string" || typeof literal.value === "number" || typeof literal.value === "boolean")) return { const: literal.value };
@@ -677,7 +681,29 @@ export const createConverter = (host: TypeHost, root: string) => {
677
681
  return { anyOf: values.map(value => ({ const: value })) };
678
682
  };
679
683
 
680
- return { convert, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType };
684
+ /**
685
+ * `"Map"` or `"Set"` for a declared type that is a `ReadonlyMap` or `ReadonlySet`, read through aliases, which starts
686
+ * empty in each world whatever it holds; null for any other.
687
+ */
688
+ const freshOf = async (type: TypeNode, module: Module, seen: Set<Declared> = new Set()): Promise<"Map" | "Set" | null> => {
689
+ if (type.type === "TSParenthesizedType") return await freshOf(type.typeAnnotation, module, seen);
690
+ if (type.type === "TSTypeOperator" && type.operator === "readonly") return await freshOf(type.typeAnnotation, module, seen);
691
+ if (type.type !== "TSTypeReference") return null;
692
+ const path = nameParts(type.typeName);
693
+ const [only] = path;
694
+ const count = type.typeArguments?.params.length ?? 0;
695
+ if (path.length === 1 && only !== undefined && !module.types.has(only) && !module.imports.has(only)) {
696
+ if (only === "ReadonlyMap" && count === 2) return "Map";
697
+ if (only === "ReadonlySet" && count === 1) return "Set";
698
+ }
699
+ if (count !== 0) return null;
700
+ const found = await typeNamed(path, module);
701
+ if (found === null || typeof found === "string" || found.declaration.type !== "TSTypeAliasDeclaration" || seen.has(found.declaration)) return null;
702
+ seen.add(found.declaration);
703
+ return await freshOf(found.declaration.typeAnnotation, found.module, seen);
704
+ };
705
+
706
+ return { convert, freshOf, read, site, fail, typeNamed, constantNamed, returnTypeNamed, valueOfType };
681
707
  };
682
708
 
683
709
  /** A name and the properties read from it, `Entity.none` as `["Entity", "none"]`; null for any other expression. */
@@ -2,14 +2,15 @@
2
2
  import type { Finalizer } from '../../interface/finalizer/created.js';
3
3
  import type { Rule } from '../../interface/rule/created.js';
4
4
  import type { RuleFacts } from '../../interface/registry/strata.js';
5
- import { createFinalizerRunner, placeFinalizer, type FinalizerRunner } from './finalizers.js';
5
+ import { createFinalizerRunner, followResources, placeFinalizer, watchedResourcesOf, type FinalizerRunner } from './finalizers.js';
6
+ import { watchResource } from './resource-changes.js';
6
7
  import { finalizerKeysOf, type TaskChannel } from './remote-systems.js';
7
8
  import { programFinalizers, programRules, programVersion } from '../../interface/registry/program.js';
8
9
  import { triggersOf } from './program.js';
9
10
  import { ensureResources, findResource } from './resources.js';
10
11
  import { idOf, metIdOf, type Key, type State } from './state.js';
11
12
  import { restratify } from './structure.js';
12
- import { idOfItem, idOfKey, labelOf, threadOf } from '../../interface/registry/index.js';
13
+ import { idOfItem, idOfKey, keysVersion, labelOf, threadOf } from '../../interface/registry/index.js';
13
14
 
14
15
  /**
15
16
  * Why a joined thread that imported the items of ids `imported` can't claim `threads`: a program finalizer on one that
@@ -67,14 +68,34 @@ export const placeFinalizers = (state: State): void => {
67
68
  state.finalizers.splice(0, state.finalizers.length, ...kept);
68
69
  };
69
70
 
70
- const resourcesOf = (finalizer: Finalizer): readonly Key[] => [...finalizer.declaration.resources?.read ?? [], ...finalizer.declaration.resources?.write ?? []];
71
+ /** The resources a finalizer reads, writes or, for a changes finalizer, watches. */
72
+ const resourcesOf = (finalizer: Finalizer): readonly Key[] => [
73
+ ...finalizer.declaration.resources?.read ?? [], ...finalizer.declaration.resources?.write ?? [],
74
+ ...finalizer.kind === "changes" ? watchedResourcesOf(finalizer.declaration.watch) : [],
75
+ ];
76
+
77
+ /**
78
+ * What a world ensured of the program's resources: the program's version then, and the keys' while a changes finalizer
79
+ * watches resources by a trait, which a resource registering later may declare.
80
+ */
81
+ export type Ensured = { version: number; keys: number; traits: boolean };
82
+
83
+ /** Whether the program's finalizers may use a resource the world hasn't ensured. */
84
+ export const programResourcesChanged = (ensured: Ensured): boolean => ensured.version !== programVersion() || (ensured.traits && ensured.keys !== keysVersion());
71
85
 
72
86
  /** Between runs: adds the resources of every finalizer in the program, so one can activate at any sync point. */
73
- export const ensureProgramResources = (state: State, ensured: { version: number }): void => {
74
- const version = programVersion();
75
- if (ensured.version === version) return;
76
- ensured.version = version;
87
+ export const ensureProgramResources = (state: State, ensured: Ensured): void => {
88
+ if (!programResourcesChanged(ensured)) return;
89
+ ensured.version = programVersion();
90
+ ensured.keys = keysVersion();
91
+ ensured.traits = programFinalizers().some(finalizer => finalizer.kind === "changes" && finalizer.declaration.watch.resourceTraits.length > 0);
77
92
  ensureResources(state, new Set(programFinalizers().flatMap(resourcesOf)));
93
+ // A watched resource is followed from now, before its finalizer activates, so writes before then are seen.
94
+ for (const finalizer of programFinalizers()) {
95
+ if (finalizer.kind === "changes") for (const key of watchedResourcesOf(finalizer.declaration.watch)) watchResource(state, idOf(state, key));
96
+ }
97
+ // Active finalizers watching resources by a trait follow those registered since.
98
+ for (const runner of state.finalizers) if (runner.finalizer.kind === "changes") followResources(state, runner.finalizer.declaration.watch, runner.resourceWatches);
78
99
  // Finalizers waiting for these can activate at the next sync point.
79
100
  state.activation.met = true;
80
101
  };
@@ -90,11 +111,12 @@ const ready = (state: State, item: Rule | Finalizer): boolean => {
90
111
  * resolves its components' ids, which may make more ready.
91
112
  */
92
113
  export const activate = (state: State): void => {
93
- const { activation } = state;
94
- if (!activation.met && activation.version === programVersion()) return;
95
- activateNow(state);
114
+ if (activationPending(state)) activateNow(state);
96
115
  };
97
116
 
117
+ /** Whether a rule or finalizer of the program may be ready to activate: a component met, or the program changed. */
118
+ export const activationPending = (state: State): boolean => state.activation.met || state.activation.version !== programVersion();
119
+
98
120
  /** `activate` once something may be ready: apart, so a sync point with nothing to activate allocates no closure context. */
99
121
  const activateNow = (state: State): void => {
100
122
  const { activation, activated, structure } = state;
@@ -29,7 +29,7 @@ declare module "@adobe/ecs" {
29
29
  }
30
30
 
31
31
  describe("staged spawns", () => {
32
- it("are seen once by `added` observers in earlier, the same and later tiers, and not before the sync point", async () => {
32
+ it("are seen once by `added` observers in earlier, the same and later tiers, from the sync point after them", async () => {
33
33
  const seen = { early: 0, same: 0, late: 0 };
34
34
  const spawned: Entity[] = [];
35
35
  const early = System.create({ components: { added: [score] } }, () => { seen.early++; });
@@ -42,8 +42,8 @@ describe("staged spawns", () => {
42
42
  const frame = System.create({ systems: [early, spawn, same, late] });
43
43
  const world = createWorld({ [workers]: 0 });
44
44
  await world.run(frame);
45
- // Nothing saw the spawns during their own run.
46
- expect(seen).toEqual({ early: 0, same: 0, late: 0 });
45
+ // Only what follows the spawn saw them in their own run, after the sync point it needs.
46
+ expect(seen).toEqual({ early: 0, same: 0, late: 100 });
47
47
  await world.run(frame);
48
48
  await world.run(frame);
49
49
  expect(seen).toEqual({ early: 100, same: 100, late: 100 });
@@ -7,6 +7,13 @@ export const spawnCommand = 0;
7
7
  export const despawnCommand = 1;
8
8
  export const addCommand = 2;
9
9
  export const removeCommand = 3;
10
+ /**
11
+ * Names the ids of its query's `with`, then of its `not`, and with a `where`, last, `despawnWhereHolds`, whose value is
12
+ * whether an archetype holds the `where`'s values; its entity is how many are of `with`.
13
+ */
14
+ export const despawnWhereCommand = 4;
15
+ /** The id a `despawnWhere` names its `where` by, which no component has. */
16
+ export const despawnWhereHolds = -1;
10
17
 
11
18
  /** Rows `start` to `end` of a kernel's spawn batch, committed in its buffer's place in merge order. */
12
19
  export type Staged = { batch: SpawnBatch; start: number; end: number };