@oasys/oecs 0.5.3 → 0.6.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 (510) hide show
  1. package/CHANGELOG.md +1399 -276
  2. package/README.md +327 -169
  3. package/dist/core/ecs/access_check.d.cts +31 -31
  4. package/dist/core/ecs/access_check.d.ts +31 -31
  5. package/dist/core/ecs/access_check.d.ts.map +1 -1
  6. package/dist/core/ecs/archetype.d.cts +273 -205
  7. package/dist/core/ecs/archetype.d.ts +273 -205
  8. package/dist/core/ecs/archetype.d.ts.map +1 -1
  9. package/dist/core/ecs/archetype_closure.d.cts +23 -0
  10. package/dist/core/ecs/archetype_closure.d.ts +23 -0
  11. package/dist/core/ecs/archetype_closure.d.ts.map +1 -0
  12. package/dist/core/ecs/archetype_graph.d.cts +34 -34
  13. package/dist/core/ecs/archetype_graph.d.ts +34 -34
  14. package/dist/core/ecs/archetype_graph.d.ts.map +1 -1
  15. package/dist/core/ecs/archetype_types.d.cts +55 -0
  16. package/dist/core/ecs/archetype_types.d.ts +55 -0
  17. package/dist/core/ecs/archetype_types.d.ts.map +1 -0
  18. package/dist/core/ecs/change_feed.d.cts +53 -0
  19. package/dist/core/ecs/change_feed.d.ts +53 -0
  20. package/dist/core/ecs/change_feed.d.ts.map +1 -0
  21. package/dist/core/ecs/changed_query.d.cts +36 -0
  22. package/dist/core/ecs/changed_query.d.ts +36 -0
  23. package/dist/core/ecs/changed_query.d.ts.map +1 -0
  24. package/dist/core/ecs/chunk_columns.d.cts +49 -0
  25. package/dist/core/ecs/chunk_columns.d.ts +49 -0
  26. package/dist/core/ecs/chunk_columns.d.ts.map +1 -0
  27. package/dist/core/ecs/command_log.d.cts +30 -30
  28. package/dist/core/ecs/command_log.d.ts +30 -30
  29. package/dist/core/ecs/command_log.d.ts.map +1 -1
  30. package/dist/core/ecs/component.d.cts +45 -44
  31. package/dist/core/ecs/component.d.ts +45 -44
  32. package/dist/core/ecs/component.d.ts.map +1 -1
  33. package/dist/core/ecs/component_registry.d.cts +53 -0
  34. package/dist/core/ecs/component_registry.d.ts +53 -0
  35. package/dist/core/ecs/component_registry.d.ts.map +1 -0
  36. package/dist/core/ecs/compute_backend.d.cts +18 -7
  37. package/dist/core/ecs/compute_backend.d.ts +18 -7
  38. package/dist/core/ecs/compute_backend.d.ts.map +1 -1
  39. package/dist/core/ecs/deferred_commands.d.cts +15 -14
  40. package/dist/core/ecs/deferred_commands.d.ts +15 -14
  41. package/dist/core/ecs/deferred_commands.d.ts.map +1 -1
  42. package/dist/core/ecs/dispatch_trace.d.cts +32 -31
  43. package/dist/core/ecs/dispatch_trace.d.ts +32 -31
  44. package/dist/core/ecs/dispatch_trace.d.ts.map +1 -1
  45. package/dist/core/ecs/ecs.d.cts +337 -276
  46. package/dist/core/ecs/ecs.d.ts +337 -276
  47. package/dist/core/ecs/ecs.d.ts.map +1 -1
  48. package/dist/core/ecs/ecs_memory.d.cts +134 -136
  49. package/dist/core/ecs/ecs_memory.d.ts +134 -136
  50. package/dist/core/ecs/ecs_memory.d.ts.map +1 -1
  51. package/dist/core/ecs/ecs_options.d.cts +89 -0
  52. package/dist/core/ecs/ecs_options.d.ts +89 -0
  53. package/dist/core/ecs/ecs_options.d.ts.map +1 -0
  54. package/dist/core/ecs/entity.d.cts +4 -4
  55. package/dist/core/ecs/entity.d.ts +4 -4
  56. package/dist/core/ecs/entity_allocator.d.cts +24 -22
  57. package/dist/core/ecs/entity_allocator.d.ts +24 -22
  58. package/dist/core/ecs/entity_allocator.d.ts.map +1 -1
  59. package/dist/core/ecs/event.d.cts +37 -23
  60. package/dist/core/ecs/event.d.ts +37 -23
  61. package/dist/core/ecs/event.d.ts.map +1 -1
  62. package/dist/core/ecs/facades.d.cts +12 -126
  63. package/dist/core/ecs/facades.d.ts +12 -126
  64. package/dist/core/ecs/facades.d.ts.map +1 -1
  65. package/dist/core/ecs/frame_stepper.d.cts +12 -12
  66. package/dist/core/ecs/frame_stepper.d.ts +12 -12
  67. package/dist/core/ecs/frame_stepper.d.ts.map +1 -1
  68. package/dist/core/ecs/frame_trace.d.cts +34 -34
  69. package/dist/core/ecs/frame_trace.d.ts +34 -34
  70. package/dist/core/ecs/frame_trace.d.ts.map +1 -1
  71. package/dist/core/ecs/host_commands.d.cts +107 -89
  72. package/dist/core/ecs/host_commands.d.ts +107 -89
  73. package/dist/core/ecs/host_commands.d.ts.map +1 -1
  74. package/dist/core/ecs/index.d.cts +22 -12
  75. package/dist/core/ecs/index.d.ts +22 -12
  76. package/dist/core/ecs/index.d.ts.map +1 -1
  77. package/dist/core/ecs/observer.d.cts +41 -122
  78. package/dist/core/ecs/observer.d.ts +41 -122
  79. package/dist/core/ecs/observer.d.ts.map +1 -1
  80. package/dist/core/ecs/phase.d.cts +79 -0
  81. package/dist/core/ecs/phase.d.ts +79 -0
  82. package/dist/core/ecs/phase.d.ts.map +1 -0
  83. package/dist/core/ecs/plugin.d.cts +125 -0
  84. package/dist/core/ecs/plugin.d.ts +125 -0
  85. package/dist/core/ecs/plugin.d.ts.map +1 -0
  86. package/dist/core/ecs/plugin_slots.d.cts +26 -0
  87. package/dist/core/ecs/plugin_slots.d.ts +26 -0
  88. package/dist/core/ecs/plugin_slots.d.ts.map +1 -0
  89. package/dist/core/ecs/query.d.cts +219 -568
  90. package/dist/core/ecs/query.d.ts +219 -568
  91. package/dist/core/ecs/query.d.ts.map +1 -1
  92. package/dist/core/ecs/query_cache.d.cts +130 -0
  93. package/dist/core/ecs/query_cache.d.ts +130 -0
  94. package/dist/core/ecs/query_cache.d.ts.map +1 -0
  95. package/dist/core/ecs/query_registry.d.cts +47 -0
  96. package/dist/core/ecs/query_registry.d.ts +47 -0
  97. package/dist/core/ecs/query_registry.d.ts.map +1 -0
  98. package/dist/core/ecs/query_terms.d.cts +126 -0
  99. package/dist/core/ecs/query_terms.d.ts +126 -0
  100. package/dist/core/ecs/query_terms.d.ts.map +1 -0
  101. package/dist/core/ecs/ref.d.cts +129 -16
  102. package/dist/core/ecs/ref.d.ts +129 -16
  103. package/dist/core/ecs/ref.d.ts.map +1 -1
  104. package/dist/core/ecs/relation.d.cts +105 -186
  105. package/dist/core/ecs/relation.d.ts +105 -186
  106. package/dist/core/ecs/relation.d.ts.map +1 -1
  107. package/dist/core/ecs/resource.d.cts +14 -14
  108. package/dist/core/ecs/resource.d.ts +14 -14
  109. package/dist/core/ecs/resource_registry.d.cts +6 -6
  110. package/dist/core/ecs/resource_registry.d.ts +6 -6
  111. package/dist/core/ecs/resource_registry.d.ts.map +1 -1
  112. package/dist/core/ecs/row_kinds.d.cts +24 -0
  113. package/dist/core/ecs/row_kinds.d.ts +24 -0
  114. package/dist/core/ecs/row_kinds.d.ts.map +1 -0
  115. package/dist/core/ecs/run_condition.d.cts +23 -23
  116. package/dist/core/ecs/run_condition.d.ts +23 -23
  117. package/dist/core/ecs/run_condition.d.ts.map +1 -1
  118. package/dist/core/ecs/schedule.d.cts +98 -114
  119. package/dist/core/ecs/schedule.d.ts +98 -114
  120. package/dist/core/ecs/schedule.d.ts.map +1 -1
  121. package/dist/core/ecs/schedule_plan.d.cts +33 -0
  122. package/dist/core/ecs/schedule_plan.d.ts +33 -0
  123. package/dist/core/ecs/schedule_plan.d.ts.map +1 -0
  124. package/dist/core/ecs/snapshot.d.cts +77 -0
  125. package/dist/core/ecs/snapshot.d.ts +77 -0
  126. package/dist/core/ecs/snapshot.d.ts.map +1 -0
  127. package/dist/core/ecs/snapshot_mount.d.cts +42 -0
  128. package/dist/core/ecs/snapshot_mount.d.ts +42 -0
  129. package/dist/core/ecs/snapshot_mount.d.ts.map +1 -0
  130. package/dist/core/ecs/sparse_store.d.cts +110 -53
  131. package/dist/core/ecs/sparse_store.d.ts +110 -53
  132. package/dist/core/ecs/sparse_store.d.ts.map +1 -1
  133. package/dist/core/ecs/store.d.cts +644 -511
  134. package/dist/core/ecs/store.d.ts +644 -511
  135. package/dist/core/ecs/store.d.ts.map +1 -1
  136. package/dist/core/ecs/store_layout_listener.d.cts +8 -8
  137. package/dist/core/ecs/store_layout_listener.d.ts +8 -8
  138. package/dist/core/ecs/store_types.d.cts +122 -0
  139. package/dist/core/ecs/store_types.d.ts +122 -0
  140. package/dist/core/ecs/store_types.d.ts.map +1 -0
  141. package/dist/core/ecs/system.d.cts +151 -66
  142. package/dist/core/ecs/system.d.ts +151 -66
  143. package/dist/core/ecs/system.d.ts.map +1 -1
  144. package/dist/core/ecs/system_context.d.cts +296 -0
  145. package/dist/core/ecs/system_context.d.ts +296 -0
  146. package/dist/core/ecs/system_context.d.ts.map +1 -0
  147. package/dist/core/ecs/system_set.d.cts +48 -0
  148. package/dist/core/ecs/system_set.d.ts +48 -0
  149. package/dist/core/ecs/system_set.d.ts.map +1 -0
  150. package/dist/core/ecs/utils/arrays.d.cts +15 -1
  151. package/dist/core/ecs/utils/arrays.d.ts +15 -1
  152. package/dist/core/ecs/utils/arrays.d.ts.map +1 -1
  153. package/dist/core/ecs/utils/error.d.cts +87 -4
  154. package/dist/core/ecs/utils/error.d.ts +87 -4
  155. package/dist/core/ecs/utils/error.d.ts.map +1 -1
  156. package/dist/core/ecs/utils/plugin_error.d.cts +10 -0
  157. package/dist/core/ecs/utils/plugin_error.d.ts +10 -0
  158. package/dist/core/ecs/utils/plugin_error.d.ts.map +1 -0
  159. package/dist/core/restore_errors.d.cts +30 -0
  160. package/dist/core/restore_errors.d.ts +30 -0
  161. package/dist/core/restore_errors.d.ts.map +1 -0
  162. package/dist/core/store/action_ring.d.cts +48 -49
  163. package/dist/core/store/action_ring.d.ts +48 -49
  164. package/dist/core/store/action_ring.d.ts.map +1 -1
  165. package/dist/core/store/allocator.d.cts +166 -114
  166. package/dist/core/store/allocator.d.ts +166 -114
  167. package/dist/core/store/allocator.d.ts.map +1 -1
  168. package/dist/core/store/buffer_backed_column.d.cts +21 -22
  169. package/dist/core/store/buffer_backed_column.d.ts +21 -22
  170. package/dist/core/store/buffer_backed_column.d.ts.map +1 -1
  171. package/dist/core/store/column_store.d.cts +138 -100
  172. package/dist/core/store/column_store.d.ts +138 -100
  173. package/dist/core/store/column_store.d.ts.map +1 -1
  174. package/dist/core/store/command_dispatch.d.cts +14 -15
  175. package/dist/core/store/command_dispatch.d.ts +14 -15
  176. package/dist/core/store/command_dispatch.d.ts.map +1 -1
  177. package/dist/core/store/command_ring.d.cts +41 -45
  178. package/dist/core/store/command_ring.d.ts +41 -45
  179. package/dist/core/store/command_ring.d.ts.map +1 -1
  180. package/dist/core/store/descriptor.d.cts +35 -18
  181. package/dist/core/store/descriptor.d.ts +35 -18
  182. package/dist/core/store/descriptor.d.ts.map +1 -1
  183. package/dist/core/store/entity_index.d.cts +34 -35
  184. package/dist/core/store/entity_index.d.ts +34 -35
  185. package/dist/core/store/entity_index.d.ts.map +1 -1
  186. package/dist/core/store/event_ring.d.cts +40 -42
  187. package/dist/core/store/event_ring.d.ts +40 -42
  188. package/dist/core/store/event_ring.d.ts.map +1 -1
  189. package/dist/core/store/extend.d.cts +15 -9
  190. package/dist/core/store/extend.d.ts +15 -9
  191. package/dist/core/store/extend.d.ts.map +1 -1
  192. package/dist/core/store/grow.d.cts +3 -3
  193. package/dist/core/store/grow.d.ts +3 -3
  194. package/dist/core/store/grow.d.ts.map +1 -1
  195. package/dist/core/store/header.d.cts +37 -33
  196. package/dist/core/store/header.d.ts +37 -33
  197. package/dist/core/store/header.d.ts.map +1 -1
  198. package/dist/core/store/index.d.cts +10 -10
  199. package/dist/core/store/index.d.ts +10 -10
  200. package/dist/core/store/index.d.ts.map +1 -1
  201. package/dist/core/store/layout_ops.d.cts +72 -63
  202. package/dist/core/store/layout_ops.d.ts +72 -63
  203. package/dist/core/store/layout_ops.d.ts.map +1 -1
  204. package/dist/core/store/region_table.d.cts +28 -21
  205. package/dist/core/store/region_table.d.ts +28 -21
  206. package/dist/core/store/region_table.d.ts.map +1 -1
  207. package/dist/core/store/snapshot.d.cts +31 -16
  208. package/dist/core/store/snapshot.d.ts +31 -16
  209. package/dist/core/store/snapshot.d.ts.map +1 -1
  210. package/dist/core/store/state_hash.d.cts +14 -14
  211. package/dist/core/store/state_hash.d.ts +14 -14
  212. package/dist/core/store/state_hash.d.ts.map +1 -1
  213. package/dist/core/store/store_regions.d.cts +10 -10
  214. package/dist/core/store/store_regions.d.ts +10 -10
  215. package/dist/core/store/store_regions.d.ts.map +1 -1
  216. package/dist/core/store/vendored_abi/abi.d.cts +27 -2
  217. package/dist/core/store/vendored_abi/abi.d.ts +27 -2
  218. package/dist/core/store/vendored_abi/abi.d.ts.map +1 -1
  219. package/dist/host_commands-B2KMx8O2.cjs +1 -0
  220. package/dist/host_commands-ClVPvhSa.cjs +2 -0
  221. package/dist/host_commands-CwxVQS1z.js +1524 -0
  222. package/dist/host_commands-nJvTy88E.js +1667 -0
  223. package/dist/index.cjs +1 -1
  224. package/dist/index.d.cts +16 -11
  225. package/dist/index.d.ts +16 -11
  226. package/dist/index.d.ts.map +1 -1
  227. package/dist/index.development.cjs +1 -1
  228. package/dist/index.development.js +6709 -5729
  229. package/dist/index.js +5664 -4743
  230. package/dist/internal.cjs +2 -2
  231. package/dist/internal.d.cts +5 -2
  232. package/dist/internal.d.ts +5 -2
  233. package/dist/internal.d.ts.map +1 -1
  234. package/dist/internal.development.cjs +1 -1
  235. package/dist/internal.development.js +70 -20
  236. package/dist/internal.js +138 -83
  237. package/dist/plugins/abi-D3V-QUSf.cjs +1 -0
  238. package/dist/plugins/abi-NP8pNxpd.js +42 -0
  239. package/dist/plugins/arrays-Dr_1hCjL.js +28 -0
  240. package/dist/plugins/arrays-Dt-Joc4i.cjs +1 -0
  241. package/dist/plugins/assertions-gtDVfbnQ.js +6 -0
  242. package/dist/plugins/assertions-z5GQd_bB.cjs +1 -0
  243. package/dist/{extensions → plugins}/editor/editor.d.cts +56 -56
  244. package/dist/{extensions → plugins}/editor/editor.d.ts +56 -56
  245. package/dist/plugins/editor/editor.d.ts.map +1 -0
  246. package/dist/{extensions → plugins}/editor/field_handle.d.cts +13 -13
  247. package/dist/{extensions → plugins}/editor/field_handle.d.ts +13 -13
  248. package/dist/plugins/editor/field_handle.d.ts.map +1 -0
  249. package/dist/plugins/editor/index.d.cts +22 -0
  250. package/dist/plugins/editor/index.d.ts +22 -0
  251. package/dist/plugins/editor/index.d.ts.map +1 -0
  252. package/dist/plugins/editor.cjs +1 -0
  253. package/dist/plugins/editor.d.cts +1 -0
  254. package/dist/plugins/editor.d.ts +1 -0
  255. package/dist/plugins/editor.development.cjs +1 -0
  256. package/dist/{extensions/editor/index.development.js → plugins/editor.development.js} +83 -66
  257. package/dist/{extensions/editor/index.js → plugins/editor.js} +83 -66
  258. package/dist/plugins/events/event_channel.d.cts +13 -0
  259. package/dist/plugins/events/event_channel.d.ts +13 -0
  260. package/dist/plugins/events/event_channel.d.ts.map +1 -0
  261. package/dist/plugins/events/event_registry.d.cts +22 -0
  262. package/dist/plugins/events/event_registry.d.ts +22 -0
  263. package/dist/plugins/events/event_registry.d.ts.map +1 -0
  264. package/dist/plugins/events/facade.d.cts +26 -0
  265. package/dist/plugins/events/facade.d.ts +26 -0
  266. package/dist/plugins/events/facade.d.ts.map +1 -0
  267. package/dist/plugins/events/index.d.cts +10 -0
  268. package/dist/plugins/events/index.d.ts +10 -0
  269. package/dist/plugins/events/index.d.ts.map +1 -0
  270. package/dist/plugins/events.cjs +1 -0
  271. package/dist/plugins/events.d.cts +1 -0
  272. package/dist/plugins/events.d.ts +1 -0
  273. package/dist/plugins/events.development.cjs +1 -0
  274. package/dist/plugins/events.development.js +174 -0
  275. package/dist/plugins/events.js +159 -0
  276. package/dist/plugins/observers/index.d.cts +23 -0
  277. package/dist/plugins/observers/index.d.ts +23 -0
  278. package/dist/plugins/observers/index.d.ts.map +1 -0
  279. package/dist/plugins/observers/observer_registry.d.cts +101 -0
  280. package/dist/plugins/observers/observer_registry.d.ts +101 -0
  281. package/dist/plugins/observers/observer_registry.d.ts.map +1 -0
  282. package/dist/plugins/observers.cjs +1 -0
  283. package/dist/plugins/observers.d.cts +1 -0
  284. package/dist/plugins/observers.d.ts +1 -0
  285. package/dist/plugins/observers.development.cjs +1 -0
  286. package/dist/plugins/observers.development.js +431 -0
  287. package/dist/plugins/observers.js +403 -0
  288. package/dist/plugins/relation_store-BsoegrZh.js +313 -0
  289. package/dist/plugins/relation_store-DQKSnxsm.cjs +1 -0
  290. package/dist/plugins/relation_store-RBhpcE28.cjs +1 -0
  291. package/dist/plugins/relation_store-RqW5Snx6.js +328 -0
  292. package/dist/plugins/relations/builtin_relations.d.cts +48 -0
  293. package/dist/plugins/relations/builtin_relations.d.ts +48 -0
  294. package/dist/plugins/relations/builtin_relations.d.ts.map +1 -0
  295. package/dist/plugins/relations/facade.d.cts +73 -0
  296. package/dist/plugins/relations/facade.d.ts +73 -0
  297. package/dist/plugins/relations/facade.d.ts.map +1 -0
  298. package/dist/plugins/relations/index.d.cts +11 -0
  299. package/dist/plugins/relations/index.d.ts +11 -0
  300. package/dist/plugins/relations/index.d.ts.map +1 -0
  301. package/dist/{core/ecs → plugins/relations}/relation_service.d.cts +93 -105
  302. package/dist/{core/ecs → plugins/relations}/relation_service.d.ts +93 -105
  303. package/dist/plugins/relations/relation_service.d.ts.map +1 -0
  304. package/dist/plugins/relations/relation_store.d.cts +177 -0
  305. package/dist/plugins/relations/relation_store.d.ts +177 -0
  306. package/dist/plugins/relations/relation_store.d.ts.map +1 -0
  307. package/dist/plugins/relations.cjs +1 -0
  308. package/dist/plugins/relations.d.cts +1 -0
  309. package/dist/plugins/relations.d.ts +1 -0
  310. package/dist/plugins/relations.development.cjs +1 -0
  311. package/dist/plugins/relations.development.js +603 -0
  312. package/dist/plugins/relations.js +553 -0
  313. package/dist/plugins/snapshots/index.d.cts +33 -0
  314. package/dist/plugins/snapshots/index.d.ts +33 -0
  315. package/dist/plugins/snapshots/index.d.ts.map +1 -0
  316. package/dist/plugins/snapshots/resume.d.cts +57 -0
  317. package/dist/plugins/snapshots/resume.d.ts +57 -0
  318. package/dist/plugins/snapshots/resume.d.ts.map +1 -0
  319. package/dist/plugins/snapshots/snapshot_service.d.cts +59 -0
  320. package/dist/plugins/snapshots/snapshot_service.d.ts +59 -0
  321. package/dist/plugins/snapshots/snapshot_service.d.ts.map +1 -0
  322. package/dist/plugins/snapshots.cjs +1 -0
  323. package/dist/plugins/snapshots.d.cts +1 -0
  324. package/dist/plugins/snapshots.d.ts +1 -0
  325. package/dist/plugins/snapshots.development.cjs +1 -0
  326. package/dist/plugins/snapshots.development.js +737 -0
  327. package/dist/plugins/snapshots.js +737 -0
  328. package/dist/plugins/solid/index.d.cts +8 -0
  329. package/dist/plugins/solid/index.d.ts +8 -0
  330. package/dist/plugins/solid/index.d.ts.map +1 -0
  331. package/dist/plugins/solid/solid.d.cts +97 -0
  332. package/dist/plugins/solid/solid.d.ts +97 -0
  333. package/dist/plugins/solid/solid.d.ts.map +1 -0
  334. package/dist/plugins/solid.cjs +1 -0
  335. package/dist/plugins/solid.d.cts +1 -0
  336. package/dist/plugins/solid.d.ts +1 -0
  337. package/dist/plugins/solid.development.cjs +1 -0
  338. package/dist/plugins/solid.development.js +319 -0
  339. package/dist/plugins/solid.js +319 -0
  340. package/dist/plugins/workers/index.d.cts +75 -0
  341. package/dist/plugins/workers/index.d.ts +75 -0
  342. package/dist/plugins/workers/index.d.ts.map +1 -0
  343. package/dist/plugins/workers/node_threads.d.cts +25 -0
  344. package/dist/plugins/workers/node_threads.d.ts +25 -0
  345. package/dist/plugins/workers/node_threads.d.ts.map +1 -0
  346. package/dist/plugins/workers/plan.d.cts +61 -0
  347. package/dist/plugins/workers/plan.d.ts +61 -0
  348. package/dist/plugins/workers/plan.d.ts.map +1 -0
  349. package/dist/plugins/workers/pool.d.cts +144 -0
  350. package/dist/plugins/workers/pool.d.ts +144 -0
  351. package/dist/plugins/workers/pool.d.ts.map +1 -0
  352. package/dist/plugins/workers/protocol.d.cts +112 -0
  353. package/dist/plugins/workers/protocol.d.ts +112 -0
  354. package/dist/plugins/workers/protocol.d.ts.map +1 -0
  355. package/dist/plugins/workers/worker_bind.d.cts +64 -0
  356. package/dist/plugins/workers/worker_bind.d.ts +64 -0
  357. package/dist/plugins/workers/worker_bind.d.ts.map +1 -0
  358. package/dist/plugins/workers/worker_loop.d.cts +14 -0
  359. package/dist/plugins/workers/worker_loop.d.ts +14 -0
  360. package/dist/plugins/workers/worker_loop.d.ts.map +1 -0
  361. package/dist/plugins/workers.cjs +1 -0
  362. package/dist/plugins/workers.d.cts +1 -0
  363. package/dist/plugins/workers.d.ts +1 -0
  364. package/dist/plugins/workers.development.cjs +1 -0
  365. package/dist/plugins/workers.development.js +596 -0
  366. package/dist/plugins/workers.js +474 -0
  367. package/dist/primitives.cjs +1 -1
  368. package/dist/primitives.d.cts +4 -4
  369. package/dist/primitives.d.ts +4 -4
  370. package/dist/primitives.development.cjs +1 -1
  371. package/dist/primitives.development.js +66 -22
  372. package/dist/primitives.js +66 -22
  373. package/dist/shared-BKl12xF1.js +132 -0
  374. package/dist/shared-Dk-pACZT.cjs +1 -0
  375. package/dist/shared.cjs +1 -1
  376. package/dist/shared.d.cts +7 -7
  377. package/dist/shared.d.ts +7 -7
  378. package/dist/shared.d.ts.map +1 -1
  379. package/dist/shared.development.cjs +1 -1
  380. package/dist/shared.development.js +4 -3
  381. package/dist/shared.js +4 -3
  382. package/dist/topological_sort-Cc_yXqPT.js +346 -0
  383. package/dist/topological_sort-vp1imU6D.cjs +1 -0
  384. package/dist/type_primitives/assertions.d.cts +12 -16
  385. package/dist/type_primitives/assertions.d.ts +12 -16
  386. package/dist/type_primitives/assertions.d.ts.map +1 -1
  387. package/dist/type_primitives/binary_heap/binary_heap.d.cts +2 -6
  388. package/dist/type_primitives/binary_heap/binary_heap.d.ts +2 -6
  389. package/dist/type_primitives/binary_heap/binary_heap.d.ts.map +1 -1
  390. package/dist/type_primitives/bitset/bitset.d.cts +7 -11
  391. package/dist/type_primitives/bitset/bitset.d.ts +7 -11
  392. package/dist/type_primitives/bitset/bitset.d.ts.map +1 -1
  393. package/dist/type_primitives/brand.d.cts +2 -7
  394. package/dist/type_primitives/brand.d.ts +2 -7
  395. package/dist/type_primitives/brand.d.ts.map +1 -1
  396. package/dist/type_primitives/error.d.cts +1 -2
  397. package/dist/type_primitives/error.d.ts +1 -2
  398. package/dist/type_primitives/error.d.ts.map +1 -1
  399. package/dist/type_primitives/index.d.cts +1 -1
  400. package/dist/type_primitives/index.d.ts +1 -1
  401. package/dist/type_primitives/sparse_map/sparse_map.d.cts +3 -7
  402. package/dist/type_primitives/sparse_map/sparse_map.d.ts +3 -7
  403. package/dist/type_primitives/sparse_map/sparse_map.d.ts.map +1 -1
  404. package/dist/type_primitives/sparse_set/sparse_set.d.cts +2 -6
  405. package/dist/type_primitives/sparse_set/sparse_set.d.ts +2 -6
  406. package/dist/type_primitives/sparse_set/sparse_set.d.ts.map +1 -1
  407. package/dist/type_primitives/topological_sort/topological_sort.d.cts +1 -5
  408. package/dist/type_primitives/topological_sort/topological_sort.d.ts +1 -5
  409. package/dist/type_primitives/topological_sort/topological_sort.d.ts.map +1 -1
  410. package/dist/type_primitives/typed_arrays/typed_arrays.d.cts +18 -17
  411. package/dist/type_primitives/typed_arrays/typed_arrays.d.ts +18 -17
  412. package/dist/type_primitives/typed_arrays/typed_arrays.d.ts.map +1 -1
  413. package/dist/utils/error.d.ts.map +1 -1
  414. package/dist/version.d.cts +4 -4
  415. package/dist/version.d.ts +4 -4
  416. package/dist/worker.cjs +1 -0
  417. package/dist/worker.d.cts +17 -0
  418. package/dist/worker.d.ts +17 -0
  419. package/dist/worker.d.ts.map +1 -0
  420. package/dist/worker.development.cjs +1 -0
  421. package/dist/worker.development.js +253 -0
  422. package/dist/worker.js +253 -0
  423. package/package.json +363 -191
  424. package/dist/core/ecs/builtin_relations.d.cts +0 -43
  425. package/dist/core/ecs/builtin_relations.d.ts +0 -43
  426. package/dist/core/ecs/builtin_relations.d.ts.map +0 -1
  427. package/dist/core/ecs/event_registry.d.cts +0 -22
  428. package/dist/core/ecs/event_registry.d.ts +0 -22
  429. package/dist/core/ecs/event_registry.d.ts.map +0 -1
  430. package/dist/core/ecs/relation_service.d.ts.map +0 -1
  431. package/dist/core/ecs/resume.d.cts +0 -85
  432. package/dist/core/ecs/resume.d.ts +0 -85
  433. package/dist/core/ecs/resume.d.ts.map +0 -1
  434. package/dist/core/ecs/snapshot_service.d.cts +0 -94
  435. package/dist/core/ecs/snapshot_service.d.ts +0 -94
  436. package/dist/core/ecs/snapshot_service.d.ts.map +0 -1
  437. package/dist/extensions/editor/editor.d.ts.map +0 -1
  438. package/dist/extensions/editor/field_handle.d.ts.map +0 -1
  439. package/dist/extensions/editor/index.cjs +0 -1
  440. package/dist/extensions/editor/index.d.cts +0 -21
  441. package/dist/extensions/editor/index.d.ts +0 -21
  442. package/dist/extensions/editor/index.d.ts.map +0 -1
  443. package/dist/extensions/editor/index.development.cjs +0 -1
  444. package/dist/extensions/reactive/ecs_sync.d.cts +0 -214
  445. package/dist/extensions/reactive/ecs_sync.d.ts +0 -214
  446. package/dist/extensions/reactive/ecs_sync.d.ts.map +0 -1
  447. package/dist/extensions/reactive/index.cjs +0 -1
  448. package/dist/extensions/reactive/index.d.cts +0 -23
  449. package/dist/extensions/reactive/index.d.ts +0 -23
  450. package/dist/extensions/reactive/index.d.ts.map +0 -1
  451. package/dist/extensions/reactive/index.development.cjs +0 -1
  452. package/dist/extensions/reactive/index.development.js +0 -215
  453. package/dist/extensions/reactive/index.js +0 -215
  454. package/dist/extensions/solid/index.cjs +0 -1
  455. package/dist/extensions/solid/index.d.cts +0 -6
  456. package/dist/extensions/solid/index.d.ts +0 -6
  457. package/dist/extensions/solid/index.d.ts.map +0 -1
  458. package/dist/extensions/solid/index.development.cjs +0 -1
  459. package/dist/extensions/solid/index.development.js +0 -40
  460. package/dist/extensions/solid/index.js +0 -36
  461. package/dist/extensions/solid/kernel_solid.d.cts +0 -45
  462. package/dist/extensions/solid/kernel_solid.d.ts +0 -45
  463. package/dist/extensions/solid/kernel_solid.d.ts.map +0 -1
  464. package/dist/host_commands-3jyFfWNg.js +0 -2326
  465. package/dist/host_commands-DNf0f6ko.cjs +0 -1
  466. package/dist/internal-C9jjL90H.js +0 -2485
  467. package/dist/internal-RZN14uMw.cjs +0 -2
  468. package/dist/interop-CT-REx0W.cjs +0 -1
  469. package/dist/interop-CcY6ASQc.js +0 -18
  470. package/dist/interop-DqxleBo7.js +0 -18
  471. package/dist/interop-T1HZ-Dpa.cjs +0 -1
  472. package/dist/kernel-Cz2Kxlwl.js +0 -240
  473. package/dist/kernel-DgyrLFjW.js +0 -227
  474. package/dist/kernel-sw7ScKWu.cjs +0 -1
  475. package/dist/kernel-yWV3XnAb.cjs +0 -1
  476. package/dist/reactive/array.d.cts +0 -24
  477. package/dist/reactive/array.d.ts +0 -24
  478. package/dist/reactive/array.d.ts.map +0 -1
  479. package/dist/reactive/index.cjs +0 -1
  480. package/dist/reactive/index.d.cts +0 -11
  481. package/dist/reactive/index.d.ts +0 -11
  482. package/dist/reactive/index.d.ts.map +0 -1
  483. package/dist/reactive/index.development.cjs +0 -1
  484. package/dist/reactive/index.development.js +0 -18
  485. package/dist/reactive/index.js +0 -18
  486. package/dist/reactive/interop.d.cts +0 -19
  487. package/dist/reactive/interop.d.ts +0 -19
  488. package/dist/reactive/interop.d.ts.map +0 -1
  489. package/dist/reactive/kernel.d.cts +0 -73
  490. package/dist/reactive/kernel.d.ts +0 -73
  491. package/dist/reactive/kernel.d.ts.map +0 -1
  492. package/dist/reactive/map.d.cts +0 -16
  493. package/dist/reactive/map.d.ts +0 -16
  494. package/dist/reactive/map.d.ts.map +0 -1
  495. package/dist/reactive/shallow.d.cts +0 -11
  496. package/dist/reactive/shallow.d.ts +0 -11
  497. package/dist/reactive/shallow.d.ts.map +0 -1
  498. package/dist/reactive/struct.d.cts +0 -10
  499. package/dist/reactive/struct.d.ts +0 -10
  500. package/dist/reactive/struct.d.ts.map +0 -1
  501. package/dist/shallow-9S1pY_Iw.js +0 -159
  502. package/dist/shallow-C5YmTbtb.cjs +0 -1
  503. package/dist/shallow-DQ8aG51_.cjs +0 -1
  504. package/dist/shallow-DfnAm9vG.js +0 -150
  505. package/dist/shared-BMXh9hxm.cjs +0 -1
  506. package/dist/shared-DQKK0i-E.js +0 -109
  507. package/dist/topological_sort-BDvEyb9W.cjs +0 -1
  508. package/dist/topological_sort-DK6EjpWa.js +0 -135
  509. package/dist/typed_arrays-BcXcnzB4.js +0 -258
  510. package/dist/typed_arrays-D3NmwtCG.cjs +0 -1
package/CHANGELOG.md CHANGED
@@ -5,64 +5,1186 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [0.5.3] 2026-07-09
8
+ ## [0.6.0] - 2026-09-08
9
9
 
10
- ### Fixed heap columns back on V8's fast element-access path (~5× iteration)
10
+ ### Changed (breaking). One vocabulary for the query connectives
11
11
 
12
- - The pure-TS **heap profile** (`heapArraybufferAllocator` the default backing)
12
+ The connectives are `and`, `not` and `or`, one word each, everywhere they appear.
13
+
14
+ | 0.5 | 0.6 |
15
+ | --- | --- |
16
+ | `query.without(...)` | `query.not(...)` |
17
+ | `query.anyOf(...)` | `query.or(...)` |
18
+ | `query.withSparse(...)` | `query.andSparse(...)` |
19
+ | `query.withoutSparse(...)` | `query.notSparse(...)` |
20
+ | `query.withRelation(...)` | `query.andRelation(...)` |
21
+ | `query.withoutRelation(...)` | `query.notRelation(...)` |
22
+ | `qb.with(...)`, the builder form | `qb.and(...)` |
23
+ | `changed.without(...)`, `changed.anyOf(...)` | `changed.not(...)`, `changed.or(...)` |
24
+ | `not(cond)`, the run condition | `runIfNot(cond)` |
25
+ | `allOf(...conds)`, the run condition | `runIfAll(...conds)` |
26
+ | `anyOf(...conds)`, the run condition | `runIfAny(...conds)` |
27
+
28
+ `without`, `anyOf` and the four `with*` term verbs spelled the same three ideas in a second
29
+ vocabulary. A sparse term and a relation term keep their own verbs. A sparse id and a relation id
30
+ are both plain numbers at run time. No test tells them apart, so one `and` cannot
31
+ route them. Each now puts the connective word first.
32
+
33
+ The run condition combinators moved into the `runIf` family that `runIfAnyMatch` and
34
+ `runIfResourceEq` already used. That frees the three bare words for the query engine.
35
+
36
+ ### Added. `Query.where(term)`, and the `and`, `or` and `not` combinators
37
+
38
+ A chain asks one flat question of the component mask. An expression nests:
39
+
40
+ ```ts
41
+ import { and, or, not } from "@oasys/oecs";
42
+
43
+ const q = ecs.query(Tag).where(or(and(Pos, Vel), Frozen));
44
+ ```
45
+
46
+ Each combinator takes a component definition as a leaf, and another expression as a node. It
47
+ returns an `ArchetypeTerm`, which is `{ name, matches }`. A plugin supplies its own term on the
48
+ same footing. The three words are functionally complete over archetype membership, so there is no
49
+ fourth connective. An expression judges the dense component mask alone. Sparse membership and
50
+ relation membership are per entity, outside the archetype, and stay on their own terms.
51
+
52
+ The term runs one time per archetype, per query, at the rebuild the store's dirty epoch triggers.
53
+ `forEach`, `forEachChunk` and `forEachEntity` walk the list the rebuild produced.
54
+
55
+ **Added. `ECS_ERROR.QUERY_TERM_DENSE_PATH`.** `archetypeCount`, `archetypes` and `excludeWords`
56
+ answer from the unfiltered archetype list. So a query that carries an archetype term refuses all
57
+ three in a development build. The refusal used to borrow `SPARSE_QUERY_DENSE_PATH`, which named
58
+ the wrong fault.
59
+
60
+ **Added. The `ArchetypeExpr` type**, the operand of a combinator.
61
+
62
+ ### Changed (breaking). Four subsystems became plugins a world installs
63
+
64
+ `new ECS()` no longer carries relations, events, snapshot and restore, or observers. Each is a
65
+ plugin on its own subpath, installed at construction:
66
+
67
+ ```ts
68
+ import { ECS } from "@oasys/oecs";
69
+ import { relations } from "@oasys/oecs/relations";
70
+ import { observers } from "@oasys/oecs/observers";
71
+
72
+ const world = ECS.create({ plugins: [relations(), observers()] });
73
+ world.relations.register();
74
+ ```
75
+
76
+ `ECS.create` returns the world intersected with the facades its plugins contribute. A world that
77
+ did not install a plugin has no member to reach for. So `ecs.relations` on a bare world is a
78
+ compile error, and not a fault at run time. `new ECS()` still builds a world, and that world holds
79
+ none of the four.
80
+
81
+ The reason is that a class method cannot be removed by a bundler. While `ECS` declared `relations`
82
+ and `snapshots`, every program carried the relation and snapshot code whether or not it named them.
83
+ A plugin the construction site imports is a reference a bundler can follow, and one it can drop.
84
+ A program that installs none of the four now ships far less code. `bench/` holds the measurement.
85
+
86
+ Each plugin keeps its call sites unchanged. Only construction moves.
87
+
88
+ - `@oasys/oecs/relations`, `relations()`, gives `ecs.relations` and the relation terms on a query.
89
+ - `@oasys/oecs/events`, `events()`, gives `ecs.events`, `ctx.emit` and `ctx.readEvents`.
90
+ - `@oasys/oecs/snapshots`, `snapshots()`, gives `ecs.snapshots.capture` and `.restore`.
91
+ - `@oasys/oecs/observers`, `observers()`, gives `ecs.observe`.
92
+
93
+ On npm, each plugin also has a `/dev` subpath. `@oasys/oecs/relations/dev` and the seven others,
94
+ `@oasys/oecs/editor/dev` and `@oasys/oecs/solid/dev` among them, serve the build with the
95
+ development guards on. JSR publishes no `/dev` subpath. A plugin binds to the core build it was
96
+ made against. Take the plugin and the world from the same channel.
97
+
98
+ `ecs.snapshots.stateHash()` and `ecs.snapshots.deterministic` stay on every world. They describe the
99
+ world, not the plugin, and the determinism opt-in is still separate: `capture` and `restore`
100
+ throw `DETERMINISM_DISABLED` on a world built without `{ deterministic: true }`, installed or not.
101
+
102
+ In TypeScript, reaching for a plugin the world did not install is a compile error. In JavaScript
103
+ nothing stops the call, so the world throws `ECS_ERROR.PLUGIN_NOT_INSTALLED`. The message names
104
+ the API and the import that supplies it. The fix is at the construction site. On a bare world every
105
+ member of `ecs.relations` and of `ecs.events` throws it. So do the call `ecs.observe(...)` and the
106
+ four members `ecs.snapshots.capture`, `restore`, `captureSparse` and `restoreSparse`. The
107
+ system-side seams throw it too. `ctx.emit`, `ctx.readEvents`, `ctx.addRelation`,
108
+ `query.andRelation`, `query.hierarchy` and `query.forEachRelatedTo` are among them.
109
+
110
+ Installing one plugin two times throws the new `ECS_ERROR.PLUGIN_ALREADY_INSTALLED`.
111
+
112
+ The types `Plugin`, `PluginHost` and `PluginsOf` are exported from `@oasys/oecs`. A
113
+ third-party plugin is typed the way the four built-in plugins are. `Plugin<X>` is what a
114
+ factory such as `relations()` returns, and what a plugin list holds. Its `install` takes a
115
+ `PluginHost` and returns `X`, the surface the world gains. `PluginsOf` is the surface a plugin
116
+ list adds to the world.
117
+
118
+ ### Changed (breaking). `registerIsA` and `registerChildOf` ship on the relations entry
119
+
120
+ The two built-in relation presets moved off the package root:
121
+
122
+ ```ts
123
+ // before
124
+ import { ECS, registerChildOf } from "@oasys/oecs";
125
+
126
+ // after
127
+ import { ECS } from "@oasys/oecs";
128
+ import { relations, registerChildOf } from "@oasys/oecs/relations";
129
+
130
+ const world = ECS.create({ plugins: [relations()] });
131
+ const ChildOf = registerChildOf(world);
132
+ ```
133
+
134
+ `BuiltinRelationOptions` moved with them. Each function calls
135
+ `world.relations.register`, so it needs the plugin, and a bare world cannot call it. Exporting
136
+ them from the root also pulled the relation code into every bundle. The plugin split set out to
137
+ stop that.
138
+
139
+ ### Added. A change feed more than one plugin reads, and a richer plugin host
140
+
141
+ The store's record of what changed is now a seam any plugin drains. `ChangeFeed` names it,
142
+ `Store` implements it, and `PluginHost.changes` hands it out. A consumer asks for a grain with
143
+ `configureObservation` or `configureSparseObservation`, keyed by its plugin name. It drains with
144
+ `drainSet` or `drainSparseSet`. The store merges every consumer's ask by OR. So one consumer dropping
145
+ a flag never takes that flag from another. Each drain is memoized on its run. A second consumer of
146
+ one run gets the result the first one got. A consumer also takes each structural round through
147
+ `addStructuralHook`, and the feed carries `forEachChangedArchetype`, `collectEnabledWith`, `isAlive`,
148
+ `isDisabled` and `hasComponent`.
149
+
150
+ `PluginHost` gains three members. `host.world` is the bare world. Take it to register a system,
151
+ read a field, build a cursor or reach a resource. `host.changes` is the change feed.
152
+ `host.onSettle(fn)` runs `fn` at the tail of every `update()`, after every system and every flush of
153
+ the frame. Hooks run in install order. The `run` argument sits above every stamp the frame made.
154
+
155
+ `Plugin` gains `requires`, the plugins this one reads through, by name. `ECS.create` walks
156
+ the plugin list in order, so a dependency comes earlier in the list. A missing one throws
157
+ `PLUGIN_NOT_INSTALLED` at construction, and the message names the plugin that asked.
158
+
159
+ `ECS.create` now checks the facade a plugin returns. A key that names a member the world already
160
+ carries throws the new `ECS_ERROR.PLUGIN_SURFACE_COLLISION`. `Object.assign` would overwrite that
161
+ member without a word. The five reserved slots, `relations`, `events`, `observe`, `snapshots` and
162
+ `workers`, are the exception. The check is development-only.
163
+
164
+ Four types are exported from `@oasys/oecs`: `ChangeFeed`, `ObservationFlags`, `DrainResult` and
165
+ `StructuralObserverEvents`. The new [plugins](docs/api/plugins.md) page documents the host,
166
+ the rules and the feed for an author.
167
+
168
+ ### Added. The phase set is open
169
+
170
+ `ecs.addPhase(name, { loop, before, after })` adds one slot to the schedule and gives back a
171
+ `Phase` handle. A plugin owns its own slot that way. It no longer contends for insertion order
172
+ inside a phase the application also writes to.
173
+
174
+ `loop` is `"startup"`, `"fixed"` or `"update"`. It decides which drive runs the phase, and what
175
+ delta time the phase receives. `before` and `after` order the new phase against the other phases of
176
+ the same loop, built-in or added. A target in another loop expands to nothing. A phase that names
177
+ no neighbour lands at the tail of its loop. Each loop's phases sort with Kahn's algorithm, and
178
+ declaration order breaks a tie. The systems inside a phase already follow that rule. The order
179
+ resolves at `addPhase`.
180
+
181
+ A phase has an identity of object identity, and not of name, the rule `systemSet` follows. Two
182
+ calls with one name make two phases. The handle belongs to the world that made it.
183
+
184
+ The seven built-ins keep their `SCHEDULE` spelling and gain no handle. `addSystems` takes either
185
+ spelling, so no call that you write today changes.
186
+
187
+ `Phase`, `PhaseConfig`, `PhaseLoop`, `PhaseName` and `SchedulePhase` are exported from
188
+ `@oasys/oecs`. Two error categories are new. `ECS_ERROR.UNKNOWN_PHASE` names a phase this world
189
+ does not hold: a name no built-in spells, or a handle another world made.
190
+ `ECS_ERROR.CIRCULAR_PHASE_DEPENDENCY` names a phase order with a cycle. Both throw in each build.
191
+ A handle from another world would otherwise push systems into that world's list. A production
192
+ build would then run them nowhere.
193
+
194
+ A plugin adds a phase through `host.world.addPhase`.
195
+ `src/core/ecs/__tests__/integration/phase.test.ts` locks the order against the built-ins a phase
196
+ names, the identity rule, the three loops and both faults. See
197
+ [schedule](docs/api/schedule.md).
198
+
199
+ ### Changed (breaking). A frame trace event carries a `PhaseName`
200
+
201
+ `FrameTraceSink` and `FrameTraceEvent` widen `phase` from `SCHEDULE` to `PhaseName`, which is
202
+ `SCHEDULE | (string & {})`. A phase from `addPhase` spells the name it was given, so the value set
203
+ is open. A consumer that switches on `SCHEDULE.UPDATE` keeps working, and it now needs a default
204
+ arm. A consumer that assigns `event.phase` to a `SCHEDULE` variable no longer compiles. On
205
+ `FrameTraceSink`, `systemBegin`, `flushBegin`, `flushEnd` and `phaseBoundary` each take a
206
+ `PhaseName`.
207
+
208
+ ### Changed. The frame loop reads a phase's plan from a field
209
+
210
+ Each loop holds its phases as one sorted array, resolved at `addPhase`. Each phase holds its own
211
+ sorted plan as a field. The drive loop walks the array and reads the field. It used to key a map by
212
+ the phase string, once for each phase, in each frame. A schedule of short system bodies gets
213
+ faster. That is the case where the scheduler's own work is most of the frame. `bench/` holds the
214
+ comparison.
215
+
216
+ ### Changed (breaking for a plugin author). Every host hook point names what it hooks
217
+
218
+ `PluginHost` named two plugins. It now carries nine members, and every one is open to any plugin:
219
+ `store`, `world`, `changes`, `context`, `memory`, `onSettle`, `onPrewarm`, `onDispose` and
220
+ `installRoute`.
221
+
222
+ - `onPrewarm(fn)` contributes the access shapes that `startup()` folds into the archetype closure.
223
+ The observers plugin takes it, where it used to hand the world a registry.
224
+ - `onDispose(fn)` runs when the world goes away. A plugin ends a thread, a timer or a socket there.
225
+ - `memory` names where the world's bytes are: `backing`, `backingSource` and `storeBase`.
226
+ - `installRoute(planner)` claims the body of the systems a plugin routes. It gives back the one
227
+ call that turns the route on and off. A world holds one route, so a second install throws
228
+ `PLUGIN_ALREADY_INSTALLED`.
229
+
230
+ The renames that go with it: `WorkerHooks` is `SystemRoutePlanner`, `WorkerWorld` splits into
231
+ `PluginMemory` and `RouteControl`, `ParallelRoute` is `RouteDispatch`,
232
+ `SystemDescriptor.parallelPlan` is `routePlan`, and `Schedule.setWorkerPool` is `setRoute`. Of
233
+ these, `PluginHost` alone was ever exported, so the published break reaches a plugin author and
234
+ nobody else. An application that installs plugins sees nothing.
235
+
236
+ Two rules changed with the rename. The world asks the planner about every system it registers.
237
+ A planner answers `undefined` for one it does not claim. The old hook saw only a system
238
+ carrying a `parallel` config. So a plugin now validates its own config for every candidate.
239
+ `noteScan` moved onto `ChangeFeed`, where a consumer of the feed reaches it.
240
+
241
+ The dispatch path is unchanged by construction. Registration is keyed and cold, and the world
242
+ caches what it resolved into the field the dispatch already read. The emitted production dispatch
243
+ loop is byte-identical once the rename is undone.
244
+
245
+ `src/core/ecs/__tests__/integration/third_party_plugin.test.ts` writes a route outside the core.
246
+ It claims one system body, runs a dispose hook, and holds the world to the second-route refusal. See
247
+ [plugins](docs/api/plugins.md).
248
+
249
+ ### Removed (breaking for a plugin author). `installObservers` and `installWorkers`
250
+
251
+ Both were named slots on `PluginHost`, one for each first-party plugin. The core named two plugins
252
+ that way, and every other plugin went through the generic path. The generic hook points above
253
+ replace them. A plugin outside this package now reaches the ground the first parties reach.
254
+
255
+ The stated reason for the two slots was hot-path lookup cost. It holds for one read alone, the
256
+ system dispatch route, and that read stays one typed slot. Every other read behind the two slots is
257
+ cold.
258
+
259
+ ### Added. `solid()`, a plugin that writes one Solid signal per row off the change feed
260
+
261
+ `@oasys/oecs/solid` now exports `solid()`, for `ECS.create({ plugins: [solid()] })`. A world that
262
+ installs it carries `ecs.solid`, which projects ECS state into Solid signals. It reads the change
263
+ feed and writes Solid, with nothing in between. The observers plugin is out of that path. This is
264
+ the one path from ECS state into a UI.
265
+
266
+ `ecs.solid` has three entry points, and each view carries `dispose()`. `component(def, project)`
267
+ projects one dense component, and `cell(id)` is that row's value as one Solid signal. The first call
268
+ for an id makes the signal, and every later call returns the same accessor. So bind it once for each
269
+ row. A `keys()` signal beside it drives a keyed `<For>`. `fields(def, fields)` is sugar that
270
+ publishes a fixed field list as a record, with an `eq` that compares those fields.
271
+ `singleton(def, eid, fields)` publishes one entity's fields into a keyless Solid store, where a fixed
272
+ key set earns the store's cost. A remove or a disable of the target resets them. `grain` is
273
+ `"entity"`, the default, or `"column"`. `eq` is each cell's value equality, handed to Solid as the
274
+ signal's `equals`, and it defaults to Solid's `===`. `seedExisting` publishes the current enabled
275
+ members at creation, and defaults to true.
276
+
277
+ The first design published into a Solid store keyed by entity id. `bench/foundations/p23-solid.mjs`
278
+ measured that store's publish above the publish that ships, at every density. So a row became a
279
+ signal before this release.
280
+
281
+ Everything publishes at the settle point, the tail of `update()`. A structural event arrives mid-tick
282
+ and records an entity id. Nothing reaches Solid inside the flush. The plugin then publishes
283
+ inside one Solid `batch`. One `update()` is one Solid flush, whatever the number of views. An entity
284
+ spawned and despawned in one tick never appears. A published value is the final value of the tick.
285
+ Only a deferred structural operation reaches a view. The observers carry the same limit, because both
286
+ read structural events from one flush.
287
+
288
+ This plugin and the observers plugin are two consumers of one feed. Install both, in either
289
+ order, and each one sees the same by-id write.
290
+
291
+ What it refuses. Dense components only. A sparse definition throws a `TypeError` that names the call.
292
+ No join. A view subscribes to one component, so a projection that reads a second component goes
293
+ stale. An entity that leaves the component and rejoins inside one tick projects twice. The value is
294
+ the final one. A cell carries the whole projected value, so a field read does not track that field
295
+ alone. A projection must not return a function, which a Solid setter reads as an updater. A cell is
296
+ kept for the life of the view.
297
+
298
+ Measured against the path it replaces. `bench/foundations/p23-solid.mjs` times a whole tick on both
299
+ paths with one effect per entity. Once the path is warm, the plugin is the cheaper of the two.
300
+ That holds at every dense density the probe measures, and the gap widens with density. Its publish
301
+ alone, with no subscriber, costs less as well. On a tick that moves one row or no row the older
302
+ path measures lower.
303
+
304
+ What is untested. Under the test runner, `solid-js` resolves to its server build. There a signal
305
+ holds a value, consults no comparator and schedules no effect. The tests assert the value, and they
306
+ assert that `eq` reaches the signal and then run it by hand. The suite renders no component. It
307
+ proves nothing about a `<For>` re-render.
308
+
309
+ `src/plugins/solid/__tests__/solid.test.ts` locks the seed, the by-id publish, and the spawn and
310
+ the despawn. It locks the disable and the enable, the column grain, and one batch for each update.
311
+ It locks the cell identity across a delete, and the `eq` the cell carries. It locks coexistence
312
+ with observers, the singleton reset and the sparse refusal.
313
+
314
+ ### Removed (breaking). `ECS_ERROR.ENTITY_NOT_DISABLED`
315
+
316
+ The code was declared in 0.4.0 and no path ever threw it. `enable` on a live entity is a no-op by
317
+ contract, so the fault it named cannot occur. A handler that compares against it no longer compiles.
318
+ Delete the branch.
319
+
320
+ ### Removed (breaking). The signals kernel, its ECS bridge, and the kernel-to-Solid adapter
321
+
322
+ `@oasys/oecs/reactive` and `@oasys/oecs/reactive-sync` are gone. So are the adapter functions on
323
+ `@oasys/oecs/solid`: `fromKernel`, `fromKernelMap`, `fromKernelStruct` and `fromKernelArray`.
324
+ `@oasys/oecs/solid` now exports the `solid()` plugin alone.
325
+
326
+ The `solid()` plugin is the one path from ECS state into a UI. It reads the change feed and writes
327
+ Solid. The kernel and its mirror were a second path to the same place. They put two more graphs in
328
+ between. `component`, `fields` and `singleton` replace `syncComponentToMap`, `syncFieldsToMap` and
329
+ the two singleton bridges. `batchedUpdate` has no replacement, because a view publishes inside one
330
+ Solid `batch` at the settle point of `update()`.
331
+
332
+ There is **no React path in this release**, and no framework-free reactive path. A consumer that is
333
+ not a Solid app polls the world. Take `ecs.getField`, a cursor, or a `changed()` query.
334
+ `syncJoinToMap` also has no replacement. A view subscribes to one component, so take one view for
335
+ each component and combine them where you read.
336
+
337
+ ### Changed. The host write seam drains at a phase the caller added
338
+
339
+ `HostCommandSeamOptions.schedules` took `readonly SCHEDULE[]`, so a seam could drain at one of the
340
+ seven built-ins only. It now takes `readonly SchedulePhase[]`, the same union `addSystems` takes, so
341
+ a handle from `ecs.addPhase` works. A plugin drains at the slot it owns. It no longer contends for
342
+ insertion order inside a phase the application also writes to. Every existing call still compiles,
343
+ because a `SCHEDULE` member is a `SchedulePhase`.
344
+
345
+ The recorder's refusal now reads the loop of a phase and not its name. A phase added with
346
+ `loop: "fixed"` is refused the same way `SCHEDULE.FIXED_UPDATE` is, with
347
+ `ECS_ERROR.INVALID_RECORDER_SCHEDULE`, and the message names the phase. The reason is unchanged. A
348
+ fixed-loop drain sees the fixed timestep, and not the host update dt. So the replay would diverge.
349
+ The seed-time bucket follows the loop too, so a phase of the startup loop records as startup.
350
+
351
+ ### Changed. A malformed event id and a malformed system id throw `ECSError`
352
+
353
+ The two id minters refused a value that is not an integer >= 0 with an `AssertionError`. They now
354
+ throw an `ECSError` carrying the new `ECS_ERROR.INVALID_EVENT_ID` and
355
+ `ECS_ERROR.INVALID_SYSTEM_ID`. Both checks are development only, and the production build is
356
+ unchanged. Neither minter is on a public entry, so only a `catch` that tested the assertion class
357
+ sees the change.
358
+
359
+ The reason is the bundle. `AssertionError` extends `AppError`. So a plugin bundle that reached it
360
+ carried a second error class, and bound the base from the package. `ECSError` already resolves to
361
+ the package root. The assertion class and its base now leave every plugin bundle, and
362
+ `dist_artifact.test.ts` locks that.
363
+
364
+ ### Removed. `AppError` leaves `@oasys/oecs/internal`
365
+
366
+ The base class was on the tooling entry because a development plugin bundle bound it there. No
367
+ bundle reaches it now, so the entry drops it. `ECSError` and `isEcsError` are on the package root,
368
+ and they are what a consumer catches.
369
+
370
+ ### Added. The store can start anywhere in its memory
371
+
372
+ `memory.storeBase` places the store header at a caller-chosen byte offset. Every offset the store
373
+ writes is now relative to that base. That covers the header, the column descriptors, the region
374
+ table and the rings. `capacity` is the span from the base. The store writes nothing below the base.
375
+ A wasm-backed world defaults to one page and refuses zero. A compiled module owns the low
376
+ addresses of its own linear memory. A safe Zig or Rust build cannot read address 0. A caller
377
+ places the base above the module's `__heap_base` and its run-time heap. `memoryPlan.storeBase`
378
+ reports the value. `WASM_STORE_BASE_BYTES` is exported from `@oasys/oecs/internal`.
379
+
380
+ `storeBaseAbove(exports, extraBytes)` reads a module's `__heap_base` export, adds the run-time heap
381
+ the caller reserves, and rounds up to a whole page. So the base clears everything the module owns.
382
+
383
+ A checked-in WebAssembly module, built with no toolchain, now reads a live store in the test suite.
384
+ It agrees with the TypeScript side on the layout walk and the byte digest. It also agrees on an f32
385
+ kernel and the deterministic state hash. The layout is a tested ABI, not a fixture that TypeScript
386
+ compares with itself.
387
+
388
+ ### Added. One system across workers
389
+
390
+ `workers()` from `@oasys/oecs/workers` is a plugin. `ECS.create({ plugins: [workers()] })` gives a
391
+ world `ecs.workers`, which carries `attach(options)`, `pool` and `detach()`. The pool, the plan
392
+ builder and the shim that reaches the node threads module ship in that subpath. So a world that
393
+ never names it carries none of them. A JavaScript caller reading `world.workers.attach` on a bare
394
+ world gets `ECS_ERROR.PLUGIN_NOT_INSTALLED`, and the message names the import. `AttachWorkersOptions`,
395
+ `WorkerPool`, `WorkersPlugin` and `DEFAULT_JOIN_TIMEOUT_MS` are exported from the same subpath.
396
+ `ParallelConfig`, `ParallelKernel` and `ParallelColumn` stay on the root, because they erase.
397
+
398
+ `world.workers.attach({ count })` starts a persistent pool on the package's own worker entry,
399
+ `@oasys/oecs/worker`. A system that carries a `parallel` config names a kernel a worker can load.
400
+ That kernel is a compiled `WebAssembly.Module` export, or an export of a JavaScript module URL. The
401
+ config also names the columns the kernel receives, in order. The schedule hands the pass to the
402
+ pool inside the same access span a TypeScript body gets. It parks the host on `Atomics.wait`, and
403
+ joins before the phase flush. No spawn, no despawn and no grow can overlap the workers. Nothing
404
+ else runs on the main thread while it is parked. Every worker computes its own row range per
405
+ archetype. It reads the descriptor row counts, its index and the worker count. So no plan crosses
406
+ the wire, and the result is deterministic. The join stamps every matched archetype for each
407
+ declared write.
408
+
409
+ A parallel system declares only `reads`, `writes` and a dense query. Sparse, relation, resource,
410
+ spawn, despawn and transition declarations, `exclusive`, and `backendHandle` are refused at
411
+ registration with `ECS_ERROR.PARALLEL_ACCESS`. Those refusals ship with the plugin. So a world that
412
+ installed no workers plugin validates no `parallel` config, builds no plan and runs the system's
413
+ `fn`. Below `parallel.minRows`, and without an attached pool, the system runs its `fn`. A heap
414
+ world cannot attach workers. A WASM kernel needs the wasm backing, because a `SharedArrayBuffer`
415
+ cannot be imported as a module memory.
416
+
417
+ The split pays only above a row count that depends on the machine, the kernel and the worker count.
418
+ `parallel.minRows` carries a measured default that sits above every crossover the probes found. The
419
+ probes cover both bodies, both kernel forms, both backings and every runtime tested. A world that
420
+ never tunes it never pays a pooled frame the sequential frame would have won. It gives up the gain
421
+ instead. A compute-bound kernel crosses far earlier and should set its own value. A caller's value
422
+ always wins. `bench/` holds the measurements and the tuning method.
423
+
424
+ With a bundler, pass `workerUrl` from the bundler's own URL import of the `@oasys/oecs/worker` entry, for
425
+ Vite `import workerUrl from "@oasys/oecs/worker?worker&url"`. The default resolution finds the entry beside
426
+ the package as it ships and not inside a bundle. A worker whose script does not load now fails
427
+ `workers.attach` with `ECS_ERROR.WORKERS_ENTRY_UNREACHABLE`. It terminates the pool, instead of
428
+ resolving with workers that never answer. The pool reaches the node threads module through
429
+ `process.getBuiltinModule`. So a browser build sees no node builtin specifier, and prints no warning.
430
+
431
+ `workers.attach` takes `joinTimeoutMs`, a safety net and not a budget. A worker that dies inside a pass
432
+ can never report done, and the parked host would wait forever. On timeout the frame throws
433
+ `PARALLEL_KERNEL_FAILED`. The pool enters a failed state in which every later frame runs `fn`.
434
+ `detach` terminates the hung worker.
435
+
436
+ At the join every worker adds one to a done word. The worker whose add completes the count
437
+ wakes the host. So the host wakes once for a pass, whatever the worker count is. `bench/` holds
438
+ the measurement beside a per-worker done word and a tree join, both of which cost more.
439
+
440
+ ### Added. A kernel module contract that holds for any toolchain
441
+
442
+ Every worker of the pool instantiates one module over one memory. `docs/api/parallel.md` now states
443
+ what that costs a module, and what a build has to do about it. The list is the one import, the
444
+ export and its arity, and the store base. It also covers the stack, the data segment and the heap.
445
+ It carries one build line for Zig, for Rust, for C through `zig cc` and for AssemblyScript.
446
+
447
+ `registerSystem` refuses a `wasm` kernel module the pool cannot serve, with the new
448
+ `ECS_ERROR.PARALLEL_KERNEL_MODULE`. An import other than `env.memory` is named in the message. A
449
+ module that imports no memory is refused as well. It addresses a linear memory of its own,
450
+ writes rows nothing reads, and reports success. An export name the module does not carry, and an
451
+ export that is not a function, are the other two. Development builds only, at registration.
452
+
453
+ A worker now checks the export's parameter count against the column count plus three. It fails the
454
+ kernel load with both numbers when they disagree.
455
+
456
+ Five modules are checked into the test suite. The four toolchains above built them, and so did a
457
+ hand-written emitter that uses no toolchain. Each carries the same bodies. Each runs on the real
458
+ pool across several workers. Each must leave the bytes the sequential TypeScript body leaves. Five
459
+ more carry one fault each, so every refusal above has a real module behind it. The suite proves the
460
+ contract on a machine with no compiler installed.
461
+
462
+ ### Added. Each worker instance owns its shadow stack, and `stackBytes` sizes it
463
+
464
+ A worker gives each instance of a `wasm` kernel module its own shadow stack.
465
+
466
+ An LLVM build, which is Zig, Rust, C and others, keeps a shadow stack in linear memory. It addresses
467
+ that stack through the mutable global `__stack_pointer`. A wasm global is per-instance, and every
468
+ instance starts at the address the linker chose. So every worker wrote its frames to the same bytes.
469
+ A kernel could spill a local array, a struct passed by pointer, or the address of a local. It then
470
+ read back what another worker wrote. The corruption was silent, and it needed no shared column. No
471
+ probe before this one caught it. The earlier kernels held every value in a wasm local.
472
+
473
+ The worker now carves one region for each worker out of `[__heap_base, storeBase)`. It moves
474
+ `__stack_pointer` to the top of its own. The regions come off the top of that span, downward from
475
+ the store base. So worker `i` gets its top at `storeBase - i * stackBytes`.
476
+
477
+ `workers.attach({ stackBytes })` says how big one region is, and everything below the lowest region
478
+ stays the module's heap. Reserve the module's peak run-time heap plus one stack for each worker with
479
+ `storeBaseAbove`. Then pass the same `stackBytes` to the pool. Omit it and the pool divides the whole
480
+ span, which leaves the module no heap. That is the default, and it suits a kernel that allocates
481
+ nothing. The heap rule asks for that anyway.
482
+
483
+ `stackBytes` must be an integer, a multiple of the frame alignment of 16, and at least one WASM page.
484
+ A value outside that fails the attach with `WORKERS_COUNT_INVALID`. A span too small to hold one
485
+ region for each worker fails the kernel load with `PARALLEL_KERNEL_FAILED`. The message names the
486
+ span, the region, the worker count and the remedy. A module that exports no `__stack_pointer` is left
487
+ alone. The docs say such a kernel may not use a stack. One worker needs no region, because one
488
+ instance owns the linked stack alone.
489
+
490
+ The assignment runs once for each kernel load, so a pass pays nothing for it.
491
+
492
+ ### Changed (breaking for a module that reads the layout). `SIM_ABI_VERSION` is 1
493
+
494
+ A reader that carries version 0 measured every offset from buffer byte 0. A module that treated a
495
+ `byte_off` as a buffer address must add the store base it receives through `setLayout`. Restore and
496
+ resume accept a version 0 snapshot. Every version 0 store sat at byte 0, and its offsets read
497
+ correctly as offsets from the header. So a snapshot the 0.5 line wrote still restores. Any other
498
+ version is refused.
499
+
500
+ The archetype descriptor header grows from 36 bytes to 40 and gains `entity_ids_off` at offset 36.
501
+ The field is reserved for the archetype's row-to-entity table. The store writes zero, which says
502
+ the archetype carries no such table. A walker steps to the next record by `40 + column_count * 16`.
503
+ A reader that ignores the field reads every other field as before. A snapshot carries the
504
+ descriptor bytes. So restore rewrites a version 0 region at the new width, before it reads anything
505
+ else. The world's `stateHash` is unchanged, because it never folds a descriptor.
506
+
507
+ ### Changed. `ComputeBackend.run` takes `dt` and the tick
508
+
509
+ `ComputeBackend.run(handle, deltaTime, tick)` replaces `run(handle)`. A backend that still declares
510
+ `run(handle)` keeps compiling and keeps running, because the extra arguments are ignored. Only code
511
+ that calls `run` itself sees the new shape. A module body needs `dt`, and
512
+ neither `dt` nor the frame tick lives in the bytes. The schedule also publishes the descriptor row
513
+ counts before every backend dispatch. So a module never reads a stale count. That covers a host
514
+ spawn before `startup()`, and a spawn from a run condition.
515
+
516
+ A caller-supplied `WebAssembly.Memory` may now carry `maxBytes`. The store needs a cap to promise
517
+ its span, so the cap is `maxBytes` or the default ceiling.
518
+
519
+ ### Fixed
520
+
521
+ A kernel that would not load rejected `workers.attach` and left its workers running. So a node
522
+ process never exited on its own. The pool now ends the workers before the fault leaves.
523
+
524
+ An `ECSError` built on an engine without `Error.captureStackTrace` was a `TypeError` with no
525
+ category. The base class now checks for that V8 extension before it calls it. `error.name` read as
526
+ one minified letter in the production build, because it came off the constructor. It is now the
527
+ literal `ECSError`.
528
+
529
+ The command, event and action rings copied slot payloads through a view built from buffer byte 0.
530
+ At a nonzero base they wrote below the store. The rings now add the view offset.
531
+
532
+ `wasmMemoryAllocator` predicted the JavaScriptCore write cost of a growable `SharedArrayBuffer`. A
533
+ shared `WebAssembly.Memory` does not pay it. The comment now says so, and `bench/` holds the
534
+ measurement.
535
+
536
+ ### Changed. The store's observation seam takes a consumer name
537
+
538
+ `Store.configureObservation` and `Store.configureSparseObservation` take the consumer name first, and
539
+ the store merges every consumer's record instead of holding one. The structural hook seam is now a
540
+ list that every consumer joins. It was one dispatch bound to the observer registry. The registry is
541
+ now one consumer among others, named `observers`.
542
+
543
+ The observers plugin behaves as it did. It still owns the access-topological order, the radix
544
+ entity order, the access span of each callback and `yieldExisting`.
545
+ `src/core/ecs/__tests__/unit/change_feed.test.ts` locks the flag merge, the drain memo, and the
546
+ install order of both hook lists.
547
+
548
+ ### Changed (breaking). The store no longer forwards to its collaborators
549
+
550
+ Thirty methods on the internal `Store` forwarded one operation each to a collaborator, and carried
551
+ no logic. A caller now names the owner: `store.relations.addRelation`, `store.events.emit`,
552
+ `store.resources.get`, `store.snapshots.snapshot`. The forwarding hid which object held the state and
553
+ widened the class for nothing.
554
+
555
+ ### Changed. The query terms travel as one record
556
+
557
+ A query carries two kinds of term. A dense term sets a bit in the component mask and picks the
558
+ archetypes. Every other term now rides in one `QueryTerms` record. Those terms are sparse
559
+ membership, optional fetch, include-disabled, the `(R, *)` wildcard, hierarchy ordering, and an
560
+ archetype term from `where`. `Query`'s constructor takes one parameter where it took seven. The
561
+ three driver seams take one where they repeated four. A query that declares no such term shares
562
+ one frozen record. Adding a term is one edit instead of five.
563
+
564
+ ### Changed. `Commands` and `SystemContext` moved to their own module
565
+
566
+ `query.ts` held the read side and the write side of the system-facing interface. The write side is
567
+ `system_context.ts` now. Every export is unchanged, and the barrel re-exports both.
568
+
569
+ ### Fixed. The build no longer splits the core entry into small chunks
570
+
571
+ Declaring the plugin entries beside the core entries put them in one rollup graph. Rollup
572
+ then split `index.js` into ten small shared chunks. Those splits are real module boundaries at run
573
+ time. A measurement of `spawn` against the shipped artifact showed the cost. The plugins
574
+ build in their own pass now, and the core chunk graph is unchanged.
575
+
576
+ ### Changed (breaking). A name that misdescribed its body now says what it does
577
+
578
+ A name that promises one act and performs another sends a reader to the wrong conclusion without
579
+ opening the body. An audit read every named function and method in `src/` against its body. Each
580
+ name that failed is now the act it performs. The old names are removed and not aliased, because a
581
+ name that stays reachable keeps teaching the wrong model.
582
+
583
+ The public surface:
584
+
585
+ | 0.5 | 0.6 |
586
+ | --- | --- |
587
+ | `query.eachChunk(cb)` | `query.forEachChunk(cb)` |
588
+ | `query.forEachUntil(cb)` | `query.some(cb)` |
589
+ | `ctx.read(key)` | `ctx.readEvents(key)` |
590
+ | `ecs.onStoreLayoutPublished(fn)` | `ecs.subscribeLayout(fn)` |
591
+ | `ecs.publishArchetypeRowCounts()` | `ecs.publishRowCounts()` |
592
+ | `queue.pending` | `queue.pendingCount` |
593
+ | `FrameTraceSink.systemStart` | `FrameTraceSink.systemBegin` |
594
+ | `column.get(i)`, on `/primitives` | `column.getAt(i)` |
595
+ | `column.ensureCapacity(n)`, on `/primitives` | `column.reserve(n)` |
596
+
597
+ `ctx.read` moves because `cols.read(def)` in the same walk returns a column group, so one verb
598
+ carried two shapes. `forEachUntil` returns whether a callback accepted, thus it is a predicate and
599
+ now reads as one. `reserve` is the contract of `ColumnBacking`: guarantee room for the count, or
600
+ throw. A heap column grows to keep it. A buffer-backed column cannot grow, so it throws, and each
601
+ doc states which.
602
+
603
+ On `@oasys/oecs/internal`, every `accessCheck.check*` method is now `assert*`, and
604
+ `dispatchTrace.recordEmit` and `recordRead` are `recordEventEmit` and `recordEventRead`.
605
+
606
+ Three rules now hold across the tree. One verb throws on a bad state, `assert`, and `validate`
607
+ keeps only the helpers that return the value they test. One verb constructs, `create`. The
608
+ underscore prefix marks a private or a protected class member and nothing else. So a member that
609
+ another module reaches carries no prefix.
610
+
611
+ ### Changed (breaking). A field name now says what it holds
612
+
613
+ The same audit read every field and every module-scope variable. A field whose name promised one
614
+ content and held another is now the content it holds. The underscore prefix now marks a private
615
+ or a protected member on a field. It does so exactly as it does on a method. A member that another
616
+ module reaches carries no prefix, whatever its role.
617
+
618
+ The public surface:
619
+
620
+ | 0.5 | 0.6 |
621
+ | --- | --- |
622
+ | `query._defs` | `query.defs` |
623
+ | `query._include` | `query.include` |
624
+ | `query._id` | `query.id` |
625
+ | `query._sparseInclude`, `query._sparseExclude` | `query.terms.sparseIncludes`, `query.terms.sparseExcludes` |
626
+ | `query._optional` | `query.terms.optionalTerms` |
627
+ | `query._includeDisabled` | `query.includesDisabled` |
628
+ | `query._relationIncludes`, `query._relationExcludes` | `query.terms.relationIncludes`, `query.terms.relationExcludes` |
629
+ | `query._hierarchy` | `query.terms.hierarchyTerm` |
630
+ | `cols._arch`, `cols._tick`, on `ChunkColumns` | `cols.arch`, `cols.tick` |
631
+ | `ecs._caches` | `ecs.caches` |
632
+ | `bitset._words`, on `/primitives` | `bitset.words` |
633
+
634
+ None of these is part of the documented API. Each is public because another module reads it, so the
635
+ prefix claimed a privacy the member never had. Three of them could not drop the prefix alone.
636
+ `Query` already carries an `optional`, an `includeDisabled` and a `hierarchy` method. Each
637
+ of those three now names the thing it holds: a term list, a flag, a term. The terms that are not
638
+ dense then moved into the one `terms` record described above. `includesDisabled` stayed on the
639
+ query as its own copy.
640
+
641
+ Inside the package the same rule moved about a hundred more members. `Archetype` publishes
642
+ `flatColumns`, `bufs`, `accessorColumns`, `colOffset`, `fieldCount`, `columnIds` and `changedTick`
643
+ without a prefix. `Store` publishes `tick`, `trace`, `anyDirtyTracked` and `queryDirtyEpoch`.
644
+ Every private field of `Store`, `Schedule`, `ECS`, `AccessCheck`, `EventRegistry` and the editor now
645
+ carries one. A table that holds one entry per entity reads as plural, so `Store.entityRow` is
646
+ `_entityRows` and `Store.entityArchetype` is `_entityArchetypes`. `Schedule.setConditions` and
647
+ `setOrdering` read as verbs and are `_conditionsBySet` and `_orderingBySet`. `EventRegistry.count`
648
+ allocates the next event id and is `_nextEventId`. `DispatchTrace.buf` holds counts, not bytes, and
649
+ is `_counts`.
650
+
651
+ ### Fixed. A fifth column type made every row move in the process slow
652
+
653
+ Every structural row operation walked the columns of an archetype through one loop. That loop had
654
+ one typed-array access site. The operations are the copy behind `addComponent` and
655
+ `removeComponent`, the swap-remove behind `despawn`, and the swaps behind `disable` and `enable`.
656
+ The site saw every column type that any archetype in the process used. V8 keeps one site fast for
657
+ at most four typed-array classes. At the fifth type the site became megamorphic. Each element move
658
+ then cost far more, in every archetype, and not only in the one that mixed the types. The library
659
+ offers eight column types. Consider a schema with `f32` positions, an `i32` counter, a `u8` flag, a
660
+ `u16` team and an `f64` timer. It reached the fifth type without notice.
661
+
662
+ The structural operations now move bits through views whose class depends on the element width
663
+ alone (`Uint8Array`, `Uint16Array`, `Uint32Array`, `Float64Array`). So the site sees at most four
664
+ classes. A write of a number must convert to the column's type. So the value-writing paths use the
665
+ true view, through one access site for each type. Those paths are `spawn` with a template, and
666
+ `addComponent` with values. The cost of a row move is now flat across the number of column types.
667
+ It is unchanged for a world that uses one type.
668
+
669
+ ### Changed. A sparse component is id-indexed, and `sparseCursor` is the fastest read by id
670
+
671
+ A sparse component kept each entity's values in a small JavaScript array. A map keyed by entity
672
+ index held those arrays. So a read by id paid a lookup and a pointer chase. It also stored a value
673
+ as the number given, and not as the declared type. The store now keeps one typed array of the
674
+ declared type for each field, indexed by entity index. A sparse set sits beside them for
675
+ membership. A read by id is one load. An add or a remove is a bit and a write at the index. A value
676
+ converts as the field's type converts, as a dense field does. An `i32` truncates, a `u8` wraps, an
677
+ `f32` rounds.
678
+
679
+ `sparseCursor` and `sparseCursorRead`, on `ecs` and on `ctx`, are the sparse form of `cursor`.
680
+ `at` writes the entity index alone, and a field access is one load. So a sparse cursor is the read
681
+ by id to use. Take it when a system touches many entities from a list of ids. In development, `at`
682
+ throws for a dead entity or a non-member. In production it does not test. `ctx.sparseCursor` needs the
683
+ component in `sparseWrites`, and `ctx.sparseCursorRead` in `sparseReads`.
684
+
685
+ The columns of a store double to fit the highest member index. So the memory of a sparse component
686
+ is proportional to that index, and not to the member count. The snapshot format is unchanged. The
687
+ `indices` view of a store is now a typed view with a fixed length. The query driver walks the live
688
+ member list, so a walk sees an edit made during it as before. That walk is `forEachEntity` over a
689
+ sparse term. It now keeps the dense verdict of the last archetype it saw. So members of one
690
+ archetype that sit together in the sparse list pay the mask test one time, not each.
691
+
692
+ ### Fixed. Every ref and cursor read paid for a property key that the optimizer could not fold
693
+
694
+ The state of a ref or cursor was keyed by two symbols that other modules imported. The package
695
+ build puts the accessor module and the store in different chunks. A key read through an import
696
+ binding is not a constant to the optimizer. So every field access through a ref or a cursor was a
697
+ generic keyed load. A symbol has no other way to be reached. So the state is now keyed by two
698
+ reserved names, `__cols` and `__row`. Each site writes them as literals. Registration refuses those
699
+ two names on a dense or a sparse component.
700
+
701
+ Two more constants on the by-id paths are read through local copies, for the same reason. They are
702
+ the entity index mask, and the entity-id bounds that `isAlive` compares against. The `switch`
703
+ over column types in the value-write path compares against local copies of the tags. So it compiles
704
+ to a jump table.
705
+
706
+ The shape of the accessors is also settled when the module loads. A dense cursor reassigns its
707
+ column array on every `at`. A ref or a sparse cursor never does. So the first dense `at` in a
708
+ process changed a field of the shared shape from constant to mutable. Every optimized function
709
+ that had read the field under the constant assumption was thrown away. It compiled again, slower.
710
+ One throwaway object of each shape now reassigns both fields at load. So nothing compiles under the
711
+ assumption, and nothing is thrown away.
712
+
713
+ ### Fixed. Systems made from one factory ran their hot loops much slower
714
+
715
+ V8 decides what to inline from the feedback of a call site. Every system in a world could come from
716
+ one function literal. A factory such as `makeMover(component)` does that, and so does a world with
717
+ a single system. The scheduler's dispatch site then saw one target. The engine inlined the system
718
+ body into the scheduler's own loop over the systems. That body carried its `eachChunk` callback and
719
+ its hot loop. That inlined loop ran much slower than the same loop compiled on its own. A world
720
+ whose systems came from two or more literals never hit this. So the factory case was slower than
721
+ the plain case. Nothing in the user's code said why.
722
+
723
+ Every system body now runs through one trampoline whose call site the module makes megamorphic
724
+ when it loads. No system body is inlined into the scheduler, whatever the number of literals, and
725
+ each is compiled on its own. A dispatch costs a little more, one time for each system in each phase.
726
+ A system that does any work gains more than that cost.
727
+
728
+ ### Changed. The cost of a new archetype no longer grows with the number of archetypes
729
+
730
+ A world discovers its archetypes as it runs, and each new one extends the column store. That extend
731
+ took the in-place path, which moves no rows. But it still walked every archetype three times. One
732
+ walk built a list of row counts that only the realloc path reads. One summed the descriptor bytes
733
+ in use. One copied the archetype map. So the N-th archetype cost N steps, and a world with many
734
+ archetypes paid for that at startup. The list is now built only when the realloc path runs. The
735
+ store caches the descriptor bytes in use. The in-place extend appends to the archetype
736
+ map, instead of copying it. The cost of a new archetype is now the cost of its own columns.
737
+
738
+ ### Fixed. A fifth component made every ref and cursor in the process slow
739
+
740
+ A ref got one prototype for each (archetype, component) pair, and a cursor one for each component.
741
+ An engine gives an object a distinct shape for each distinct prototype. So the read of the row
742
+ inside each getter saw one shape for each component. That covers every component the program read
743
+ through refs or cursors. At the fifth shape that read became megamorphic. Every field access
744
+ through every ref and every cursor in the process became far slower. A world with five components,
745
+ each with only `f64` fields, was enough.
746
+
747
+ Every ref and every cursor now shares one prototype for the whole process. Each distinct field name
748
+ gets one global id and one accessor on the prototype. The first component registration that uses
749
+ the name installs them. An accessor holds the component's columns indexed by that id. So a field
750
+ read costs the same two index operations it did before. The own state of an accessor is the two
751
+ reserved names `__cols` and `__row`. Registration refuses both as field names, so no field can
752
+ collide with it. A field the component does not have throws `FIELD_NOT_REGISTERED` under `DEV`,
753
+ where it read a neighbouring column before.
754
+
755
+ `for..in` over a ref or a cursor is no longer a way to list a component's fields. Use the schema.
756
+ The shared prototype carries the field name of every component registered in the process. The
757
+ two reserved names are own fields. So the walk reports all of them. `Object.keys` and the spread
758
+ report the two reserved names alone, where they reported nothing before.
759
+
760
+ Two components may give one field name different types. An `x` may be `f32` in one and `i32` in
761
+ another. That name reads and writes correctly on both. Its accessor dispatches on the column's
762
+ class. That costs one `switch` more than an accessor for a name with one type.
763
+
764
+ ### Changed (breaking). `memory` is two fields, and not one union of five arms
765
+
766
+ `ECSOptions.memory` held two questions inside one key-discriminated union. The questions do not
767
+ depend on each other: how big the world is, and what holds its bytes. A caller could answer only
768
+ one of them. The `budget` arm and the `maxBytes` arm each selected the heap allocator themselves.
769
+ So "a budget of 50,000 entities on a shared backing" was not something you could say. `maxBytes`
770
+ had to appear three times, once for each backing arm, because the size axis had nowhere else to
771
+ live.
772
+
773
+ Sizing and backing are now two fields, and every pair of them is legal:
774
+
775
+ ```ts
776
+ new ECS({ memory: { entities: 50_000 } }); // size only
777
+ new ECS({ memory: { backing: "shared" } }); // storage only
778
+ new ECS({ memory: { entities: 50_000, backing: "shared" } }); // both, this was a type error before
779
+ new ECS({ memory: { entities: 50_000, maxBytes: 64 * MiB } });// size from one, ceiling from the other
780
+ ```
781
+
782
+ Each removed arm throws `INVALID_MEMORY_OPTIONS` and names its new spelling. They are removed and
783
+ not aliased. A sizing that the engine ignored in silence would build a world of the wrong
784
+ size. It would show that much later, as a limit error far from its cause.
785
+
786
+ | 0.5 | 0.6 |
787
+ | --- | --- |
788
+ | `{ budget: { entities: N } }` | `{ entities: N }` |
789
+ | `{ heap: { maxBytes: X } }` | `{ maxBytes: X, backing: "heap" }` |
790
+ | `{ shared: { maxBytes: X } }` | `{ maxBytes: X, backing: "shared" }` |
791
+ | `{ wasm: W }` | `{ backing: { wasm: W } }` |
792
+ | `{ allocator: A, capBytesHint: X }` | `{ maxBytes: X, backing: { allocator: A } }` |
793
+
794
+ `maxBytes` and `columnCapacity` keep their names and their meaning. The types `EntityBudget` and
795
+ `SharedMemoryArm` are removed. The three fields of a budget are now fields of `memory` itself. The
796
+ shared backing is the string `"shared"`. The new type `MemoryBacking` names the backing axis.
797
+ `ResolvedECSMemory.source` now names the backing alone, and the new field `sizing` names the size
798
+ axis.
799
+
800
+ ### Fixed. A custom allocator with a limit below about 12.6 MiB could not build a world
801
+
802
+ The `allocator` arm reserved the full identity space for the entity index, always, and ignored the
803
+ limit that `capBytesHint` declared. The reservation happens when the store is built. So the index
804
+ alone did not fit under a small limit. The world threw `STORE_CAP_EXCEEDED` before it existed.
805
+ The error then blamed the caller for runaway entity growth, in a world that held no entities. Every
806
+ other arm already sized the index from the limit.
807
+
808
+ The reservation of the entity index now comes from `entities` first, then the byte limit, then the
809
+ default. That holds for each backing equally.
810
+
811
+ ### Fixed. A declared number of entities now sizes the entity index on every backing
812
+
813
+ Only the `budget` arm derived the index from the entity count, and that arm forced the heap backing.
814
+ On each other backing the index was sized backwards from the byte limit. That reserves much more
815
+ than a small world needs. A count now reaches the index whichever backing holds the bytes.
816
+
817
+ ### Added. `fixedSabAllocator`, a shared buffer that does not grow
818
+
819
+ `fixedSabAllocator(maxBytes)`, from `@oasys/oecs/shared`, reserves one fixed `SharedArrayBuffer` at
820
+ the limit. It is `heapArrayBufferAllocator` with a `SharedArrayBuffer`. So the bytes stay shareable
821
+ with a worker or a WASM module, and the buffer never moves.
822
+
823
+ It exists for a measured reason. JavaScriptCore has no fast store path for a TypedArray view over a
824
+ growable `SharedArrayBuffer`. A column read costs what a fixed buffer costs. Every column write
825
+ costs far more. The cost is for each access and not for each byte, so a small world pays it too.
826
+ V8 shows no such difference. Safari and Bun are JavaScriptCore. A fixed buffer restores the fast
827
+ store path on both engine families and gives up only the growth.
828
+
829
+ `growableSabAllocator` and `wasmMemoryAllocator` now carry that warning in their own documentation.
830
+ A shared `WebAssembly.Memory` gives a growable `SharedArrayBuffer` and can give nothing else. So the
831
+ WASM backing should pay the same cost on JavaScriptCore. That last point is reasoning and not
832
+ measurement, and it is marked as such.
833
+
834
+ The default backing for `{ backing: "shared" }` is unchanged: it is still `growableSabAllocator`.
835
+
836
+ ### Fixed. The fixed-length rule for a column view is now stated and locked
837
+
838
+ Each column view is built with an explicit `(byteOffset, length)`. A TypedArray built with no length
839
+ argument tracks the length of its buffer. Measurement puts that shape far behind a fixed-length
840
+ view on every engine tested. `createView` is the only place that builds a column view, but nothing
841
+ said so and nothing tested a view's length. The rule is now in the `createView` documentation, and
842
+ two tests hold it. One walks every column of every archetype. One proves that a view keeps its
843
+ length when the buffer below it grows. The second matters most, because a length-tracking view
844
+ survives the identity and data checks that were already there.
845
+
846
+ ### Added. `ECS_ERROR.INVALID_TEMPLATE`
847
+
848
+ `spawn` and `spawnMany` take a template from `ecs.template(...)`. A component definition, a callable
849
+ bundle, the pre-0.5 array of entries, or some other value reached the store instead. The store then
850
+ failed with a `TypeError` about an internal field. That error named the wrong place, and it did not
851
+ say what to do. A development build now throws `INVALID_TEMPLATE`. It names the value the caller
852
+ gave, and it names the call to make in its place. `ecs.template` rejects the array of entries with
853
+ the same code. The types already reject all four shapes, so this catches an untyped call site. Both
854
+ checks are development only, and the production build is unchanged.
855
+
856
+ ### Added. `ECS_ERROR.SNAPSHOT_RESTORE_FAILED`, and `ECSRestoreError` is an `ECSError`
857
+
858
+ `ecs.snapshots.restore` threw an `ECSRestoreError` that extended `Error` and carried no code. So a
859
+ caller who sorts faults by `category` saw a restore failure fall through. `ECSRestoreError` now
860
+ extends `ECSError` with the category `SNAPSHOT_RESTORE_FAILED`, and `isEcsError` answers true for
861
+ it. The class, its name and the message are unchanged, so a `catch` by class or by `err.name`
862
+ still holds. `StoreRestoreError` and `SparseRestoreError` stay plain `Error` classes. The
863
+ module that declares them imports nothing, and `core/store` does not see the code enum.
864
+
865
+ ### Added. `InPlaceBufferAllocator` and `BufferAllocator` are type exports
866
+
867
+ `memory: { backing: { allocator } }` takes an `InPlaceBufferAllocator`. The type was declared in
868
+ `core/store` without a published entry that re-exports it. So a caller could not name the interface
869
+ a custom allocator implements. The root and `@oasys/oecs/shared` now export both types.
870
+
871
+ ### Fixed. A write was reported on two frames when the writer ran before the reader
872
+
873
+ `changed()` compared a per-frame tick with the last run of the reader. It took a stamp at or
874
+ after that run. A frame tick cannot order a writer and a reader inside one frame. So a write by an
875
+ earlier system was reported on that frame, and again on the next. Writer before reader is the usual
876
+ order, so the usual order reported every write twice. The same tick missed a host write between
877
+ frames at an archetype-level `onSet`. The observer's baseline was the next frame, which a host
878
+ write never reached.
879
+
880
+ The engine now keeps a change tick apart from the frame tick. It advances before each system run,
881
+ and before each phase flush. It also advances before the `onSet` dispatch, and at the end of each
882
+ update. A write stamps it, and a consumer reports a stamp above its own last run. One write is
883
+ reported one time at each grain, whichever system runs first. A host write between frames reaches
884
+ both grains on the next update. `ctx.ecsTick` still counts frames. A system no longer sees its own
885
+ stamp on its next run. A writer that also read `changed()` on the same component fired on every
886
+ frame. It now fires for the writes of other systems. It also fires for its own writes, inside the
887
+ run that made them.
888
+
889
+ `ecs.getCurrentTick()` is `ecs.getChangeTick()`, and the schedule's `runStartup`, `runUpdate` and
890
+ `runFixedUpdate` no longer take a tick.
891
+
892
+ ### Fixed. A `ref` or `cursor` write never reached an entity-level `onSet`
893
+
894
+ Only `setField`, `updateField` and `markChanged` recorded an entity for an `onSet` observer with
895
+ entity granularity. The change detection page said a `ref` write was seen. The accessor
896
+ setters write raw columns and cannot record. So `ctx.ref` records the entity when you create the
897
+ ref. A mutable cursor records it on each `at`, on the context and on the host. Both are
898
+ conservative, as the archetype stamp is. `refRead` and `cursorRead` record nothing. The entity
899
+ level drain also no longer allocates a list on each frame.
900
+
901
+ ### Added. `cols.ticks(def)`, the row record for an entity-level `onSet`
902
+
903
+ A raw column write in a chunk loop is invisible to the engine. So an `onSet` observer with entity
904
+ granularity needed `ctx.markChanged` for each row. That is a call and a list push. The loop can now
905
+ store the change tick into the row of `cols.ticks(def)`: `t[i] = cols.tick`. One typed-array store,
906
+ which costs about what the write beside it costs. The column exists only while an entity-level
907
+ `onSet` observer tracks the component, and the call throws `ROW_TICKS_NOT_TRACKED` otherwise.
908
+
909
+ ### Added. `ecs.trackRows(def)`, `cols.ticksRead(def)`, `cols.since` and `changed(def).forEachChunk`
910
+
911
+ Change detection at the row grain as a pull. `ecs.trackRows(def)` keeps one change tick for each
912
+ row of every archetype that holds `def`, stamped by every write path. A `ChangedQuery` now has
913
+ `forEachChunk`. Inside it, `cols.ticksRead(def)` is the row tick column. `cols.since` is the
914
+ change tick of the previous run of the system. So `t[i] > cols.since` picks the rows that changed
915
+ since that run. An `onSet` observer with entity granularity turns the row ticks on as well.
916
+
917
+ ### Added. Change detection for a sparse component
918
+
919
+ A sparse component had none. It now has the row grain. `ecs.trackRows(def)` keeps one change tick
920
+ for each entity index in the sparse store. `setSparseField` and `at` on the mutable sparse cursor
921
+ stamp it. An add zeroes it. `ctx.sparseChanged(def, entityId)` reads it as a pull, true for the
922
+ run after a write. `observe(def, { granularity: "entity", onSet })` reads it as a push. That is the
923
+ one observer shape a sparse component takes. It has no archetype, so no structural callback and no
924
+ archetype grain. `observe` names that in its error. A mutable sparse cursor's `at` pays one load
925
+ and one branch while the component keeps no row ticks.
926
+
927
+ ### Changed. A by-id record stops listing once the list outgrows a fraction of the live entities
928
+
929
+ An entity-level `onSet` drain paid a push, three checks and a sort slot for each recorded entity.
930
+ So a system that wrote most rows by id paid more than a scan of the rows would cost. Past a cap
931
+ set from the live entity count at each drain, a frame switches to the scan. The by-id record then
932
+ stamps the row and pushes nothing. The drain walks the plane of each archetype a writer stamped. A
933
+ `markChanged` record stamps no archetype, so it is listed still, and dropped when a scan covers it.
934
+
935
+ ### Fixed. The observer drain took a slow path on JavaScriptCore
936
+
937
+ The radix pass that orders a drain by entity index kept its scratch in a plain array. A length
938
+ assignment grew that array. JavaScriptCore turns such an array into a sparse store, and each
939
+ element store in the pass became a hash insert. The scratch is a typed array now, grown by
940
+ doubling. That holds in the observer registry, and in the hierarchy walk of the relation service.
941
+ V8 did not care either way.
942
+
943
+ ### Changed. The entity grain keeps a row tick, and not a dedup byte for each entity slot
944
+
945
+ An entity-level `onSet` observer used to allocate one byte for each entity index slot of the world.
946
+ It did that for each tracked component, whatever the live count. It now gives every archetype of
947
+ the component one word of ticks for each row. That word rides the row plane through every move.
948
+ The dirty list takes an entity one time per drain, by comparing that tick. A row carries its tick
949
+ across a transition, so the compare holds for a move. It does not hold when the entity leaves the
950
+ component and joins it again. The new row has no tick to carry. So the drain drops a repeated id
951
+ after it orders the list. The drain fires a row a chunk loop recorded, with no liveness check. A
952
+ row inside the enabled partition is alive, a member and enabled by construction. A row a
953
+ by-id path recorded is still checked. In a frame where a chunk loop took `cols.ticks(def)`, the
954
+ drain walks more rows. It walks each row of every archetype of the component that a writer stamped.
955
+ So take the column only in a loop that stores into it.
956
+
957
+ A tag keeps no row ticks. It has no field, so no write can record one, and `trackRows` on a tag
958
+ does nothing.
959
+
960
+ ### Fixed. `observe()` on a sparse component named "component undefined"
961
+
962
+ A sparse component and a relation have no observers and no change tick. `observe` now throws
963
+ `OBSERVER_INVALID_CONFIG` and names the remedy.
964
+
965
+ ## [0.5.4] - 2026-07-31
966
+
967
+ ### Added. `cursor` and `cursorRead`, the accessor for a sweep by id
968
+
969
+ `ecs.cursor(def)` and `ecs.cursorRead(def)`, with the two equivalents on `ctx`, give a single-entity
970
+ accessor that you can **move again**. You create it one time, and then you move it with
971
+ `at(entity)`.
972
+
973
+ ```ts
974
+ const p = ctx.cursor(Pos);
975
+ for (let i = 0; i < ids.length; i++) {
976
+ p.at(ids[i]);
977
+ p.x += p.y * dt;
978
+ }
979
+ ```
980
+
981
+ The engine creates a ref for each entity. Over a list of ids, the loop then discards each ref that
982
+ it created. A cursor lifts that creation out of the loop: `at` writes the archetype, the offset, and
983
+ the row, and nothing else. A cursor also resolves the position of each field when you create it, so
984
+ a read does not look up a field name.
985
+
986
+ A cursor is **safer than a ref that you hold**, and not more dangerous. `at` resolves the archetype
987
+ and the row again on each call. So a structural change between two `at` calls cannot make the cursor
988
+ read a different entity. A cursor also follows an entity that changes archetype, which a ref cannot
989
+ do. Only the window between one `at` call and the reads that follow it must be free of structural
990
+ change.
991
+
992
+ In a development build, a cursor makes its declared-access check on **each `at`**, and not only when
993
+ you create it. `cursor` makes a write check, and `cursorRead` makes a read check. The check is on
994
+ `at` because you keep a cursor, and a cursor can therefore outlive the system that made it. A cursor
995
+ that you make outside a system, or in a different system, is checked against the system that
996
+ **uses** it. An `at` outside every system makes no check, because no system can hold the fault. A
997
+ production build removes this check, as it removes each of the other development guards.
998
+
999
+ A cursor obeys the rules of the family: the definition comes first, the mutable name has no suffix,
1000
+ and the read-only name has the `Read` suffix. There is one constraint. A component with a field
1001
+ named `at` collides with the method of the cursor. Creation of the cursor rejects that component,
1002
+ and the message says so.
1003
+
1004
+ See [refs and cursors](docs/api/refs.md#cursors-many-entities-by-id).
1005
+
1006
+ ### Added. `ECS_ERROR.ARCHETYPE_ROW_INVARIANT`
1007
+
1008
+ This error reports that the row bookkeeping of an archetype does not agree with its backing columns.
1009
+ There are three causes. A reserve did not give the capacity that the engine asked for. A restore
1010
+ gave a partition boundary that is out of range. Or a cached row plane points at a buffer that is no
1011
+ longer current. The error is for development builds only. It reports a failure of an internal
1012
+ invariant, and not a mistake by the caller. That is what makes it different from
1013
+ `STORE_CAP_EXCEEDED`, which is the allocator that refuses a legitimate grow. Two development
1014
+ assertions that reported the general `COMPONENT_NOT_REGISTERED` now use it.
1015
+
1016
+ ### Changed. Structural churn, system dispatch, and fragmented iteration
1017
+
1018
+ No signature changed on a function that exists, and no result changed. Each entry below is a change
1019
+ to the internal mechanism. What moved:
1020
+
1021
+ - **The row plane of an archetype.** Row placement went through the `ColumnBacking` API (`push`,
1022
+ `swapRemove`, and `pop`). That API costs three things **for each column and for each row**: a call
1023
+ to the `.buf` accessor, a comparison against the capacity, and a load and store of `_len`. The
1024
+ actual work is one move of an element. But `Archetype.length` is already the row count of every
1025
+ column. So the archetype now indexes cached raw views (`_bufs[i][row]`), and it moves `length` one
1026
+ time. The probe for an overflow on an append becomes one comparison against a cached capacity.
1027
+ - **`eachChunk` no longer refreshes a column group on each call.** `cols.mut(def)` and
1028
+ `cols.read(def)` used one cached object for each (archetype, component) pair. But they wrote one
1029
+ property for each field on *every* call, and a fragmented pass makes that call one time for each
1030
+ chunk. Only `_syncRowPlane` can change the identity of the buffer of a column. So `_syncRowPlane`
1031
+ now points the cached groups at the current buffers, and the accessors make no test for a stale
1032
+ buffer.
1033
+ - **`readField` indexes the row plane.** It reads `_bufs[i][row]`, and not
1034
+ `_flatColumns[i].buf[row]`. This removes a `.buf` accessor whose concrete type is different for a
1035
+ heap column and for a `SharedArrayBuffer` column.
1036
+ - **The last-run ticks of the schedule.** `systemLastRun` was a `Map<SystemDescriptor, number>`, and
1037
+ a phase read it and wrote it one time for each system. It is now a packed array that a slot, local
1038
+ to the schedule, indexes. The slots travel in the cached phase plan, next to the sorted
1039
+ descriptors. `hasFixedSystems()` holds the node list of `FIXED_UPDATE` directly, and it makes no
1040
+ lookup by key for each frame.
1041
+ - **One probe of the edge for a single add or remove.** `addComponent` made four lookups before it
1042
+ touched a row: `mask.has`, `archResolveAdd`, which read both again, `archGet`, and then a second
1043
+ `getEdge` for the transition map. An `edge.add` value that is not null means exactly "this
1044
+ archetype does not hold the component, and the destination is resolved". So one probe of the holey
1045
+ `edges` array answers all of it. The first-sight case and the overwrite-in-place case move to a
1046
+ cold tail. `removeComponent` is the mirror image.
1047
+ - **One resolve of liveness and index, in place of two derivations.** `Store.hasComponent` called
1048
+ `isAlive`, which derived the packed entity index. It then derived that index a second time to
1049
+ reach `entityArchetype`. The read of the generations also went through a call into the allocator.
1050
+ Both now fold into one `_liveIndex`. `getEntityArchetype` and `getEntityRow` become one
1051
+ `resolveEntity`.
1052
+ - **`ecs.query(...)` allocates nothing when the cache holds the query.** The caller copied its
1053
+ scratch mask before it gave that mask to the resolver, and the resolver copies each mask that it
1054
+ keeps. That copy made a `BitSet` and a backing `number[]` for each call, for nothing. The contract
1055
+ for the borrowed mask is now written at the resolver.
1056
+ - **`clearEvents` returns immediately when the frame emitted no event.** A write of `length` on an
1057
+ array is a property store, and V8 does not remove that store for an array that is already empty.
1058
+ This ran one time for each `update()` call, and most phases emit no event.
1059
+
1060
+ ### Changed. Diagnostic vocabulary catch-up (the deferred snake_case remnants)
1061
+
1062
+ - **Breaking (diagnostics):** `ECS.memoryPlan.source` now reports `"maxBytes"` instead of
1063
+ `"max_bytes"` for the arm with the explicit byte cap. The name now agrees with the option key
1064
+ that it names. Every other arm already agreed: `budget`, `heap`, `shared`, `wasm`, `allocator`,
1065
+ and `default`.
1066
+ - The `INVALID_MEMORY_OPTIONS` messages and the `memoryPlan.derivation` trace now name the options
1067
+ by their real camelCase keys (`columnCapacity`, `entityIndex`, `budget.bytesPerEntity`,
1068
+ `wasm.maximumPages`, `wasm.initialPages`, and `capBytesHint`), and not by the snake_case
1069
+ spellings from before 0.4.
1070
+
1071
+ ### Fixed. Row-plane and schedule-slot correctness under the new caches
1072
+
1073
+ - **A grow that throws no longer leaves the row plane on a buffer that the engine released.** The
1074
+ reserve grows the entity-id array on the heap before it asks the store to grow the columns. So
1075
+ when a refusal of the `SharedArrayBuffer` cap threw out of the grow handler, the cached entity-id
1076
+ view pointed at a buffer that the engine had released, and the array itself had moved. The world
1077
+ must survive a refusal of the cap, because that refusal is the basis of the fail-closed `spawn`
1078
+ and `spawnMany` contract. But a later append that fitted the stale capacity wrote its entity id
1079
+ into the released buffer. The next re-sync then put in the buffer that never received that row. A
1080
+ re-sync occurs on a successful grow, and on the `refreshViews` call that each new archetype
1081
+ causes. The id then read back as `0`, and the swap-remove that followed corrupted the row pointer
1082
+ of a *different live entity*. The engine now derives the plane again on the path that throws.
1083
+ - **A shortfall of capacity in the entity-id array alone no longer causes a reallocation of the
1084
+ full store.** The cached row capacity is the smaller of two values: the capacity of the entity-id
1085
+ array, and the capacity of every column. But the *decision to grow* belongs to the column term
1086
+ alone. A decision on the smaller value sent a shortfall to the store when only the entity-id array
1087
+ had one, and that array had already grown. The store then calculated a capacity that did not
1088
+ change, and it found no column to resize. But it still did a full snapshot, create, and restore of
1089
+ the full column store, plus a `refreshViews` call on each archetype. It did all of that to resize
1090
+ nothing. This is reachable after a restore from a snapshot, because a restore grows the entity-id
1091
+ array to the *number* of restored rows, and not to its capacity.
1092
+ - **A recycled `systemLastRun` slot can no longer be the slot of two systems.** A phase copies the
1093
+ slot array of its plan into a local. So a system that you remove from inside that phase still runs
1094
+ from the snapshot, and it still writes its last-run tick as it ends. Before this fix, the engine
1095
+ could give that freed slot to a system that you added during the same drive. The write of the
1096
+ removed system then landed on the tick of the new system. That write moved the `changed()` window
1097
+ of the new system, and it gave no signal. The engine now recycles a slot only outside a running
1098
+ drive. Between drives the slots recycle as before, so the array stays bounded.
1099
+
1100
+ ### Note. The lookup of a field name stays as it is
1101
+
1102
+ `getField` resolves a field name through the `_fieldIndex[cid][field]` table. The engine builds that
1103
+ table with `Object.create(null)`, which puts it in dictionary mode. The investigation covered three
1104
+ replacements: a `{}` literal, a `Map`, and interning of the names with a perfect hash for each
1105
+ component. None of the three is better than the table that exists in a world that has many
1106
+ components with different field names. The table stays, and a comment next to it records the
1107
+ investigation. To remove the lookup, the caller must hold the ordinal of the field, and not its name.
1108
+ `cursor` does exactly that for a sweep. To do the measurement again, use
1109
+ `bench/vs/probe-fieldshape.mjs` and `bench/vs/probe-lookupcost.mjs`.
1110
+
1111
+ ### Docs
1112
+
1113
+ - `ARCHITECTURE.md` carries the 0.5.4 stamp. It now describes the row plane of an archetype, the
1114
+ packed last-run slots of the schedule, and the `ARCHETYPE_ROW_INVARIANT` assertion.
1115
+ - `ARCHITECTURE.md` gives **no line numbers**. A reference names its source file only, because a
1116
+ line number becomes incorrect as the source changes. To find a claim, search for the name of the
1117
+ symbol next to the reference.
1118
+ - The documentation gives no benchmark figures. Each entry describes what changed.
1119
+ - `ARCHITECTURE.md`, `README.md`, `api/memory.md`, `api/index.md`, and `BEST_PRACTICES.md` no
1120
+ longer describe the default heap backing as a growable or resizable `ArrayBuffer`. It is fixed at
1121
+ the cap, and has been since 0.5.3.
1122
+ - `api/refs.md` documents cursors, and `api/errors.md` documents
1123
+ `ARCHETYPE_ROW_INVARIANT`. The error count in `api/errors.md` is now 48.
1124
+
1125
+ ### Packaging
1126
+
1127
+ - `bench/` is no longer part of the JSR package. npm ships from a list of the files to include
1128
+ (`files: ["dist", "CHANGELOG.md"]`), so npm was never affected. But JSR publishes the source tree
1129
+ against a list of the files to exclude, and the local bench and oracle harnesses would have
1130
+ shipped as soon as git tracked them.
1131
+
1132
+ ## [0.5.3] - 2026-07-09
1133
+
1134
+ ### Fixed. Heap columns are again on the fast element-access path of V8
1135
+
1136
+ - The pure-TS **heap profile** (`heapArraybufferAllocator`, the default backing)
13
1137
  now reserves its store as a **fixed, non-resizable `ArrayBuffer`** at the full
14
- cap instead of a growable one resized via `.resize()`. V8 has no fast
15
- element-access path for TypedArray views over a **resizable/growable**
16
- `ArrayBuffer` every `col[i]` re-checks the mutable length — so iterating a
17
- column was ~4× slower than over a fixed buffer (measured on V8 13.6, an
18
- isolated `col[i] *= 2` loop: ~0.37G vs ~1.6G element-accesses/s). This
19
- silently regressed every iteration-bound system ~5× from 0.3.x (a
20
- cross-library bench had oecs at ~85k op/s on the `packed_5` scenario vs
21
- ~407k at 0.3.1); 0.5.3 restores it (~420k, measured against 0.5.2 in a
22
- same-toolchain A/B: 85.9k 422.7k op/s).
23
- - The fixed buffer faults pages in lazily, so RSS tracks real use, not the
24
- reservation (a 256 MiB default cap on a 1000-entity world stays a few MiB
25
- resident — measured ~4 MiB RSS, within ~1 MiB of the old resizable buffer).
1138
+ cap. Before this release it used a growable buffer, and it made that buffer
1139
+ larger with `.resize()`. V8 has no fast element-access path for a TypedArray
1140
+ view over a **resizable or growable** `ArrayBuffer`, because each `col[i]`
1141
+ reads the mutable length again. Thus a loop over a column was much slower than
1142
+ the same loop over a fixed buffer. This made each iteration-bound system slower
1143
+ from 0.3.x, and it gave no signal. Version 0.5.3 corrects the fault, and a
1144
+ cross-library bench gives the throughput of 0.3.1 again.
1145
+ - The fixed buffer faults pages in lazily. Thus RSS follows the real use, and not
1146
+ the reservation: a world with few entities keeps a small resident set at the
1147
+ 256 MiB default cap, which is equivalent to the old resizable buffer.
26
1148
  Growth remains in place: the store relocates columns within the pre-reserved
27
1149
  buffer, so the buffer identity never changes and every existing view stays
28
- valid
29
- `isInPlace: true` and ADR-0008's entity-index-hoist-across-grow invariant hold
1150
+ valid.
1151
+ `isInPlace: true` and the entity-index-hoist-across-grow invariant hold
30
1152
  unchanged. The store keys its tail cursor off the header `capacity` (the
31
1153
  logical high-water) rather than `buffer.byteLength` (now always the cap).
32
- - Only the heap backing changed. The `growable_sab` / `wasm_memory` backings
1154
+ - Only the heap backing changed. The `growable_sab` and `wasm_memory` backings
33
1155
  keep their resizable buffers and page-rounded tail layout byte-for-byte
34
- (their determinism/layout goldens are unchanged).
1156
+ (their determinism and layout goldens are unchanged).
35
1157
 
36
- ## [0.5.2] 2026-07-08
1158
+ ## [0.5.2] - 2026-07-08
37
1159
 
38
- ### Added a guards-on build and an explicit dev entry
1160
+ ### Added. A guards-on build and an explicit dev entry
39
1161
 
40
- - **`@oasys/oecs/dev`** the same public API as `@oasys/oecs` with the `__DEV__`
41
- guards left **on**, for a direct guards-on import (browser/CDN, quick debugging,
1162
+ - **`@oasys/oecs/dev`**, the same public API as `@oasys/oecs` with the `__DEV__`
1163
+ guards left **on**, for a direct guards-on import (browser or CDN, quick debugging,
42
1164
  or bundlers that don't auto-select conditions).
43
- - **`development` export condition** dev-mode bundlers (`vite dev`,
1165
+ - **`development` export condition**, dev-mode bundlers (`vite dev`,
44
1166
  `webpack --mode development`) now resolve `@oasys/oecs` (and every subpath) to a
45
- guards-on build automatically; production-mode builds resolve to the stripped
1167
+ guards-on build automatically. Production-mode builds resolve to the stripped
46
1168
  build as before.
47
- - npm now ships a **dual build** (`scripts/build.mjs`): the default `*.js`/`*.cjs`
1169
+ - npm now ships a **dual build** (`scripts/build.mjs`): the default `*.js` and `*.cjs`
48
1170
  are the stripped production artifacts (unchanged), alongside new guards-on
49
- `*.development.js`/`*.development.cjs`.
1171
+ `*.development.js` and `*.development.cjs`.
50
1172
  - New guide: [Development guards & production builds](docs/PRODUCTION.md).
51
1173
 
52
- ### Changed — dev-guard default is now production on JSR/Deno
1174
+ ### Changed. Dev-guard default is now production on JSR or Deno
53
1175
 
54
- - Raw-source (JSR/Deno) consumers now default to `__DEV__ = false` (**production
55
- guards off, no per-frame tax**), matching npm's default. Previously the raw path
1176
+ - Raw-source (JSR and Deno) consumers now default to `__DEV__ = false` (production, with the
1177
+ guards off and no cost in each frame), which matches the npm default. Previously the raw path
56
1178
  defaulted to guards-on. To enable the guards while developing on Deno, set
57
1179
  `globalThis.__DEV__ = true` **before the first import**. The `globalThis.__DEV__`
58
- override is unchanged; only the default flipped. (`dev_flag.ts`)
1180
+ override is unchanged. Only the default flipped. (`dev_flag.ts`)
59
1181
 
60
- ## [0.5.1] 2026-07-06
1182
+ ## [0.5.1] - 2026-07-06
61
1183
 
62
- ### Changed (breaking) one attach grammar
1184
+ ### Changed (breaking). One attach grammar
63
1185
 
64
1186
  `addComponents` and `template` now take the same callable-bundle varargs as `spawnBundle`
65
- and `ctx.commands.spawn` / `add`, replacing the `{ def, values }[]` entry-object array one
1187
+ and `ctx.commands.spawn` and `add`, replacing the `{ def, values }[]` entry-object array, one
66
1188
  grammar across every authoring surface:
67
1189
 
68
1190
  ```ts
@@ -78,17 +1200,17 @@ To migrate: drop the array brackets, wrap a valued entry in its def's call
78
1200
  (`{ def: X, values: V }` → `X(V)`), and leave a bare entry bare (`{ def: X }` → `X`).
79
1201
 
80
1202
  - Each item is schema-checked against its **own** def via the `StrictBundles` mapped tuple
81
- (`{ [K in keyof Items]: … }`) a misspelled or cross-component field, including a
1203
+ (`{ [K in keyof Items]: … }`), a misspelled or cross-component field, including a
82
1204
  hand-written raw `{ def, values }` literal, is a compile error. `spawnBundle` gains this
83
1205
  per-item checking (it previously had none).
84
- - `ctx.commands.spawn` / `ctx.commands.add` now schema-check their bundle values in
1206
+ - `ctx.commands.spawn` and `ctx.commands.add` now schema-check their bundle values in
85
1207
  declared-access systems as well (the `DeclaredBundleOrDef` type distributes over the
86
- declared add set); a permissive / `exclusive` context stays loose, as before.
87
- - The `TemplateEntry` / `TemplateEntries` public types are removed (they encoded the retired
88
- entry-object grammar). The host command seam (`HostCommandQueue.spawn` the record/replay
1208
+ declared add set). A permissive context and an `exclusive` context stay loose, as before.
1209
+ - The `TemplateEntry` and `TemplateEntries` public types are removed (they encoded the retired
1210
+ entry-object grammar). The host command seam (`HostCommandQueue.spawn`, the record and replay
89
1211
  and editor-undo transport) deliberately keeps its entry-object + complete-values shape.
90
1212
 
91
- ### Changed API vocabulary consistency
1213
+ ### Changed. API vocabulary consistency
92
1214
 
93
1215
  Cheap alignments from a public-API vocabulary audit that followed the grammar unification:
94
1216
 
@@ -97,100 +1219,101 @@ Cheap alignments from a public-API vocabulary audit that followed the grammar un
97
1219
  - `HostCommandQueue.pending()` is now a `pending` getter (matching every other count accessor).
98
1220
  - `ReadonlyEntityIdArray` → `ReadonlyEntityIDArray` (acronym casing, matching `EntityID`).
99
1221
  - The entity-id parameter is now uniformly `entityId` across the core surface (ECS lifecycle,
100
- host-command queue, `ObserverFn`); the `HostCommand` wire-format field stays `eid`.
1222
+ host-command queue, `ObserverFn`). The `HostCommand` wire-format field stays `eid`.
101
1223
  - Source-compatible widenings: `ecs.despawn`, `ecs.removeSystem`, and the `HostCommandQueue`
102
1224
  mutators now return `this` for chaining.
103
1225
 
104
- The `ref` / `refRead` argument order was reviewed and **deliberately kept** def-first
105
- (`ctx.ref(def, entityId)`): these are the outside-iteration members of the `cols.mut` /
1226
+ The `ref` and `refRead` argument order was reviewed and **deliberately kept** def-first
1227
+ (`ctx.ref(def, entityId)`): these are the outside-iteration members of the `cols.mut` and
106
1228
  `cols.read` column-cursor family, so def-first is the cursor convention, not an inconsistency
107
- to fix flipping it would align with `getField` while breaking alignment with `cols.mut`.
1229
+ to fix, flipping it would align with `getField` while breaking alignment with `cols.mut`.
108
1230
  Documented as such (`refs.md`, `queries.md`) rather than flipped.
109
1231
 
110
- ### Changed (breaking) — host-write-seam verb grammar
1232
+ ### Changed (breaking). Host-write-seam verb grammar
111
1233
 
112
1234
  The host-write-seam handles are namespaced command buffers, so they drop the component noun
113
- to match `ctx.commands.add` / `remove` and their own already-bare
114
- `spawn`/`despawn`/`disable`/`enable`/`setField`:
1235
+ to match `ctx.commands.add` and `remove`, and their own already-bare
1236
+ `spawn`, `despawn`, `disable`, `enable` and `setField`:
115
1237
 
116
- - `HostCommandQueue.addComponent` / `removeComponent` → `add` / `remove`.
117
- - `Editor` and `TransactionBuilder` `.addComponent` / `.removeComponent` → `add` / `remove`
1238
+ - `HostCommandQueue.addComponent` and `removeComponent` → `add` and `remove`.
1239
+ - `Editor` and `TransactionBuilder` `.addComponent` and `.removeComponent` → `add` and `remove`
118
1240
  (the two surfaces are designed to match, so they move together).
119
1241
  - The editor extension's entity-id parameters and its `FieldReader` type now read `entityId`,
120
1242
  completing the core's `eid` → `entityId` pass.
121
1243
 
122
- The wire-format `kind` discriminants (`"add_component"` / `"remove_component"`), the ring
123
- codecs, and the `HostCommand` record's `eid` field are unchanged transport vocabulary.
1244
+ The wire-format `kind` discriminants (`"add_component"` and `"remove_component"`), the ring
1245
+ codecs, and the `HostCommand` record's `eid` field are unchanged, transport vocabulary.
124
1246
 
125
- ### Changed (breaking) `ctx.getResource`
1247
+ ### Changed (breaking). `ctx.getResource`
126
1248
 
127
1249
  The in-system resource getter is now `ctx.getResource(key)` (was the verb-less `ctx.resource(key)`),
128
- matching its flat-surface siblings `setResource` / `removeResource` / `hasResource` and the
129
- `getField` / `setField` / `hasComponent` convention. The rule is now explicit: the flat `ctx`
130
- surface verbs every accessor; the grouped `ecs.resources` facade drops the noun (`get` / `set` /
131
- `remove` / `has`) because its receiver already names it. `ConditionContext` (run-condition
1250
+ matching its flat-surface siblings `setResource`, `removeResource` and `hasResource` and the
1251
+ `getField`, `setField` and `hasComponent` convention. The rule is now explicit: the flat `ctx`
1252
+ surface verbs every accessor. The grouped `ecs.resources` facade drops the noun (`get`, `set`,
1253
+ `remove` and `has`) because its receiver already names it. `ConditionContext` (run-condition
132
1254
  predicates) moves in lockstep.
133
1255
 
134
1256
  ### Fixed
135
1257
 
136
- - The immediate host spawn family (`spawn` / `spawnBundle` / `spawnMany`) now throws in DEV
137
- when called from inside a system body redirecting to `ctx.commands.spawn` like every
1258
+ - The immediate host spawn family (`spawn`, `spawnBundle` and `spawnMany`) now throws in DEV
1259
+ when called from inside a system body, redirecting to `ctx.commands.spawn`, like every
138
1260
  other immediate host structural mutator. Previously it was silently unguarded (the archetype
139
- iteration guard does not cover the append path), a live mid-iteration footgun; its guard
1261
+ iteration guard does not cover the append path), a live mid-iteration footgun. Its guard
140
1262
  docstring's "one rule for every host mutator" claim is now true.
141
1263
  - Added explicit public `QueryCache` cache-map type annotations so JSR publish passes
142
1264
  slow-type validation and can generate package declarations cleanly.
143
1265
 
144
- ## [0.5.0] 2026-07-06
1266
+ ## [0.5.0] - 2026-07-06
145
1267
 
146
- ### Changed (breaking) lifecycle & naming unification
1268
+ ### Changed (breaking). Lifecycle & naming unification
147
1269
 
148
- One vocabulary across host, commands, and access declarations; the receiver now implies the
149
- timing (host = immediate, `ctx.commands` = deferred). Hard renames, no deprecation aliases
150
- see [docs/MIGRATION-0.4-to-0.5.md](docs/MIGRATION-0.4-to-0.5.md) for the complete
151
- rename/removal map:
1270
+ One vocabulary across host, commands, and access declarations. The receiver now implies the
1271
+ timing (host = immediate, `ctx.commands` = deferred). Hard renames, no deprecation aliases.
1272
+ See [docs/MIGRATION-0.4-to-0.5.md](docs/MIGRATION-0.4-to-0.5.md) for the complete map of the
1273
+ renames and the removals:
152
1274
 
153
1275
  | 0.4 | 0.5 |
154
1276
  | --- | --- |
155
- | `ecs.createEntity()` / `ecs.createEntity(template, overrides?)` | `ecs.spawn()` / `ecs.spawn(template, overrides?)` |
1277
+ | `ecs.createEntity()` and `ecs.createEntity(template, overrides?)` | `ecs.spawn()` and `ecs.spawn(template, overrides?)` |
156
1278
  | `ecs.createEntities(template, count)` | `ecs.spawnMany(template, count, overrides?)` |
157
- | `ecs.destroyEntity(e)` *(deferred)* | `ecs.despawn(e)` **now immediate** |
1279
+ | `ecs.destroyEntity(e)` *(deferred)* | `ecs.despawn(e)`, **now immediate** |
158
1280
  | `ctx.createEntity()` | `ctx.commands.spawn()` |
159
1281
  | `ctx.destroyEntity(e)` | `ctx.commands.despawn(e)` |
160
1282
  | `ctx.addComponent(e, def, values?)` | `ctx.commands.add(e, def, values)` or `ctx.commands.add(e, def({ … }))` |
161
1283
  | `ctx.removeComponent(e, def)` | `ctx.commands.remove(e, def)` |
162
- | `ctx.disable(e)` / `ctx.enable(e)` | `ctx.commands.disable(e)` / `ctx.commands.enable(e)` |
163
- | `sourcesOf(def, tgt)` | `sourcesOf(tgt, def)` matches `targetOf` / `targetsOf` |
1284
+ | `ctx.disable(e)` and `ctx.enable(e)` | `ctx.commands.disable(e)` and `ctx.commands.enable(e)` |
1285
+ | `sourcesOf(def, tgt)` | `sourcesOf(tgt, def)`, matches `targetOf` and `targetsOf` |
164
1286
  | `query.count()` | `query.entityCount` (getter, beside `archetypeCount`) |
165
- | `WorldRestoreError` / `WORLD_SNAPSHOT_VERSION` | `ECSRestoreError` / `ECS_SNAPSHOT_VERSION` |
1287
+ | `WorldRestoreError` and `WORLD_SNAPSHOT_VERSION` | `ECSRestoreError` and `ECS_SNAPSHOT_VERSION` |
166
1288
 
167
- - **Host `despawn` is immediate** `ecs.despawn(e); ecs.isAlive(e)` is `false` on the next
168
- line, closing the audit's M1 finding (host `addComponent` immediate but destroy buffered).
169
- **Observer note:** like every immediate op, host `despawn` fires no *structural* observers —
1289
+ - **Host `despawn` is immediate**, `ecs.despawn(e); ecs.isAlive(e)` is `false` on the next
1290
+ line. This removes the inconsistency: host `addComponent` was immediate, but destroy was
1291
+ buffered.
1292
+ **Observer note:** like every immediate op, host `despawn` fires no *structural* observers.
170
1293
  `onRemove` no longer sees host-despawned entities (it did at 0.4, when host destroy was
171
1294
  deferred). Observer-driven consumers, including the `reactive-sync` map bridges, only see
172
1295
  despawns that go through `ctx.commands.despawn` or the host-command seam. (`onSet` is
173
- receiver-blind derived change detection sees host `setField` writes as always.)
1296
+ receiver-blind, derived change detection sees host `setField` writes as always.)
174
1297
  - **Every immediate host structural mutator throws in dev when called from inside a system
175
- body** `despawn`, `addComponent`/`addComponents`, `removeComponent`/`removeComponents`,
176
- `batchAddComponent`/`batchRemoveComponent`, `disable`/`enable` each error pointing at its
1298
+ body**, `despawn`, `addComponent` and `addComponents`, `removeComponent` and `removeComponents`,
1299
+ `batchAddComponent` and `batchRemoveComponent`, `disable` and `enable`, each error pointing at its
177
1300
  `ctx.commands` equivalent. Mid-system these ops can move rows a running query is walking and
178
- are invisible to observers; previously only `despawn` was guarded wholesale (the others were
1301
+ are invisible to observers. Previously only `despawn` was guarded wholesale (the others were
179
1302
  caught only when they touched the archetype being iterated). Cross-world host mutation from
180
- another world's system (#785) is unaffected the guard is scoped to the mutated world.
181
- - **The bare deferred duplicates on `ctx` are removed** `ctx.addComponent`,
1303
+ another world's system is unaffected, the guard is scoped to the mutated world.
1304
+ - **The bare deferred duplicates on `ctx` are removed**, `ctx.addComponent`,
182
1305
  `ctx.removeComponent`, `ctx.disable`, `ctx.enable` join the already-removed
183
- `ctx.createEntity` / `ctx.destroyEntity`. `ctx.commands` is now the *only* deferred surface,
1306
+ `ctx.createEntity` and `ctx.destroyEntity`. `ctx.commands` is now the *only* deferred surface,
184
1307
  completing the receiver-implies-timing rule with zero exceptions. `ctx.commands.add` gains
185
1308
  the explicit complete-values shape (`ctx.commands.add(e, Pos, { x: 0, y: 0 })`) the removed
186
1309
  `ctx.addComponent` carried, so compile-checked complete attaches survive the move.
187
- `ctx.isDisabled` stays (immediate read), as do the immediate sparse/relation ops.
188
- - **`sourcesOf` canonicalized to `(entity, def)`** on `ecs.relations` and `SystemContext` —
189
- it was the one arg-order outlier on the relation surface (M3).
190
- - **The package root is now a curated, explicit export list** `export *` no longer flattens the
1310
+ `ctx.isDisabled` stays (immediate read), as do the immediate sparse and relation ops.
1311
+ - **`sourcesOf` canonicalized to `(entity, def)`** on `ecs.relations` and `SystemContext`,
1312
+ It was the one arg-order outlier on the relation surface.
1313
+ - **The package root is now a curated, explicit export list**, `export *` no longer flattens the
191
1314
  whole core barrel, so future barrel additions cannot silently widen the public API. A checked-in
192
1315
  public-API snapshot test makes any surface change an explicit diff in review.
193
- - **Internal/tooling symbols moved to `@oasys/oecs/internal`** (explicitly **unstable no semver
1316
+ - **Internal and tooling symbols moved to `@oasys/oecs/internal`** (explicitly **unstable, no semver
194
1317
  guarantees**): the packed-EntityID codec (`createEntityId`, `getEntityGeneration`, `MAX_INDEX`,
195
1318
  `MAX_GENERATION`, `MAX_LIVE_GENERATION`, `RETIRED_GENERATION`, `MAX_ENTITY_ID`), the SAB
196
1319
  command-ring transport (`HostCommandDispatcher`, `ring*Codec`, `HOST_COMMAND_PAYLOAD_BYTES`),
@@ -199,150 +1322,150 @@ rename/removal map:
199
1322
 
200
1323
  ### Added
201
1324
 
202
- - **`addComponent` bundle overload** `ecs.addComponent(e, Pos({ x: 1 }))` accepts a bundle
203
- with the usual zero-fill semantics (M2); the explicit `(e, def, values)` form stays
1325
+ - **`addComponent` bundle overload**, `ecs.addComponent(e, Pos({ x: 1 }))` accepts a bundle
1326
+ with the usual zero-fill semantics. The explicit `(e, def, values)` form stays
204
1327
  complete-values, so a typo'd or missing field is still a compile error.
205
- - **`spawnMany` typed template + shared overrides** bulk spawn takes the same typed
1328
+ - **`spawnMany` typed template + shared overrides**, bulk spawn takes the same typed
206
1329
  `Template<Defs>` as `spawn` plus one optional `TemplateOverrides<Defs>` object applied to
207
1330
  every row (contiguous batches use one `fill` per overridden column).
208
- - **JSDoc `@example` on the core surface** `registerComponent`, `spawn`, `addComponent`,
209
- `query`, `registerSystem`, `startup`, `update`, `ctx.emit` / `ctx.read`,
210
- `events.register`, `resources.register` now carry hover-visible examples (M23).
211
- - **Component debug names** `registerComponent(schema, { name: "Pos" })` (and the sparse
1331
+ - **JSDoc `@example` on the core surface**, `registerComponent`, `spawn`, `addComponent`,
1332
+ `query`, `registerSystem`, `startup`, `update`, `ctx.emit` and `ctx.read`,
1333
+ `events.register`, `resources.register` now carry hover-visible examples.
1334
+ - **Component debug names**, `registerComponent(schema, { name: "Pos" })` (and the sparse
212
1335
  sibling) records a diagnostic label, so access-violation and liveness errors read
213
1336
  `'Pos' (component 5)` instead of leaving you to count registration order
214
1337
  (`ComponentRegisterOptions`).
215
- - **Total probes + `tryGetField`** `hasComponent` / `hasSparse` / `relations.has` now return
1338
+ - **Total probes + `tryGetField`**, `hasComponent`, `hasSparse` and `relations.has` now return
216
1339
  `false` for a dead entity instead of dev-throwing (a "has" probe is exactly the call made to
217
- avoid dead entities); `ecs.tryGetField(e, def, field)` returns `undefined` for a dead entity or
1340
+ avoid dead entities). `ecs.tryGetField(e, def, field)` returns `undefined` for a dead entity or
218
1341
  missing component, and `ctx.tryGetField` mirrors it inside systems (declared-read checked).
219
- - **Plural host mutators chain** `addComponents`, `removeComponents`, `batchAddComponent`,
1342
+ - **Plural host mutators chain**, `addComponents`, `removeComponents`, `batchAddComponent`,
220
1343
  `batchRemoveComponent` return `this` (previously `void`), matching their singular siblings.
221
- - **`Query.firstEntity()` / `Query.singleEntity()`** singleton reads (player, camera) without a
222
- hand-rolled `forEach` + capture; `singleEntity` dev-throws `QUERY_NOT_SINGLETON` on 0 or >1.
223
- - **Host-side `ecs.refRead(def, e)`** whole-component read-only view, parity with
1344
+ - **`Query.firstEntity()` and `Query.singleEntity()`**, singleton reads (player, camera) without a
1345
+ hand-rolled `forEach` + capture. `singleEntity` dev-throws `QUERY_NOT_SINGLETON` on 0 or >1.
1346
+ - **Host-side `ecs.refRead(def, e)`**, whole-component read-only view, parity with
224
1347
  `ctx.refRead`.
225
- - **Run-condition combinators** `not()` / `allOf()` / `anyOf()`, merging the operands' declared
1348
+ - **Run-condition combinators**, `not()`, `allOf()` and `anyOf()`, merging the operands' declared
226
1349
  read surfaces.
227
- - **Editor change notification** `editor.onChange(cb)` (fires on commit/undo/redo/clear) plus
228
- `canUndo` / `canRedo` getters; no more per-frame `depths()` polling.
229
- - **`using` support** `ObserverHandle` implements `Symbol.dispose`.
230
- - **Write-seam lifecycle** `uninstallHostCommandSeam(world, queue)`,
1350
+ - **Editor change notification**, `editor.onChange(cb)` (fires on commit, undo, redo and clear) plus
1351
+ `canUndo` and `canRedo` getters. No more per-frame `depths()` polling.
1352
+ - **`using` support**, `ObserverHandle` implements `Symbol.dispose`.
1353
+ - **Write-seam lifecycle**, `uninstallHostCommandSeam(world, queue)`,
231
1354
  `HostCommandQueue.clear()`, `HostCommandDispatcher.off(opCode)`,
232
1355
  `HostCommandRecorder.snapshotLog()` (stable deep copy).
233
- - **`VERSION`** export and a `"./package.json"` export; `engines: { node: ">=20" }` and a README
1356
+ - **`VERSION`** export and a `"./package.json"` export. `engines: { node: ">=20" }` and a README
234
1357
  runtime note (resizable `ArrayBuffer`).
235
1358
  - Root re-exports so failure modes are nameable without extra entry points:
236
- `StoreRestoreError`, `SabUnavailableError`, `TypedArrayTag`; `/reactive` now exports `Eq` and
237
- `shallow` (moved from `/reactive-sync`, which re-exports for compat); `signal()` gains the
238
- zero-arg Solid-parity overload; `SingletonSyncOptions.eq`.
239
- - **`FrameStepper`** optional host-side driver over the authoritative `ecs.update(dt)`:
240
- `play()`/`pause()`/`toggle()` on `requestAnimationFrame` (injectable `requestFrame`/`cancelFrame`
241
- for tests and non-browser hosts), explicit `step()`/`stepFrames()` for debuggers, editors, and
1359
+ `StoreRestoreError`, `SabUnavailableError`, `TypedArrayTag`. `/reactive` now exports `Eq` and
1360
+ `shallow` (moved from `/reactive-sync`, which re-exports for compat). `signal()` gains the
1361
+ zero-arg Solid-parity overload. `SingletonSyncOptions.eq`.
1362
+ - **`FrameStepper`**, optional host-side driver over the authoritative `ecs.update(dt)`:
1363
+ `play()`, `pause()` and `toggle()` on `requestAnimationFrame` (injectable `requestFrame` and `cancelFrame`
1364
+ for tests and non-browser hosts), explicit `step()` and `stepFrames()` for debuggers, editors, and
242
1365
  rollback playback, and a `maxDt` clamp (default 0.25 s) so a resumed background tab doesn't feed
243
1366
  the whole suspension into the accumulator as one delta. Validation throws `INVALID_FRAME_STEP`.
244
- - **`ObserverConfig.name`** diagnostic label surfaced as the frame trace's
245
- `observer_fired.observer` field (the role a system's `name` plays); observe-only, never affects
1367
+ - **`ObserverConfig.name`**, diagnostic label surfaced as the frame trace's
1368
+ `observer_fired.observer` field (the role a system's `name` plays). Observe-only, never affects
246
1369
  `stateHash` or dispatch order. Unnamed observers fall back to `observer(<component debug name>)`
247
1370
  when the component was registered with a name, else `observer(<cid>)`.
248
- - **`ECSOptions.onWarn`** injectable sink for dev-mode engine diagnostics (currently the
1371
+ - **`ECSOptions.onWarn`**, injectable sink for dev-mode engine diagnostics (currently the
249
1372
  schedule's dropped-ordering-edge warning and the `ECSOptions` unknown-key warning),
250
1373
  defaulting to `console.warn`. Replaces the internal `src/log` singleton, which is deleted.
251
- - **Editor `fieldHandle` `read` thunk is optional** defaults to `Editor.committedField`.
1374
+ - **Editor `fieldHandle` `read` thunk is optional**, defaults to `Editor.committedField`.
252
1375
 
253
1376
  ### Fixed
254
1377
 
255
- - **Host iteration guard (`STRUCTURAL_DURING_ITERATION`)** with host `despawn` now immediate,
256
- a host-side `forEach`/`eachChunk` callback that despawned (or transitioned/toggled) an entity of
1378
+ - **Host iteration guard (`STRUCTURAL_DURING_ITERATION`)**, with host `despawn` now immediate,
1379
+ a host-side `forEach` and `eachChunk` callback that despawned (or transitioned and toggled) an entity of
257
1380
  the archetype it was visiting silently skipped entities via the row swap-remove. Row-removing
258
1381
  ops on an archetype a live dense walk is standing in now throw in dev, *before* any mutation
259
1382
  lands (the transition path checks ahead of the destination append, so no dual-residency
260
1383
  half-state). Collect ids during the walk and mutate after it. Mutating archetypes the walk is
261
- *not* currently visiting stays legal the #431 fresh-snapshot machinery still covers those.
262
- - **Cross-world despawn false positive** `worldB.despawn(e)` from inside world A's system no
263
- longer trips the in-system despawn guard (the accessCheck span is process-global; the guard now
264
- also requires *this* world to be mid-schedule). Driving a second world from a system (#785)
265
- mutates it host-style, which is safe B is not iterating. Unnamed systems in the guard message
1384
+ *not* currently visiting stays legal, the fresh-snapshot machinery still covers those.
1385
+ - **Cross-world despawn false positive**, `worldB.despawn(e)` from inside world A's system no
1386
+ longer trips the in-system despawn guard (the accessCheck span is process-global, the guard now
1387
+ also requires *this* world to be mid-schedule). Driving a second world from a system
1388
+ mutates it host-style, which is safe. B is not iterating. Unnamed systems in the guard message
266
1389
  now render as `system_<id>` instead of `'?'`.
267
- - **Frame trace records every deferred command (ADR-0030)** — the removed bare `ctx.*` deferred
268
- forms bypassed the `commandQueued` trace hook, so host-command-seam adds/removes/toggles (and
1390
+ - **Frame trace records every deferred command**, the removed bare `ctx.*` deferred
1391
+ forms bypassed the `commandQueued` trace hook, so host-command-seam adds, removes and toggles (and
269
1392
  any system using the bare forms) were invisible to an attached `FrameTraceSink` while their
270
- spawns/despawns were visible. With `ctx.commands` as the only deferred surface every queued
1393
+ spawns and despawns were visible. With `ctx.commands` as the only deferred surface every queued
271
1394
  command is traced, and `ctx.commands.spawn` now also traces each bundle attach it queues
272
1395
  (previously only the spawn itself).
273
- - **Stale deferred-attach docs** `host_commands.ts` / the host-write-seam page claimed the
274
- deferred add path does not zero-fill omitted fields (NaN readback); every attach path
275
- zero-fills since #716 (`writeFields`'s `?? 0`). The complete-values requirement on
276
- `SpawnEntry` is documented as what it is explicit intent in a reified, replayable record
277
- and the observer docs now scope "immediate ops fire no observers" to *structural* observers
1396
+ - **Stale deferred-attach docs**, `host_commands.ts` and the host-write-seam page claimed the
1397
+ deferred add path does not zero-fill omitted fields (NaN readback). Every attach path
1398
+ zero-fills (`writeFields`'s `?? 0`). The complete-values requirement on
1399
+ `SpawnEntry` is documented as what it is, explicit intent in a reified, replayable record.
1400
+ The observer docs now scope "immediate ops fire no observers" to *structural* observers
278
1401
  (`onSet` is derived change detection and sees host `setField` writes).
279
- - **`ecs.refRead` / `ctx.ref` / `ctx.refRead` on a missing component or tag def** threw a raw
280
- `TypeError` from the ref internals; now a dev `ECSError` (`COMPONENT_NOT_REGISTERED`) naming the
1402
+ - **`ecs.refRead`, `ctx.ref` and `ctx.refRead` on a missing component or tag def**, threw a raw
1403
+ `TypeError` from the ref internals. Now a dev `ECSError` (`COMPONENT_NOT_REGISTERED`) naming the
281
1404
  op and component, matching `getField`. Host `refRead`'s docstring now states the single-
282
1405
  expression lifetime rule (any immediate structural mutation can row-swap under a held ref).
283
- - **Editor: aborted transactions no longer poison undo** `transaction(tx => …)` staged its
1406
+ - **Editor: aborted transactions no longer poison undo**, `transaction(tx => …)` staged its
284
1407
  `setField` shadow writes into the editor's shared map at build time, so a build callback that
285
1408
  threw left phantom pending values behind and seeded the *next* edit's undo inverse with a value
286
1409
  the world never held. Staging is now transaction-local and merges only on commit.
287
- - **Editor: `pendingField` self-resolves for dead slots** a shadow entry for a despawned entity
288
- (or removed component) echoed its stale value forever and leaked; the reconcile-on-read now
1410
+ - **Editor: `pendingField` self-resolves for dead slots**, a shadow entry for a despawned entity
1411
+ (or removed component) echoed its stale value forever and leaked. The reconcile-on-read now
289
1412
  prunes it and returns `undefined`.
290
- - **JSR/Deno consumers no longer break on the `__DEV__` global** shipped source now reads a
1413
+ - **JSR or Deno consumers no longer break on the `__DEV__` global**, shipped source now reads a
291
1414
  guarded `DEV` flag (`src/dev_flag.ts`) that constant-folds in the npm bundle and defaults to
292
1415
  dev-on for raw-source consumers (`globalThis.__DEV__ = false` opts out).
293
- - **Error experience** every `ENTITY_NOT_ALIVE` names the operation and decodes the packed id
294
- (index + generation, with context); system access violations use the new `ACCESS_UNDECLARED`
295
- category instead of overloading `*_NOT_REGISTERED`; resource/event "not registered" messages
296
- name the key and hint the registration call; messages no longer reference pre-0.4 snake_case
1416
+ - **Error experience**, every `ENTITY_NOT_ALIVE` names the operation and decodes the packed id
1417
+ (index + generation, with context). System access violations use the new `ACCESS_UNDECLARED`
1418
+ category instead of overloading `*_NOT_REGISTERED`. Resource and event "not registered" messages
1419
+ name the key and hint the registration call. Messages no longer reference pre-0.4 snake_case
297
1420
  option names or private tracker issue numbers.
298
- - **Packaging** per-entry `.d.cts` and explicit-extension declaration specifiers
1421
+ - **Packaging**, per-entry `.d.cts` and explicit-extension declaration specifiers
299
1422
  (`attw --pack` fully green: node10/node16/bundler across all eight entry points, was
300
- masquerading + resolution errors); `typesVersions` for `moduleResolution: node10` subpaths; npm
301
- tarball ships `CHANGELOG.md`; `@internal` editor internals no longer leak into published types.
302
- - **Type-level closures** `EventShape<S>` homomorphic bound (interface-declared event schemas
303
- now accepted); `RelationOptions` is a union so `{ exclusive: true, multi: true }` is a compile
304
- error; `ResourceKey`'s phantom is a unique symbol (no `.__phantom` in autocomplete);
305
- `pairsOf` / `sourcesOfAny` return readonly tuples; `SystemConfig.fn` optional when
1423
+ masquerading + resolution errors). `typesVersions` for `moduleResolution: node10` subpaths. Npm
1424
+ tarball ships `CHANGELOG.md`. `@internal` editor internals no longer leak into published types.
1425
+ - **Type-level closures**, `EventShape<S>` homomorphic bound (interface-declared event schemas
1426
+ now accepted). `RelationOptions` is a union so `{ exclusive: true, multi: true }` is a compile
1427
+ error. `ResourceKey`'s phantom is a unique symbol (no `.__phantom` in autocomplete)
1428
+ `pairsOf` and `sourcesOfAny` return readonly tuples. `SystemConfig.fn` optional when
306
1429
  `backendHandle` is present.
307
- - Dev-mode diagnostics: ownerless `computed()` / `onCleanup()` warn (kernel); ECSOptions warns on
308
- unknown keys; `runIfResourceEq` warns on object-valued `expected` (reference-identity `===`);
1430
+ - Dev-mode diagnostics: ownerless `computed()` and `onCleanup()` warn (kernel). ECSOptions warns on
1431
+ unknown keys. `runIfResourceEq` warns on object-valued `expected` (reference-identity `===`)
309
1432
  `runEveryNTicks` validation throws `ECSError` (`INVALID_RUN_CONDITION`).
310
- - **Docs standardized on the `ecs` receiver** README, GETTING_STARTED, BEST_PRACTICES, the
1433
+ - **Docs standardized on the `ecs` receiver**. README, GETTING_STARTED, BEST_PRACTICES, the
311
1434
  api reference, and every in-source JSDoc example now spell `const ecs = new ECS()`
312
- (M22; with the `World*` names renamed to `ECS*`, "world" survives only as prose). The
1435
+ (with the `World*` names renamed to `ECS*`, "world" survives only as prose). The
313
1436
  host-write-seam docs now explain *why* `queue.spawn` takes complete-value `spawnEntry`s
314
- rather than zero-filling bundles (M4: commands are a reified, replayable record complete
315
- values are explicit intent legible to replay, not a correctness need; the deferred add path
316
- zero-fills omitted fields since #716).
1437
+ rather than zero-filling bundles: commands are a reified, replayable record, complete
1438
+ values are explicit intent legible to replay, not a correctness need. The deferred add path
1439
+ zero-fills omitted fields.
317
1440
  - **JSR publish no longer ships `__tests__` helper files** (`casing_codemod.ts`,
318
- `test_helpers.ts` including a `node:fs` import subject to JSR type-checking).
1441
+ `test_helpers.ts`, including a `node:fs` import subject to JSR type-checking).
319
1442
 
320
- ### Changed (breaking) — type-level & facade surface
1443
+ ### Changed (breaking). Type-level & facade surface
321
1444
 
322
1445
  - **Compile-time typestate across the system, query, relation, and key seams.** The config-form
323
- `registerSystem` now infers your access declarations as literal types and hands `fn`/`onAdded` a
324
- `SystemContext<DeclaredAccess<…>>` narrowed to exactly the declared surface undeclared access
1446
+ `registerSystem` now infers your access declarations as literal types and hands `fn` and `onAdded` a
1447
+ `SystemContext<DeclaredAccess<…>>` narrowed to exactly the declared surface, undeclared access
325
1448
  is a compile error naming the missing declaration, with the dev-mode runtime check remaining as
326
1449
  the backstop for dynamic values. Query columns are typed by the query's terms
327
- (`ChunkColumns<Defs>` / `ArchetypeView<Defs>`; `.and(...)` extends the term set), relation
328
- handles carry their cardinality (`RelationDef<"exclusive">` vs `RelationDef<"multi">` the
1450
+ (`ChunkColumns<Defs>` and `ArchetypeView<Defs>`, `.and(...)` extends the term set), relation
1451
+ handles carry their cardinality (`RelationDef<"exclusive">` vs `RelationDef<"multi">`, the
329
1452
  exclusive-only traversal surfaces reject a multi handle at compile time), and
330
- `ResourceKey`/`EventKey`/`EventDef` are invariant so a key can no longer widen through
1453
+ `ResourceKey`, `EventKey` and `EventDef` are invariant so a key can no longer widen through
331
1454
  `unknown`. A checked-in type battery (`typing_assertions.ts`) pins every rule.
332
1455
  - **Grouped facades: `ecs.relations`, `ecs.events`, `ecs.resources`, `ecs.snapshots`.** Cohesive
333
- secondary surfaces move off the flat namespace onto narrow typed facades
1456
+ secondary surfaces move off the flat namespace onto narrow typed facades:
334
1457
  `ecs.relations.add(child, ChildOf, parent)`, `ecs.events.emit(Damage, {...})`,
335
1458
  `ecs.resources.get(Time)`, `ecs.snapshots.capture()`. The facades mirror the typestate
336
1459
  surface exactly (cardinality-stamped `relations.register`, exclusive-only traversal). Hot-path
337
- API (component ops, queries, spawn/destroy, sparse ops) stays flat by design. Facade classes
338
- are exported type-only; the runtime export list is unchanged.
1460
+ API (component ops, queries, spawn and destroy, sparse ops) stays flat by design. Facade classes
1461
+ are exported type-only. The runtime export list is unchanged.
339
1462
  - **Value arguments are schema-checked at compile time across every attach seam.** Tag defs
340
- reject value objects (`Frozen({ x: 1 })` no longer compiles tags carry no data);
1463
+ reject value objects (`Frozen({ x: 1 })` no longer compiles, tags carry no data)
341
1464
  `addComponents` takes schema-checked entries (`TemplateEntries<Defs>`), so a misspelled or
342
- cross-component field key is a compile error instead of a silent zero-fill; host-seam
1465
+ cross-component field key is a compile error instead of a silent zero-fill. Host-seam
343
1466
  `queue.spawn` entries (`SpawnEntries<Defs>`) are checked complete against each def's own
344
- schema (`ValuesArg` / `CompleteFieldValues` exported); and `events.register` requires the
345
- field list to cover the event schema (`EventFieldsCover`) a partial list silently dropped
1467
+ schema (`ValuesArg` and `CompleteFieldValues` exported), and `events.register` requires the
1468
+ field list to cover the event schema (`EventFieldsCover`), a partial list silently dropped
346
1469
  columns and read back `undefined` at runtime. Smaller closures in the same vein: `observe`
347
1470
  accepts any `ComponentHandle`, `NoInfer` pins key-typed value params (`events.emit`,
348
1471
  resources), and reactive-sync's `JoinReader.field` is constrained to the join's component
@@ -350,12 +1473,12 @@ rename/removal map:
350
1473
 
351
1474
  ### Removed (breaking)
352
1475
 
353
- - The 29 flat forms the new facades replace (`registerRelation`/`addRelation`/`targetOf`/…,
354
- `registerEvent`/`registerSignal`/`emit`/`read`, `registerResource`/`resource`/`setResource`/
355
- `removeResource`/`hasResource`, `snapshot`/`restoreInto`/`snapshotSparse`/`restoreSparse`/
356
- `stateHash`/`deterministic`, `relationCount`/`compactRelations`). Each maps 1:1 onto its
357
- grouped replacement `ecs.relations.add(...)`, `ecs.events.emit(...)`, `ecs.resources.get(...)`,
358
- `ecs.snapshots.capture()` (was `snapshot()`) / `ecs.snapshots.restore(...)` (was
1476
+ - The 29 flat forms the new facades replace (`registerRelation`, `addRelation` and `targetOf`/…,
1477
+ `registerEvent`, `registerSignal`, `emit` and `read`, `registerResource`, `resource` and `setResource`/
1478
+ `removeResource` and `hasResource`, `snapshot`, `restoreInto`, `snapshotSparse` and `restoreSparse`/
1479
+ `stateHash` and `deterministic`, `relationCount` and `compactRelations`). Each maps 1:1 onto its
1480
+ grouped replacement, `ecs.relations.add(...)`, `ecs.events.emit(...)`, `ecs.resources.get(...)`,
1481
+ `ecs.snapshots.capture()` (was `snapshot()`) and `ecs.snapshots.restore(...)` (was
359
1482
  `restoreInto(...)`), `ecs.relations.count` (was `relationCount`), `ecs.relations.compact()`
360
1483
  (was `compactRelations()`). System-side `ctx.*` and all `Store`-level methods are unchanged.
361
1484
 
@@ -363,28 +1486,28 @@ rename/removal map:
363
1486
 
364
1487
  - **`Store` decomposed into seven focused collaborators** (RelationService, EventRegistry +
365
1488
  ResourceRegistry, EntityAllocator, DeferredCommandBuffer, SnapshotService, ArchetypeGraph) with
366
- `Store` as the coordinator; the hot-path extractions were A/B-benchmarked against
367
- identical-code controls with no regression. The `ECS` facade's pure delegations now live in a
368
- marker-delimited pass-through band whose logic-free invariant is enforced by an AST guard test.
1489
+ `Store` as the coordinator. Each hot-path extraction was benchmarked against a control that
1490
+ carries identical code, and none regressed. The `ECS` facade's pure delegations now live in a
1491
+ marker-delimited pass-through band whose logic-free invariant is enforced by an ast guard test.
369
1492
  - Typed per-consumer host seams (`ObserverHost`, `QueryHost`) replace underscore-convention
370
- reach-through on `Store`; `QueryCache` now owns all 12 query-resolution cache maps.
1493
+ reach-through on `Store`. `QueryCache` now owns all 12 query-resolution cache maps.
371
1494
  - Store layer consolidation: one strategy-parameterized factory behind
372
- `growableSabAllocator` / `heapArraybufferAllocator`; a typed `isColumnStoreInternal` guard
373
- replaces six structural casts; grow/extend's ~200 duplicated lines moved to a shared
1495
+ `growableSabAllocator` and `heapArraybufferAllocator`. A typed `isColumnStoreInternal` guard
1496
+ replaces six structural casts. Grow and extend's ~200 duplicated lines moved to a shared
374
1497
  `layout_ops.ts` (bit-identical layouts pinned by a golden differential test across the
375
1498
  full allocator matrix).
376
- - `core/reactive` moved to `src/reactive` (the published `./reactive` subpath is unchanged);
1499
+ - `core/reactive` moved to `src/reactive` (the published `./reactive` subpath is unchanged)
377
1500
  `__generated__/abi.ts` renamed to `vendored_abi/abi.ts` (it is a hand-maintained snapshot,
378
1501
  not generated output).
379
- - Deleted orphaned duplicate `src/utils/{arrays,constants}.ts`; renamed the custom `TypeError`
380
- (shadowed the ECMAScript global) to `AssertionError`; retired the 246-line casing codemod +
1502
+ - Deleted orphaned duplicate `src/utils/{arrays,constants}.ts`. Renamed the custom `TypeError`
1503
+ (shadowed the ECMAScript global) to `AssertionError`. Retired the 246-line casing codemod +
381
1504
  guard test (the 0.4 rename has converged).
382
1505
 
383
- ## [0.4.0] 2026-06-24
1506
+ ## [0.4.0] - 2026-06-24
384
1507
 
385
- Major release. oecs is **re-derived from the upstream oasys engine ECS** its modern descendant and
1508
+ Major release. oecs is **re-derived from the upstream oasys engine ECS**, its modern descendant, and
386
1509
  gains whole subsystems while staying pure-TS and zero-dependency by default. The public API moves to
387
- the engine's surface, so **every consumer touches breaking changes** chiefly a global
1510
+ the engine's surface, so **every consumer touches breaking changes**, chiefly a global
388
1511
  `snake_case` → `camelCase` rename. See [docs/MIGRATION-0.3-to-0.4.md](docs/MIGRATION-0.3-to-0.4.md).
389
1512
 
390
1513
  ### Changed (breaking)
@@ -393,127 +1516,127 @@ the engine's surface, so **every consumer touches breaking changes** — chiefly
393
1516
  from `snake_case` (`create_entity` → `createEntity`, `add_component` → `addComponent`, `get_field` →
394
1517
  `getField`, `is_alive` → `isAlive`, `register_system` → `registerSystem`, …). Types and handles stay
395
1518
  PascalCase and SCREAMING_SNAKE constants are unchanged. A `vitest` casing guard prevents regressions.
396
- - **Renamed query/context verbs.** `QueryBuilder.every` → `with`; `query.not` → `without`;
397
- `query.any_of` → `anyOf`; `query.for_each` → `forEach`; `archetype.get_column` → `getColumnRead`;
398
- `event_key` / `signal_key` / `resource_key` → `eventKey` / `signalKey` / `resourceKey`;
399
- `is_ecs_error` → `isEcsError`; `destroy_entity_deferred` → `destroyEntity` (still deferred).
1519
+ - **Renamed query and context verbs.** `QueryBuilder.every` → `with`. `query.not` → `without`
1520
+ `query.any_of` → `anyOf`. `query.for_each` → `forEach`. `archetype.get_column` → `getColumnRead`
1521
+ `event_key`, `signal_key` and `resource_key` → `eventKey`, `signalKey` and `resourceKey`
1522
+ `is_ecs_error` → `isEcsError`. `destroy_entity_deferred` → `destroyEntity` (still deferred).
400
1523
  - **Ref mutability flipped on the unsuffixed name.** `ctx.ref` is now the **mutable** default (was
401
- read-only in 0.3); the read-only variant is `ctx.refRead` (was `ctx.ref_mut` for the mutable one).
1524
+ read-only in 0.3). The read-only variant is `ctx.refRead` (was `ctx.ref_mut` for the mutable one).
402
1525
  Same rule for columns: mutable `getColumn` (internal) vs read-only `getColumnRead`.
403
- - **`WorldOptions` → `ECSOptions`; `fixed_timestep` → `fixedTimestep`.**
404
- - **`initial_capacity` removed** replaced by the `memory` surface (`memory: { budget }` /
405
- `{ maxBytes }` / `{ columnCapacity }` pin / `{ shared }` / `{ wasm }` / `{ allocator }`). Passing the
1526
+ - **`WorldOptions` → `ECSOptions`. `fixed_timestep` → `fixedTimestep`.**
1527
+ - **`initial_capacity` removed**, replaced by the `memory` surface (`memory: { budget }`,
1528
+ `{ maxBytes }`, the `{ columnCapacity }` pin, `{ shared }`, `{ wasm }` and `{ allocator }`). Passing the
406
1529
  old option keys throws at construction, pointing at `memory`.
407
- - **Component-touching systems must declare `reads` / `writes`.** A new `__DEV__` access checker
408
- (tree-shaken from production) validates every column / ref / field / resource access against a
1530
+ - **Component-touching systems must declare `reads` and `writes`.** A new `__DEV__` access checker
1531
+ (tree-shaken from production) validates every column, ref, field and resource access against a
409
1532
  system's declared surface. The bare `(ctx, dt)` and `(q, ctx, dt)` + query-builder `registerSystem`
410
- overloads declare no access, so a system that touches ECS data through them throws in dev move it
1533
+ overloads declare no access, so a system that touches ECS data through them throws in dev, move it
411
1534
  to the config form (`registerSystem({ reads, writes, fn })`). `exclusive: true` systems bypass the
412
1535
  checker. A registration-time lint (`QUERY_ACCESS_UNDECLARED`) additionally checks any declared
413
1536
  `queries ⊆ reads ∪ writes`.
414
- - **`removeComponents` takes an array, not varargs** (`removeComponents(e, [A, B])`);
415
- `batchAddComponent` / `batchRemoveComponent` key on `ArchetypeID` instead of an `Archetype` object.
1537
+ - **`removeComponents` takes an array, not varargs** (`removeComponents(e, [A, B])`)
1538
+ `batchAddComponent` and `batchRemoveComponent` key on `ArchetypeID` instead of an `Archetype` object.
416
1539
  - **Event schema shape.** `eventKey`'s type parameter is now a field → value-type record
417
1540
  (`eventKey<{ target: EntityID; amount: number }>("Damage")`) rather than a tuple of field names, so
418
- branded fields round-trip through `emit` / `read`. `registerEvent(key, [...fieldNames])` unchanged
1541
+ branded fields round-trip through `emit` and `read`. `registerEvent(key, [...fieldNames])` unchanged
419
1542
  otherwise.
420
1543
 
421
1544
  ### Added
422
1545
 
423
1546
  - **Two storage profiles over one backing-neutral `ColumnStore`.** Default is pure-TS **heap** (a plain
424
- resizable `ArrayBuffer`) no `SharedArrayBuffer`, no cross-origin isolation. Opt-in
425
- `@oasys/oecs/shared` (`memory: { shared: {} }`) uses a `SharedArrayBuffer` for worker offload / a WASM
426
- compute backend. Same code path; identical state hash.
427
- - **Determinism** (opt-in `deterministic: true`): a state hash over column bytes + `snapshot()` /
428
- `restoreInto()` (and `snapshotSparse` / `restoreSparse`), **backing-agnostic** a heap world and a
429
- shared world with identical history agree. `WorldRestoreError` / `SparseRestoreError` fail closed
1547
+ resizable `ArrayBuffer`), no `SharedArrayBuffer`, no cross-origin isolation. Opt-in
1548
+ `@oasys/oecs/shared` (`memory: { shared: {} }`) uses a `SharedArrayBuffer` for worker offload or a WASM
1549
+ compute backend. Same code path. Identical state hash.
1550
+ - **Determinism** (opt-in `deterministic: true`): a state hash over column bytes plus `snapshot()` and
1551
+ `restoreInto()` (and `snapshotSparse` and `restoreSparse`), **backing-agnostic**, a heap world and a
1552
+ shared world with identical history agree. `WorldRestoreError` and `SparseRestoreError` fail closed
430
1553
  before overwriting live backing.
431
- - **Observers** `world.observe(def, { onAdd, onRemove, onSet, onDisable, onEnable })`, structural +
1554
+ - **Observers**, `world.observe(def, { onAdd, onRemove, onSet, onDisable, onEnable })`, structural +
432
1555
  per-entity.
433
- - **Relations** `(relation, target)` pairs, `ChildOf` / `IsA` presets (`registerChildOf` /
434
- `registerIsA`), `(R,*)` / `(*,T)` wildcard queries (`withRelation`, `forEachRelatedTo`,
435
- `ANY_RELATION`), hierarchy queries (`query.hierarchy`), traversal (`ancestorsOf` / `rootOf` /
1556
+ - **Relations**, `(relation, target)` pairs, `ChildOf` and `IsA` presets (`registerChildOf` and
1557
+ `registerIsA`), `(R,*)` and `(*,T)` wildcard queries (`withRelation`, `forEachRelatedTo`,
1558
+ `ANY_RELATION`), hierarchy queries (`query.hierarchy`), traversal (`ancestorsOf`, `rootOf` and
436
1559
  `cascadeOf`), and on-delete cleanup policies.
437
- - **Sparse component storage** (`registerSparseComponent` / `addSparse` / `query.withSparse`),
438
- **run conditions / system sets** (`systemSet` + `configureSet`; `runIfResourceEq` / `runEveryNTicks`
439
- / `runIfAnyMatch`), **entity enable/disable** (row-partitioned; `disable` / `enable` /
1560
+ - **Sparse component storage** (`registerSparseComponent`, `addSparse` and `query.withSparse`),
1561
+ **run conditions and system sets** (`systemSet` + `configureSet`, `runIfResourceEq`, `runEveryNTicks`
1562
+ and `runIfAnyMatch`), **entity enable and disable** (row-partitioned. `disable`, `enable` and
440
1563
  `includeDisabled`), and **templates** (`world.template([...])` + `createEntity(template, overrides)`
441
- / `createEntities(template, count)` for zero-transition spawns).
442
- - **Typed host→ECS write seam** `installHostCommandSeam(world)` + `applyHostCommand` + a
443
- `HostCommandQueue` drained by a blessed `exclusive` apply system; a cross-thread ring transport
444
- (`HostCommandDispatcher`); record/replay (`HostCommandRecorder`, `replayCommandLog`,
445
- `serializeCommandLog`); and an undo/redo + field-handle layer at `@oasys/oecs/editor`.
446
- - **Frame trace** `world.setTrace(sink)` + `FrameTraceRecorder` emit a structured per-frame event
447
- stream (`__DEV__`-gated). **Compute backend seam** `world.attachBackend(backend)` runs a system's
1564
+ and `createEntities(template, count)` for zero-transition spawns).
1565
+ - **Typed host→ECS write seam**, `installHostCommandSeam(world)` + `applyHostCommand` + a
1566
+ `HostCommandQueue` drained by a blessed `exclusive` apply system. A cross-thread ring transport
1567
+ (`HostCommandDispatcher`). Record and replay (`HostCommandRecorder`, `replayCommandLog`,
1568
+ `serializeCommandLog`), and an undo and redo + field-handle layer at `@oasys/oecs/editor`.
1569
+ - **Frame trace**, `world.setTrace(sink)` + `FrameTraceRecorder` emit a structured per-frame event
1570
+ stream (`__DEV__`-gated). **Compute backend seam**, `world.attachBackend(backend)` runs a system's
448
1571
  body on a compiled backend instead of its TS closure.
449
- - **Reactive UI seam (optional):** zero-dependency kernel at `@oasys/oecs/reactive`; ECS→reactive
450
- bridge at `@oasys/oecs/reactive-sync` (publish-only-dirty, O(changed)); SolidJS adapter at
1572
+ - **Reactive UI seam (optional):** zero-dependency kernel at `@oasys/oecs/reactive`. ECS→reactive
1573
+ bridge at `@oasys/oecs/reactive-sync` (publish-only-dirty, O(changed)). SolidJS adapter at
451
1574
  `@oasys/oecs/solid` with `solid-js` as an **optional** peer dependency.
452
- - **`memory` sizing surface** on the constructor: `budget` (by expected `entities`) / `maxBytes` /
453
- `columnCapacity` / `shared` / `wasm` / `allocator` arms; `resolveECSMemory(...)` exported to inspect
1575
+ - **`memory` sizing surface** on the constructor: `budget` (by expected `entities`), `maxBytes`,
1576
+ `columnCapacity`, `shared`, `wasm` and `allocator` arms. `resolveECSMemory(...)` exported to inspect
454
1577
  what an intent resolves to.
455
1578
  - **Hot-path iteration ergonomics:**
456
- - **`query.eachChunk((cols, count) => …)`** the mutable per-archetype iterator. `cols.mut(def)` /
1579
+ - **`query.eachChunk((cols, count) => …)`**, the mutable per-archetype iterator. `cols.mut(def)` and
457
1580
  `cols.read(def)` resolve a whole component's field columns at once into a destructurable group
458
1581
  (`const { x, y } = cols.mut(Pos)`), stamping the change tick once inside `mut` and handing back
459
1582
  `count` (= `entityCount`). The only mutable column accessor reachable through iteration (the
460
- `ArchetypeView` from `forEach` stays read-only). Honours `includeDisabled()`; dense-only like `forEach`.
461
- - **`ctx.commands`** a Bevy-`Commands`-style facade namespacing the **deferred** structural ops
462
- (`spawn` / `add` / `remove` / `despawn` / `disable` / `enable`), unambiguously deferred vs the
1583
+ `ArchetypeView` from `forEach` stays read-only). Honours `includeDisabled()`. Dense-only like `forEach`.
1584
+ - **`ctx.commands`**, a Bevy-`Commands`-style facade namespacing the **deferred** structural ops
1585
+ (`spawn`, `add`, `remove`, `despawn`, `disable` and `enable`), unambiguously deferred vs the
463
1586
  immediate `world.addComponent`.
464
- - **Callable bundles** `bundle(def, values)` pairs a def with field values (omitted fields
465
- zero-fill); `world.spawnBundle(...)` (immediate) and `ctx.commands.spawn` / `.add` (deferred)
466
- accept a `bundle(...)` or a bare def (tag / all-zero), unifying the attach shapes.
467
- - **`ctx.updateField` / `ctx.markChanged`**, and optional-component queries (`query.optional(...)` +
1587
+ - **Callable bundles**, `bundle(def, values)` pairs a def with field values (omitted fields
1588
+ zero-fill). `world.spawnBundle(...)` (immediate) and `ctx.commands.spawn` and `.add` (deferred)
1589
+ accept a `bundle(...)` or a bare def (a tag, or all-zero values), unifying the attach shapes.
1590
+ - **`ctx.updateField` and `ctx.markChanged`**, and optional-component queries (`query.optional(...)` +
468
1591
  `getOptionalColumnRead`).
469
- - **Composable change-detection queries** `query.changed(...)` returns a `ChangedQuery` that now
470
- mirrors the dense query verbs (`and` / `without` / `anyOf` / `optional`), so
1592
+ - **Composable change-detection queries**, `query.changed(...)` returns a `ChangedQuery` that now
1593
+ mirrors the dense query verbs (`and`, `without`, `anyOf` and `optional`), so
471
1594
  `q.changed(Pos).without(Dead)` works (refining *after* `changed()`, previously a dead end).
472
- - **New public exports** entity-ID codec (`createEntityId` / `getEntityIndex` / `getEntityGeneration`
473
- + `MAX_*` bounds) for snapshot/replication decode; the error taxonomy (`ECSError`, `ECS_ERROR`,
474
- `isEcsError`) for catch-and-branch; and `@oasys/oecs/primitives` (`BitSet`, `SparseSet`, `SparseMap`,
1595
+ - **New public exports**, entity-ID codec (`createEntityId`, `getEntityIndex` and `getEntityGeneration`
1596
+ + `MAX_*` bounds) for snapshot and replication decode. The error taxonomy (`ECSError`, `ECS_ERROR`,
1597
+ `isEcsError`) for catch-and-branch, and `@oasys/oecs/primitives` (`BitSet`, `SparseSet`, `SparseMap`,
475
1598
  growable typed arrays, `BinaryHeap`, `topologicalSort`).
476
1599
 
477
1600
  ### Packaging
478
1601
 
479
1602
  - **Multi-entry build** → `dist/` emits ESM + CJS + `.d.ts` for every subpath (`.`, `/primitives`,
480
- `/shared`, `/reactive`, `/reactive-sync`, `/editor`, `/solid`); `sideEffects:false` + tree-shaking
481
- keep core consumers from pulling SAB / Solid. `solid-js` is an optional peer dependency. `jsr.json`
1603
+ `/shared`, `/reactive`, `/reactive-sync`, `/editor`, `/solid`). `sideEffects:false` + tree-shaking
1604
+ keep core consumers from pulling SAB or Solid. `solid-js` is an optional peer dependency. `jsr.json`
482
1605
  exports updated.
483
1606
 
484
- ## [0.3.3] 2026-04-30
1607
+ ## [0.3.3] - 2026-04-30
485
1608
 
486
1609
  Release-process and packaging hygiene. No runtime changes.
487
1610
 
488
1611
  ### Changed
489
1612
 
490
- - **JSR bundle slimmed.** `.github/` and `docs/` are now excluded from the published JSR package. Consumers download less; build/CI artefacts stay on GitHub.
1613
+ - **JSR bundle slimmed.** `.github/` and `docs/` are now excluded from the published JSR package. Consumers download less. Build and CI artefacts stay on GitHub.
491
1614
  - **Tag-driven publish workflow.** `.github/workflows/publish.yml` now triggers on `v*` tag pushes instead of every push to `main`, and creates a GitHub Release alongside the JSR publish. Cuts a release by tagging.
492
1615
 
493
- ## [0.3.2] 2026-04-30
1616
+ ## [0.3.2] - 2026-04-30
494
1617
 
495
1618
  Documentation-only release. No runtime changes.
496
1619
 
497
1620
  ### Added
498
1621
 
499
1622
  - **Module overview on `src/index.ts`.** A `@module` block now renders as the JSR Overview tab.
500
- - **JSDoc on the full public surface.** `ECS` and its public methods, `Query` / `QueryBuilder` / `SystemContext` / `ChangedQuery`, all type aliases and interfaces, the event/resource key minters, and the `SCHEDULE` phases are now documented in-source.
1623
+ - **JSDoc on the full public surface.** `ECS` and its public methods, `Query`, `QueryBuilder`, `SystemContext` and `ChangedQuery`, all type aliases and interfaces, the event and resource key minters, and the `SCHEDULE` phases are now documented in-source.
501
1624
  - **`@internal` tags on internal-but-public TS members** (e.g. `_resolve_query`, `Query._include`, `SystemContext.store`) so JSR hides them from the rendered docs.
502
1625
 
503
- ## [0.3.1] 2026-04-23
1626
+ ## [0.3.1] - 2026-04-23
504
1627
 
505
- Performance-only patch release. Two targeted allocation-elimination changes on hot paths; no API changes; full 466-test suite unchanged.
1628
+ Performance-only patch release. Two targeted allocation-elimination changes on hot paths. No API changes. Full 466-test suite unchanged.
506
1629
 
507
1630
  ### Performance
508
1631
 
509
- - **Cache multi-component transition maps on `Archetype`.** `add_components` / `remove_components` on already-populated entities previously allocated a fresh `Int16Array` per call via `build_transition_map`. A per-archetype `batch_transition_maps: Map<ArchetypeID, Int16Array>` now caches the map on first use. Single-component paths unchanged. Measured: **+12–15%** throughput on `add_components` (already-populated) at 10k / 100k / 1M; **−35–42%** peak heap and **−49–61%** peak RSS on the same workload. ([#9](https://github.com/oasys-works/oecs/pull/9))
510
- - **Per-Query composition cache for single-component composition shapes.** `q.and(X)`, `q.not(X)`, `q.any_of(X)`, and `q.changed(X)` previously allocated a BitSet copy, a defs slice (and, for `.changed`, a new `ChangedQuery`) on every call, even though the resolver already cached the resulting `Query` object. Single-component calls now short-circuit through a per-parent-`Query` Map and skip the allocation path entirely. Multi-component compositions fall through unchanged. Measured: **~6×** throughput on a 4-shape compose loop at 10k / 100k / 1M; **−40–56%** peak heap and **essentially zero RSS growth** during the workload. ([#10](https://github.com/oasys-works/oecs/pull/10))
1632
+ - **Cache multi-component transition maps on `Archetype`.** `add_components` and `remove_components` on already-populated entities previously allocated a fresh `Int16Array` per call via `build_transition_map`. A per-archetype `batch_transition_maps: Map<ArchetypeID, Int16Array>` now caches the map on first use. Single-component paths unchanged. Measured on the same workload: a higher throughput of `add_components` on an already-populated entity, a much smaller peak heap, and a much smaller peak RSS. ([#9](https://github.com/oasys-works/oecs/pull/9))
1633
+ - **Per-Query composition cache for single-component composition shapes.** `q.and(X)`, `q.not(X)`, `q.any_of(X)`, and `q.changed(X)` previously allocated a BitSet copy, a defs slice (and, for `.changed`, a new `ChangedQuery`) on every call, even though the resolver already cached the resulting `Query` object. Single-component calls now short-circuit through a per-parent-`Query` Map and skip the allocation path entirely. Multi-component compositions fall through unchanged. Measured on a compose loop with four shapes: a much higher throughput, a much smaller peak heap, and almost no growth of RSS during the workload. ([#10](https://github.com/oasys-works/oecs/pull/10))
511
1634
 
512
- ## [0.3.0] 2026-04-21
1635
+ ## [0.3.0] - 2026-04-21
513
1636
 
514
1637
  A substantial release focused on change detection, stricter component-access
515
1638
  typing, and a simpler key-based API for events and resources. Several public
516
- entry points change shape; see the migration notes under *Breaking changes*.
1639
+ entry points change shape. See the migration notes under *Breaking changes*.
517
1640
 
518
1641
  ### Added
519
1642
 
@@ -521,45 +1644,45 @@ entry points change shape; see the migration notes under *Breaking changes*.
521
1644
 
522
1645
  - Frame-based tick counter on the world. `ECS` now holds a `_tick` that
523
1646
  advances once per `update()`. Systems can see it via `ctx.world_tick`,
524
- and each `SystemContext` receives `last_run_tick` the tick at which that
1647
+ and each `SystemContext` receives `last_run_tick`, the tick at which that
525
1648
  system last executed.
526
1649
  - Per-component change ticks on archetypes. Each archetype tracks
527
- `_changed_tick[component_id]` the tick at which any entity in that
1650
+ `_changed_tick[component_id]`, the tick at which any entity in that
528
1651
  archetype last had the component mutated. Maintained automatically by
529
1652
  `write_fields`, `write_fields_positional`, `copy_shared_from`,
530
1653
  `move_entity_from`, and `bulk_move_all_from`, all of which now accept a
531
1654
  `tick` parameter.
532
- - `ChangedQuery<Defs>` a new query variant, produced by `query.changed(...)`,
1655
+ - `ChangedQuery<Defs>`, a new query variant, produced by `query.changed(...)`,
533
1656
  that restricts iteration to archetypes whose tracked components were
534
1657
  modified after `last_run_tick`. Validates at construction that the named
535
1658
  components are part of the parent query's include set.
536
1659
 
537
1660
  #### Readonly component views
538
1661
 
539
- - `ReadonlyColumn<T>` and `ReadonlyUint32Array` compile-time readonly views
1662
+ - `ReadonlyColumn<T>` and `ReadonlyUint32Array`, compile-time readonly views
540
1663
  of typed-array columns. Returned by `archetype.get_column()` and the new
541
1664
  `archetype.entity_ids` getter. Prevents accidental indexed writes at the
542
- type level; zero runtime cost.
543
- - `ReadonlyComponentRef<S>` readonly variant of `ComponentRef`. Returned by
1665
+ type level. Zero runtime cost.
1666
+ - `ReadonlyComponentRef<S>`, readonly variant of `ComponentRef`. Returned by
544
1667
  `query.ref(...)`. Use it when you only need to read component fields.
545
- - `archetype.get_column_mut(def, field, tick)` explicit mutable column
1668
+ - `archetype.get_column_mut(def, field, tick)`, explicit mutable column
546
1669
  accessor. Writes through `get_column_mut` update `_changed_tick`.
547
- - `query.ref_mut(...)` mutable sibling of `ref()`. Returns a `ComponentRef`
1670
+ - `query.ref_mut(...)`, mutable sibling of `ref()`. Returns a `ComponentRef`
548
1671
  and records the component as changed for the current tick.
549
1672
 
550
1673
  #### Key-based Event API
551
1674
 
552
- - `EventKey<F>` symbol-typed key that carries the event's field schema as
1675
+ - `EventKey<F>`, symbol-typed key that carries the event's field schema as
553
1676
  a phantom type.
554
- - `event_key<F>(name)` / `signal_key(name)` factories for module-scope
1677
+ - `event_key<F>(name)` and `signal_key(name)`, factories for module-scope
555
1678
  event keys. `signal_key` is a convenience wrapper for zero-field events.
556
1679
 
557
1680
  #### Key-based Resource API
558
1681
 
559
- - `ResourceKey<T>` symbol-typed key carrying the resource's value type as
1682
+ - `ResourceKey<T>`, symbol-typed key carrying the resource's value type as
560
1683
  a phantom type.
561
- - `resource_key<T>(name)` factory for module-scope resource keys.
562
- - `world.has_resource(key)` existence check.
1684
+ - `resource_key<T>(name)`, factory for module-scope resource keys.
1685
+ - `world.has_resource(key)`, existence check.
563
1686
  - Resources are now plain key→value storage. `world.resource(key)` returns
564
1687
  the stored `T` directly.
565
1688
 
@@ -572,19 +1695,19 @@ entry points change shape; see the migration notes under *Breaking changes*.
572
1695
 
573
1696
  #### Assertions
574
1697
 
575
- - `assert_non_null<T>(value, message?)` in `type_primitives/assertions`
1698
+ - `assert_non_null<T>(value, message?)` in `type_primitives/assertions`, a
576
1699
  dev-only (`__DEV__` guarded) assertion that narrows `T` to `NonNullable<T>`
577
1700
  and throws a `TypeError` with contextual info on failure.
578
1701
 
579
1702
  #### New primitives
580
1703
 
581
- - `BinaryHeap<T>` in `type_primitives/binary_heap` generic array-backed
1704
+ - `BinaryHeap<T>` in `type_primitives/binary_heap`, generic array-backed
582
1705
  heap with a user-supplied comparator. `push`, `pop`, `peek`, `clear`,
583
- `size`. O(log n) push/pop, O(1) peek.
1706
+ `size`. O(log n) push and pop, O(1) peek.
584
1707
  - `topological_sort<T>(nodes, edges, tiebreaker, node_name?)` in
585
- `type_primitives/topological_sort` Kahn's algorithm with a
1708
+ `type_primitives/topological_sort`. Kahn's algorithm with a
586
1709
  `BinaryHeap`-backed ready queue for deterministic tie-breaking. Throws
587
- `TypeError` on cycles; the schedule layer re-wraps as
1710
+ `TypeError` on cycles. The schedule layer re-wraps as
588
1711
  `ECSError(CIRCULAR_SYSTEM_DEPENDENCY)`.
589
1712
 
590
1713
  #### Public exports
@@ -598,7 +1721,7 @@ entry points change shape; see the migration notes under *Breaking changes*.
598
1721
  - Query iteration is callback-based. `Query` no longer implements
599
1722
  `[Symbol.iterator]`. Iterate with `query.for_each((archetype) => { ... })`.
600
1723
  - `world.register_event`, `world.register_signal`, and `world.register_resource`
601
- return `void` and take an `EventKey` / `ResourceKey` as their first argument.
1724
+ return `void` and take an `EventKey` or a `ResourceKey` as their first argument.
602
1725
  - `world.emit`, `world.read`, `world.resource`, and `world.set_resource`
603
1726
  accept keys instead of definition objects. `world.resource(key)` returns
604
1727
  the typed value `T` directly rather than a field-reader wrapper.
@@ -606,10 +1729,10 @@ entry points change shape; see the migration notes under *Breaking changes*.
606
1729
  `run_update(label, tick)`, and `run_fixed_update(label, tick)` require
607
1730
  the current frame tick. `ECS.update()` wires this automatically.
608
1731
  - System ordering now uses the shared `topological_sort` primitive. Observable
609
- behavior is unchanged: `before`/`after` constraints respected,
1732
+ behavior is unchanged: `before` and `after` constraints respected,
610
1733
  `insertion_order` remains the tie-breaker, cycles surface as
611
1734
  `ECSError(CIRCULAR_SYSTEM_DEPENDENCY)`.
612
- - Store/query wiring. The store keeps a reference to each active `Query` via
1735
+ - Store and query wiring. The store keeps a reference to each active `Query` via
613
1736
  `update_query_ref` and calls `mark_non_empty_dirty` only when structural
614
1737
  changes occur, avoiding spurious query rebuilds on stable frames.
615
1738
  - Bit-manipulation and hash constants (`BITS_PER_WORD`, `BITS_PER_WORD_SHIFT`,
@@ -629,10 +1752,10 @@ entry points change shape; see the migration notes under *Breaking changes*.
629
1752
  ### Removed
630
1753
 
631
1754
  - `ResourceChannel`, `ResourceDef<F>`, `ResourceReader<F>`, `ResourceID`,
632
- `as_resource_id`, and the `__resource_schema` marker symbol the entire
1755
+ `as_resource_id`, and the `__resource_schema` marker symbol, the entire
633
1756
  SoA column-based resource storage layer. Resources are now key→value.
634
- - `RESOURCE_ROW` constant unused.
635
- - `EventDef<F>` replaced by `EventKey<F>`.
1757
+ - `RESOURCE_ROW` constant, unused.
1758
+ - `EventDef<F>`, replaced by `EventKey<F>`.
636
1759
 
637
1760
  ### Breaking changes
638
1761
 
@@ -648,7 +1771,7 @@ entry points change shape; see the migration notes under *Breaking changes*.
648
1771
  world.emit(DAMAGE, { amount: 5 });
649
1772
  ```
650
1773
 
651
- 2. **Resource registration / access.**
1774
+ 2. **Resource registration and access.**
652
1775
  ```ts
653
1776
  // before
654
1777
  const clock = world.register_resource({ ms: "u32" } as const, { ms: 0 });
@@ -659,7 +1782,7 @@ entry points change shape; see the migration notes under *Breaking changes*.
659
1782
  world.register_resource(CLOCK, { ms: 0 });
660
1783
  const ms = world.resource(CLOCK).ms;
661
1784
  ```
662
- `world.resource()` returns the stored value directly; the reader wrapper
1785
+ `world.resource()` returns the stored value directly. The reader wrapper
663
1786
  and the SoA column storage are gone.
664
1787
 
665
1788
  3. **Query iteration.**
@@ -672,7 +1795,7 @@ entry points change shape; see the migration notes under *Breaking changes*.
672
1795
  ```
673
1796
 
674
1797
  4. **Mutable vs readonly refs.** `query.ref(...)` now returns
675
- `ReadonlyComponentRef`. Switch to `query.ref_mut(...)` when writing
1798
+ `ReadonlyComponentRef`. Switch to `query.ref_mut(...)` when you write, because
676
1799
  this is also what enables change detection for that component.
677
1800
 
678
1801
  5. **Archetype column access.** `archetype.get_column(...)` returns a
@@ -685,4 +1808,4 @@ entry points change shape; see the migration notes under *Breaking changes*.
685
1808
 
686
1809
  ## [0.2.1] and earlier
687
1810
 
688
- Prior releases see git history.
1811
+ Prior releases, see git history.