@adobe/data-persistence 0.9.90

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 (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +258 -0
  3. package/dist/backend/memory-backend.d.ts +6 -0
  4. package/dist/backend/memory-backend.js +92 -0
  5. package/dist/backend/memory-backend.js.map +1 -0
  6. package/dist/backend/memory-backend.test.d.ts +1 -0
  7. package/dist/backend/memory-backend.test.js +7 -0
  8. package/dist/backend/memory-backend.test.js.map +1 -0
  9. package/dist/backend/memory-file.d.ts +7 -0
  10. package/dist/backend/memory-file.js +90 -0
  11. package/dist/backend/memory-file.js.map +1 -0
  12. package/dist/backend/memory-file.test.d.ts +1 -0
  13. package/dist/backend/memory-file.test.js +5 -0
  14. package/dist/backend/memory-file.test.js.map +1 -0
  15. package/dist/backend/persistence-backend.conformance.d.ts +10 -0
  16. package/dist/backend/persistence-backend.conformance.js +104 -0
  17. package/dist/backend/persistence-backend.conformance.js.map +1 -0
  18. package/dist/backend/persistence-backend.d.ts +54 -0
  19. package/dist/backend/persistence-backend.js +3 -0
  20. package/dist/backend/persistence-backend.js.map +1 -0
  21. package/dist/backend/random-access-file.conformance.d.ts +20 -0
  22. package/dist/backend/random-access-file.conformance.js +156 -0
  23. package/dist/backend/random-access-file.conformance.js.map +1 -0
  24. package/dist/backend/random-access-file.d.ts +39 -0
  25. package/dist/backend/random-access-file.js +3 -0
  26. package/dist/backend/random-access-file.js.map +1 -0
  27. package/dist/backend/validate-rel-path.d.ts +14 -0
  28. package/dist/backend/validate-rel-path.js +44 -0
  29. package/dist/backend/validate-rel-path.js.map +1 -0
  30. package/dist/backend/validate-rel-path.test.d.ts +1 -0
  31. package/dist/backend/validate-rel-path.test.js +37 -0
  32. package/dist/backend/validate-rel-path.test.js.map +1 -0
  33. package/dist/browser/browser-worker-bootstrap.d.ts +1 -0
  34. package/dist/browser/browser-worker-bootstrap.js +97 -0
  35. package/dist/browser/browser-worker-bootstrap.js.map +1 -0
  36. package/dist/browser/browser-worker-transport.browser.test.d.ts +1 -0
  37. package/dist/browser/browser-worker-transport.browser.test.js +156 -0
  38. package/dist/browser/browser-worker-transport.browser.test.js.map +1 -0
  39. package/dist/browser/browser-worker-transport.d.ts +33 -0
  40. package/dist/browser/browser-worker-transport.js +120 -0
  41. package/dist/browser/browser-worker-transport.js.map +1 -0
  42. package/dist/browser/create-opfs-provider.d.ts +23 -0
  43. package/dist/browser/create-opfs-provider.js +49 -0
  44. package/dist/browser/create-opfs-provider.js.map +1 -0
  45. package/dist/browser/index.d.ts +6 -0
  46. package/dist/browser/index.js +12 -0
  47. package/dist/browser/index.js.map +1 -0
  48. package/dist/browser/mount-opfs.d.ts +12 -0
  49. package/dist/browser/mount-opfs.js +15 -0
  50. package/dist/browser/mount-opfs.js.map +1 -0
  51. package/dist/browser/opfs-backend-lock.browser.test.d.ts +1 -0
  52. package/dist/browser/opfs-backend-lock.browser.test.js +127 -0
  53. package/dist/browser/opfs-backend-lock.browser.test.js.map +1 -0
  54. package/dist/browser/opfs-backend.d.ts +6 -0
  55. package/dist/browser/opfs-backend.js +162 -0
  56. package/dist/browser/opfs-backend.js.map +1 -0
  57. package/dist/browser/opfs-lock-test-worker.d.ts +1 -0
  58. package/dist/browser/opfs-lock-test-worker.js +50 -0
  59. package/dist/browser/opfs-lock-test-worker.js.map +1 -0
  60. package/dist/browser/opfs-sync-file.d.ts +10 -0
  61. package/dist/browser/opfs-sync-file.js +58 -0
  62. package/dist/browser/opfs-sync-file.js.map +1 -0
  63. package/dist/encoder/create-column-encoder.d.ts +15 -0
  64. package/dist/encoder/create-column-encoder.js +110 -0
  65. package/dist/encoder/create-column-encoder.js.map +1 -0
  66. package/dist/encoder/journal-snapshot-codec.d.ts +21 -0
  67. package/dist/encoder/journal-snapshot-codec.js +103 -0
  68. package/dist/encoder/journal-snapshot-codec.js.map +1 -0
  69. package/dist/encoder/journal-snapshot-codec.test.d.ts +1 -0
  70. package/dist/encoder/journal-snapshot-codec.test.js +96 -0
  71. package/dist/encoder/journal-snapshot-codec.test.js.map +1 -0
  72. package/dist/encoder/types.d.ts +72 -0
  73. package/dist/encoder/types.js +3 -0
  74. package/dist/encoder/types.js.map +1 -0
  75. package/dist/index.d.ts +24 -0
  76. package/dist/index.js +15 -0
  77. package/dist/index.js.map +1 -0
  78. package/dist/journal/journal-codec.d.ts +28 -0
  79. package/dist/journal/journal-codec.js +82 -0
  80. package/dist/journal/journal-codec.js.map +1 -0
  81. package/dist/journal/journal-codec.test.d.ts +1 -0
  82. package/dist/journal/journal-codec.test.js +106 -0
  83. package/dist/journal/journal-codec.test.js.map +1 -0
  84. package/dist/journal/journal-entry.d.ts +63 -0
  85. package/dist/journal/journal-entry.js +28 -0
  86. package/dist/journal/journal-entry.js.map +1 -0
  87. package/dist/node/create-node-fs-provider.d.ts +34 -0
  88. package/dist/node/create-node-fs-provider.js +48 -0
  89. package/dist/node/create-node-fs-provider.js.map +1 -0
  90. package/dist/node/index.d.ts +6 -0
  91. package/dist/node/index.js +10 -0
  92. package/dist/node/index.js.map +1 -0
  93. package/dist/node/mount-node-fs.d.ts +12 -0
  94. package/dist/node/mount-node-fs.js +15 -0
  95. package/dist/node/mount-node-fs.js.map +1 -0
  96. package/dist/node/node-fs-backend-lock.node.test.d.ts +1 -0
  97. package/dist/node/node-fs-backend-lock.node.test.js +160 -0
  98. package/dist/node/node-fs-backend-lock.node.test.js.map +1 -0
  99. package/dist/node/node-fs-backend.d.ts +21 -0
  100. package/dist/node/node-fs-backend.js +186 -0
  101. package/dist/node/node-fs-backend.js.map +1 -0
  102. package/dist/node/node-fs-backend.node.test.d.ts +1 -0
  103. package/dist/node/node-fs-backend.node.test.js +17 -0
  104. package/dist/node/node-fs-backend.node.test.js.map +1 -0
  105. package/dist/node/node-fs-file.d.ts +6 -0
  106. package/dist/node/node-fs-file.js +57 -0
  107. package/dist/node/node-fs-file.js.map +1 -0
  108. package/dist/node/node-fs-file.node.test.d.ts +1 -0
  109. package/dist/node/node-fs-file.node.test.js +13 -0
  110. package/dist/node/node-fs-file.node.test.js.map +1 -0
  111. package/dist/node/node-fs-provider.node.test.d.ts +1 -0
  112. package/dist/node/node-fs-provider.node.test.js +119 -0
  113. package/dist/node/node-fs-provider.node.test.js.map +1 -0
  114. package/dist/node/node-worker-bootstrap.d.ts +1 -0
  115. package/dist/node/node-worker-bootstrap.js +104 -0
  116. package/dist/node/node-worker-bootstrap.js.map +1 -0
  117. package/dist/node/node-worker-transport.d.ts +27 -0
  118. package/dist/node/node-worker-transport.js +152 -0
  119. package/dist/node/node-worker-transport.js.map +1 -0
  120. package/dist/node/node-worker-transport.node.test.d.ts +1 -0
  121. package/dist/node/node-worker-transport.node.test.js +147 -0
  122. package/dist/node/node-worker-transport.node.test.js.map +1 -0
  123. package/dist/perftest/perf-harness.d.ts +74 -0
  124. package/dist/perftest/perf-harness.js +147 -0
  125. package/dist/perftest/perf-harness.js.map +1 -0
  126. package/dist/perftest/persistence-perf.d.ts +2 -0
  127. package/dist/perftest/persistence-perf.js +290 -0
  128. package/dist/perftest/persistence-perf.js.map +1 -0
  129. package/dist/perftest/run.d.ts +1 -0
  130. package/dist/perftest/run.js +28 -0
  131. package/dist/perftest/run.js.map +1 -0
  132. package/dist/provider/create-memory-provider.d.ts +16 -0
  133. package/dist/provider/create-memory-provider.js +44 -0
  134. package/dist/provider/create-memory-provider.js.map +1 -0
  135. package/dist/provider/mount.d.ts +20 -0
  136. package/dist/provider/mount.js +20 -0
  137. package/dist/provider/mount.js.map +1 -0
  138. package/dist/provider/persistence-provider.d.ts +63 -0
  139. package/dist/provider/persistence-provider.js +3 -0
  140. package/dist/provider/persistence-provider.js.map +1 -0
  141. package/dist/provider/persistence-provider.test.d.ts +9 -0
  142. package/dist/provider/persistence-provider.test.js +139 -0
  143. package/dist/provider/persistence-provider.test.js.map +1 -0
  144. package/dist/service/component-nonpersistent-durability.test.d.ts +1 -0
  145. package/dist/service/component-nonpersistent-durability.test.js +57 -0
  146. package/dist/service/component-nonpersistent-durability.test.js.map +1 -0
  147. package/dist/service/create-incremental-persistence-service.d.ts +7 -0
  148. package/dist/service/create-incremental-persistence-service.js +960 -0
  149. package/dist/service/create-incremental-persistence-service.js.map +1 -0
  150. package/dist/service/create-worker-persistence-service.d.ts +1 -0
  151. package/dist/service/create-worker-persistence-service.js +7 -0
  152. package/dist/service/create-worker-persistence-service.js.map +1 -0
  153. package/dist/service/create-worker-persistence-service.test.d.ts +1 -0
  154. package/dist/service/create-worker-persistence-service.test.js +291 -0
  155. package/dist/service/create-worker-persistence-service.test.js.map +1 -0
  156. package/dist/service/entity-location-cache.d.ts +15 -0
  157. package/dist/service/entity-location-cache.js +75 -0
  158. package/dist/service/entity-location-cache.js.map +1 -0
  159. package/dist/service/entity-location-density.test.d.ts +1 -0
  160. package/dist/service/entity-location-density.test.js +42 -0
  161. package/dist/service/entity-location-density.test.js.map +1 -0
  162. package/dist/service/incremental-persistence-service.d.ts +57 -0
  163. package/dist/service/incremental-persistence-service.js +3 -0
  164. package/dist/service/incremental-persistence-service.js.map +1 -0
  165. package/dist/service/intermediate-relocation-durability.test.d.ts +1 -0
  166. package/dist/service/intermediate-relocation-durability.test.js +89 -0
  167. package/dist/service/intermediate-relocation-durability.test.js.map +1 -0
  168. package/dist/service/internal-access.d.ts +34 -0
  169. package/dist/service/internal-access.js +43 -0
  170. package/dist/service/internal-access.js.map +1 -0
  171. package/dist/service/journal-replay.test.d.ts +1 -0
  172. package/dist/service/journal-replay.test.js +424 -0
  173. package/dist/service/journal-replay.test.js.map +1 -0
  174. package/dist/service/journal-storage-durability.test.d.ts +1 -0
  175. package/dist/service/journal-storage-durability.test.js +171 -0
  176. package/dist/service/journal-storage-durability.test.js.map +1 -0
  177. package/dist/service/load-roundtrip.test.d.ts +1 -0
  178. package/dist/service/load-roundtrip.test.js +224 -0
  179. package/dist/service/load-roundtrip.test.js.map +1 -0
  180. package/dist/service/persistence-complexity.test.d.ts +1 -0
  181. package/dist/service/persistence-complexity.test.js +275 -0
  182. package/dist/service/persistence-complexity.test.js.map +1 -0
  183. package/dist/service/worker-persistence-service.d.ts +24 -0
  184. package/dist/service/worker-persistence-service.js +8 -0
  185. package/dist/service/worker-persistence-service.js.map +1 -0
  186. package/dist/transport/inprocess-transport.d.ts +15 -0
  187. package/dist/transport/inprocess-transport.js +64 -0
  188. package/dist/transport/inprocess-transport.js.map +1 -0
  189. package/dist/transport/inprocess-transport.test.d.ts +1 -0
  190. package/dist/transport/inprocess-transport.test.js +100 -0
  191. package/dist/transport/inprocess-transport.test.js.map +1 -0
  192. package/dist/transport/router.d.ts +12 -0
  193. package/dist/transport/router.js +146 -0
  194. package/dist/transport/router.js.map +1 -0
  195. package/dist/transport/transport.d.ts +105 -0
  196. package/dist/transport/transport.js +3 -0
  197. package/dist/transport/transport.js.map +1 -0
  198. package/package.json +59 -0
@@ -0,0 +1,960 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { ECS_SNAPSHOT_VERSION, Entity, serializedEntityLocationTables } from "@adobe/data/ecs";
3
+ import { createColumnEncoder } from "../encoder/create-column-encoder.js";
4
+ import { decodeJournalSnapshot, encodeJournalSnapshot, } from "../encoder/journal-snapshot-codec.js";
5
+ import { decodeJournalStream, encodeJournalEntry } from "../journal/journal-codec.js";
6
+ import { createInprocessTransport } from "../transport/inprocess-transport.js";
7
+ import { asMutableArchetype, getColumn, getIdColumn, getMutableStore } from "./internal-access.js";
8
+ const META_FILE = "meta.json";
9
+ // One entity-location file per persistent quadrant (matches router.ts), each
10
+ // densely indexed by the entity's per-quadrant local index.
11
+ const entityLocationFile = (quadrant) => `entity-location-${quadrant}.bin`;
12
+ const JOURNAL_FILE = "journal.bin";
13
+ const ELT_STRIDE = 8;
14
+ const columnPath = (archetypeId, component) => `archetypes/${archetypeId}/${component}.bin`;
15
+ const DEFAULT_CHECKPOINT_TXS = 1000;
16
+ const DEFAULT_CHECKPOINT_IDLE_MS = 5000;
17
+ /**
18
+ * Create an {@link IncrementalPersistenceService}. The service subscribes
19
+ * to the database's transaction observer and writes changes incrementally
20
+ * via the configured backend / transport.
21
+ */
22
+ export const createIncrementalPersistenceService = async (options) => {
23
+ const { database, backend, transport: providedTransport, autoPersist = true, checkpoint: checkpointConfig, clock = () => Date.now(), txIdGenerator, } = options;
24
+ const transport = providedTransport ?? createInprocessTransport(backend);
25
+ const ownsTransport = providedTransport === undefined;
26
+ const everyNTransactions = checkpointConfig?.everyNTransactions ?? DEFAULT_CHECKPOINT_TXS;
27
+ const idleMs = checkpointConfig?.idleMs ?? DEFAULT_CHECKPOINT_IDLE_MS;
28
+ let nextOpId = 1;
29
+ const allocOpId = () => {
30
+ const id = nextOpId;
31
+ nextOpId += 1;
32
+ return id;
33
+ };
34
+ let nextTxId = 1;
35
+ const allocTxId = () => txIdGenerator ? txIdGenerator() : (() => { const id = nextTxId; nextTxId += 1; return id; })();
36
+ const store = getMutableStore(database);
37
+ // A component whose SCHEMA is marked nonPersistent (distinct from the
38
+ // built-in `nonPersistent` marker component) is never journaled/checkpointed.
39
+ const isNonPersistentComponent = (component) => component !== "nonPersistent" && component !== "nonShared" &&
40
+ store.componentSchemas[component]?.nonPersistent === true;
41
+ // Cache per-archetype context. We discover archetypes lazily as
42
+ // they appear in changedEntities so that newly-introduced
43
+ // archetypes (created by an extend after service init) are picked
44
+ // up automatically.
45
+ const archetypeContexts = new Map();
46
+ // Reverse map: archetype object → declared name in the plugin.
47
+ // Built once eagerly from the current `store.archetypes` snapshot,
48
+ // then supplemented on every `getArchetypeContext` call (so
49
+ // archetypes registered after service construction are captured on
50
+ // first use). This replaces the O(named archetypes) linear scan
51
+ // that the previous implementation ran on every new archetype.
52
+ const archetypeNameByRef = new Map();
53
+ for (const key in store.archetypes) {
54
+ const arch = store.archetypes[key];
55
+ if (arch !== undefined)
56
+ archetypeNameByRef.set(arch, key);
57
+ }
58
+ let nextComponentId = 1;
59
+ const globalComponentIds = new Map();
60
+ const internComponent = (name) => {
61
+ const existing = globalComponentIds.get(name);
62
+ if (existing !== undefined)
63
+ return existing;
64
+ const id = nextComponentId;
65
+ nextComponentId += 1;
66
+ globalComponentIds.set(name, id);
67
+ return id;
68
+ };
69
+ const getArchetypeContext = (archetype) => {
70
+ const existing = archetypeContexts.get(archetype.id);
71
+ if (existing !== undefined)
72
+ return existing;
73
+ // Resolve the archetype's declared name via the reverse-lookup
74
+ // map. The map is built eagerly at construction and supplemented
75
+ // here for archetypes registered after service init.
76
+ let archetypeName = archetypeNameByRef.get(archetype);
77
+ if (archetypeName === undefined) {
78
+ // Newly-registered archetype — scan only the live keys we
79
+ // haven't indexed yet, then cache the result.
80
+ for (const key in store.archetypes) {
81
+ const a = store.archetypes[key];
82
+ if (a !== undefined && !archetypeNameByRef.has(a)) {
83
+ archetypeNameByRef.set(a, key);
84
+ }
85
+ }
86
+ archetypeName = archetypeNameByRef.get(archetype) ?? `archetype-${archetype.id}`;
87
+ }
88
+ const encoders = new Map();
89
+ for (const component of archetype.components) {
90
+ // Skip the implicit `id` column — entity ids are recovered
91
+ // from the per-quadrant entity-location files, so storing them
92
+ // again per archetype row would be redundant.
93
+ if (component === "id")
94
+ continue;
95
+ // Skip nonPersistent-schema components — their values are never
96
+ // saved; on load they're reset to default or the component is
97
+ // stripped (see store.reconstructNonPersistentColumns).
98
+ if (isNonPersistentComponent(component))
99
+ continue;
100
+ const buffer = getColumn(archetype, component);
101
+ if (buffer === undefined)
102
+ continue;
103
+ encoders.set(component, createColumnEncoder(component, buffer));
104
+ internComponent(component);
105
+ }
106
+ const componentIds = new Map();
107
+ for (const component of archetype.components) {
108
+ if (component === "id" || isNonPersistentComponent(component))
109
+ continue;
110
+ componentIds.set(component, internComponent(component));
111
+ }
112
+ const ctx = {
113
+ id: archetype.id,
114
+ name: archetypeName,
115
+ archetype,
116
+ encoders,
117
+ componentIds,
118
+ };
119
+ archetypeContexts.set(archetype.id, ctx);
120
+ return ctx;
121
+ };
122
+ const sendJournal = (entry) => {
123
+ // encodeJournalEntry already produces a freshly-allocated,
124
+ // owned, transferable ArrayBuffer. Forward it directly — the
125
+ // transport's send() lists it in postMessage's transfer set
126
+ // for zero-copy worker handoff.
127
+ const op = {
128
+ id: allocOpId(),
129
+ kind: "appendJournal",
130
+ bytes: encodeJournalEntry(entry),
131
+ };
132
+ transport.send(op);
133
+ };
134
+ const sendColumnSlice = (archetypeId, component, rowIndex, encoder) => {
135
+ if (encoder.storage !== "fixed")
136
+ return;
137
+ const bytes = encoder.encodeRows(rowIndex, 1);
138
+ if (bytes === null)
139
+ return;
140
+ const op = {
141
+ id: allocOpId(),
142
+ kind: "writeColumnSlice",
143
+ archetypeId,
144
+ component,
145
+ rowOffset: rowIndex * encoder.stride,
146
+ bytes,
147
+ };
148
+ transport.send(op);
149
+ };
150
+ const sendEntityLocation = (entity, archetypeId, rowIndex) => {
151
+ transport.send({ id: allocOpId(), kind: "writeEntityLocation", entity, archetypeId, rowIndex });
152
+ };
153
+ const sendEntityDelete = (entity) => {
154
+ transport.send({ id: allocOpId(), kind: "deleteEntityLocation", entity });
155
+ };
156
+ let txsSinceCheckpoint = 0;
157
+ let idleTimer = null;
158
+ const scheduleIdleCheckpoint = () => {
159
+ if (idleTimer !== null)
160
+ clearTimeout(idleTimer);
161
+ if (idleMs <= 0)
162
+ return;
163
+ idleTimer = setTimeout(() => {
164
+ void doCheckpoint();
165
+ }, idleMs);
166
+ };
167
+ const buildManifest = () => {
168
+ const archetypes = {};
169
+ const named = store.archetypes;
170
+ for (const key in named) {
171
+ const archetype = named[key];
172
+ const ctx = getArchetypeContext(archetype);
173
+ const columns = {};
174
+ for (const component of archetype.components) {
175
+ const encoder = ctx.encoders.get(component);
176
+ if (encoder === undefined)
177
+ continue;
178
+ const buffer = getColumn(archetype, component);
179
+ if (buffer === undefined)
180
+ continue;
181
+ columns[component] = {
182
+ component,
183
+ stride: encoder.stride,
184
+ storage: encoder.storage,
185
+ bufferType: buffer.type,
186
+ };
187
+ }
188
+ archetypes[archetype.id] = {
189
+ id: archetype.id,
190
+ name: ctx.name,
191
+ rowCount: archetype.rowCount,
192
+ rowCapacity: archetype.rowCapacity,
193
+ columns,
194
+ };
195
+ }
196
+ // Persist the component intern table so the loader can map
197
+ // journal componentId codes back to live column names during
198
+ // replay. Built from the global table (which spans every
199
+ // archetype the service has touched).
200
+ const components = {};
201
+ for (const [name, id] of globalComponentIds) {
202
+ components[id] = name;
203
+ }
204
+ return {
205
+ version: 1,
206
+ checkpointId: clock(),
207
+ archetypes,
208
+ entityLocation: { stride: 8 },
209
+ components,
210
+ };
211
+ };
212
+ /**
213
+ * Snapshot every journal-storage column in every known archetype.
214
+ * These writes must land on disk BEFORE the journal is truncated
215
+ * (i.e. before the `checkpoint` op runs), so we flush after
216
+ * sending them and before sending the checkpoint request.
217
+ */
218
+ const sendJournalSnapshots = () => {
219
+ const named = store.archetypes;
220
+ for (const key in named) {
221
+ const archetype = named[key];
222
+ const ctx = archetypeContexts.get(archetype.id);
223
+ if (ctx === undefined)
224
+ continue;
225
+ for (const [component, encoder] of ctx.encoders) {
226
+ if (encoder.storage !== "journal")
227
+ continue;
228
+ const rows = new Array(archetype.rowCount);
229
+ for (let row = 0; row < archetype.rowCount; row++) {
230
+ const encoded = encoder.encodeRowValue(row);
231
+ rows[row] = encoded !== null && encoded.byteLength > 0 ? encoded : undefined;
232
+ }
233
+ const bytes = encodeJournalSnapshot(rows);
234
+ transport.send({
235
+ id: allocOpId(),
236
+ kind: "writeJournalSnapshot",
237
+ archetypeId: archetype.id,
238
+ component,
239
+ bytes,
240
+ });
241
+ }
242
+ }
243
+ };
244
+ let checkpointInFlight = null;
245
+ const doCheckpoint = async () => {
246
+ if (checkpointInFlight !== null)
247
+ return checkpointInFlight;
248
+ if (idleTimer !== null) {
249
+ clearTimeout(idleTimer);
250
+ idleTimer = null;
251
+ }
252
+ // Drain all pending column / journal / entity-location writes
253
+ // before snapshotting the manifest and truncating the journal —
254
+ // otherwise a write enqueued just before the checkpoint could
255
+ // land in the truncated tail.
256
+ await transport.flush();
257
+ // Write journal-column snapshot files before truncating the
258
+ // journal. These must be on disk before the `checkpoint` op
259
+ // runs — hence the second flush that drains the snapshot writes.
260
+ sendJournalSnapshots();
261
+ await transport.flush();
262
+ const manifest = buildManifest();
263
+ const op = { id: allocOpId(), kind: "checkpoint", manifest };
264
+ checkpointInFlight = transport.request(op).finally(() => {
265
+ checkpointInFlight = null;
266
+ txsSinceCheckpoint = 0;
267
+ });
268
+ await checkpointInFlight;
269
+ };
270
+ const handleEntityDelete = (entity, txId) => {
271
+ // WAL discipline: append the journal entry FIRST, then update
272
+ // the on-disk snapshot (the entity-location files). On crash, replay
273
+ // applies the journal — which means the snapshot is always
274
+ // catching up to the WAL, never running ahead of it. The delete
275
+ // op is keyed by entity id alone; the on-disk layer resolves the
276
+ // vacated slot, so no prior-location lookup is needed.
277
+ sendJournal({
278
+ txId,
279
+ timestampMs: clock(),
280
+ kind: "delete",
281
+ entity,
282
+ archetypeId: 0,
283
+ rowIndex: 0,
284
+ componentId: 0,
285
+ bytes: new Uint8Array(0),
286
+ });
287
+ sendEntityDelete(entity);
288
+ };
289
+ /**
290
+ * Persist a present entity. `changedComponents`:
291
+ * - `Set<string>` → write only the named columns. Used for pure
292
+ * same-row value updates where the user
293
+ * transaction touched a known subset.
294
+ * - `null` → write all columns. Used for new entities and
295
+ * for any RELOCATED entity (migration or
296
+ * swap-move), whose destination row's bytes are
297
+ * wholesale new.
298
+ *
299
+ * The caller decides null vs. subset from the ECS-emitted relocation
300
+ * signal (TransactionResult.relocatedEntities) — the persistence layer
301
+ * no longer keeps a per-entity location shadow to infer it.
302
+ *
303
+ * Send order within a tx: every journal entry for this entity is
304
+ * appended FIRST (one per emitColumn call), then the
305
+ * entity-location update is sent. The journal is the WAL; the
306
+ * snapshot files (column slices + ELT) lag behind.
307
+ */
308
+ const handleEntityUpdate = (entity, changedComponents, txId) => {
309
+ const located = store.locate(entity);
310
+ if (located === null) {
311
+ // The entity reported as present/relocated is no longer in the
312
+ // store (e.g. created then deleted across skipped intermediate
313
+ // transactions). Fall back to delete (WAL-ordered internally);
314
+ // deleting an entity that was never persisted is a no-op.
315
+ handleEntityDelete(entity, txId);
316
+ return;
317
+ }
318
+ const ctx = getArchetypeContext(located.archetype);
319
+ const writeAll = changedComponents === null;
320
+ if (writeAll) {
321
+ for (const [component, encoder] of ctx.encoders) {
322
+ emitColumn(entity, ctx, component, encoder, located.row, txId);
323
+ }
324
+ }
325
+ else {
326
+ for (const component of changedComponents) {
327
+ const encoder = ctx.encoders.get(component);
328
+ if (encoder === undefined)
329
+ continue;
330
+ emitColumn(entity, ctx, component, encoder, located.row, txId);
331
+ }
332
+ }
333
+ // Snapshot writes go AFTER journal entries for this entity. The row
334
+ // is read live from the store, so it is always the current row even
335
+ // for a swap-moved neighbor.
336
+ sendEntityLocation(entity, ctx.id, located.row);
337
+ };
338
+ const emitColumn = (entity, ctx, component, encoder, row, txId) => {
339
+ // WAL discipline: append the journal entry FIRST, then write
340
+ // the column slice. The column file is the snapshot; the
341
+ // journal is the source of truth.
342
+ const payload = encoder.encodeRowValue(row);
343
+ const journalKind = "update";
344
+ sendJournal({
345
+ txId,
346
+ timestampMs: clock(),
347
+ kind: journalKind,
348
+ entity,
349
+ archetypeId: ctx.id,
350
+ rowIndex: row,
351
+ componentId: ctx.componentIds.get(component) ?? 0,
352
+ bytes: payload ?? new Uint8Array(0),
353
+ });
354
+ sendColumnSlice(ctx.id, component, row, encoder);
355
+ };
356
+ const sendCommit = (txId) => {
357
+ // Tx-end marker: replay buffers entries by txId and only
358
+ // applies a tx once it sees this entry. A torn tail loses the
359
+ // commit, so the entire tx is dropped — atomicity preserved.
360
+ sendJournal({
361
+ txId,
362
+ timestampMs: clock(),
363
+ kind: "commit",
364
+ entity: 0,
365
+ archetypeId: 0,
366
+ rowIndex: 0,
367
+ componentId: 0,
368
+ bytes: new Uint8Array(0),
369
+ });
370
+ };
371
+ // Surface any error returned by the worker side via ack messages.
372
+ // Without this, fire-and-forget sends fail silently — a class of
373
+ // bug that is very hard to diagnose in production.
374
+ const errorListeners = new Set();
375
+ const offMessage = transport.onMessage((msg) => {
376
+ if (msg.kind === "ack" && msg.error !== undefined) {
377
+ const err = new Error(`Persistence op ${msg.id} failed: ${msg.error}`);
378
+ for (const listener of errorListeners)
379
+ listener(err);
380
+ // Also surface to the host as an unhandled error so test
381
+ // suites and process-level error handlers see it.
382
+ queueMicrotask(() => { throw err; });
383
+ }
384
+ });
385
+ // Persistent entities relocated by transactions the persistence layer
386
+ // SKIPPED (intermediate ones) since the last persisted flush. An
387
+ // intermediate transaction is not persisted step-by-step, but a
388
+ // relocation it caused leaves that entity's on-disk row stale — so we
389
+ // remember it and full-write it at the next persisted flush. This is
390
+ // the transient, per-flush replacement for the old per-entity location
391
+ // shadow: bounded by inter-flush churn, not by world size.
392
+ const pendingRelocations = new Set();
393
+ let unsubscribe = null;
394
+ if (autoPersist) {
395
+ unsubscribe = database.observe.transactions((result) => {
396
+ // Accumulate relocations from EVERY observed transaction — even
397
+ // skipped intermediate ones — so a reshuffle hidden inside an
398
+ // intermediate transaction is reconciled at the next flush.
399
+ // Persistent partitions never share a location table with
400
+ // non-persistent ones, so a relocation of a persistent entity
401
+ // only ever rides a persistent transaction; filter defensively.
402
+ for (const entity of result.relocatedEntities) {
403
+ if (Entity.isPersistent(entity))
404
+ pendingRelocations.add(entity);
405
+ }
406
+ if (result.intermediate || !result.persistent)
407
+ return;
408
+ // One txId per observer firing — every entity-level entry
409
+ // we emit for this user transaction shares it, and the
410
+ // trailing commit entry uses the same id. Replay groups by
411
+ // txId so this is what makes torn-tail recovery atomic.
412
+ const txId = allocTxId();
413
+ const relocated = pendingRelocations;
414
+ const handled = new Set();
415
+ for (const entity of result.changedEntities.keys())
416
+ handled.add(entity);
417
+ for (const [entity, values] of result.changedEntities) {
418
+ // Only persistent entities are written to disk. A persistent
419
+ // transaction can also touch non-persistent (e.g. presence)
420
+ // entities; skip those — they live in a separate quadrant and
421
+ // are never persisted.
422
+ if (Entity.isNonPersistent(entity))
423
+ continue;
424
+ if (values === null) {
425
+ handleEntityDelete(entity, txId);
426
+ }
427
+ else if (relocated.has(entity)) {
428
+ // Relocated (migration): its new row's other columns are
429
+ // carried over and not in `values` — write the whole row.
430
+ handleEntityUpdate(entity, null, txId);
431
+ }
432
+ else {
433
+ // Pure same-row update: the patched values map's keys are
434
+ // the union of every component the transaction touched for
435
+ // this entity — a strict subset of all columns. Emitting
436
+ // only those is the per-component-write optimization.
437
+ const components = Object.keys(values);
438
+ handleEntityUpdate(entity, new Set(components), txId);
439
+ }
440
+ }
441
+ // Swap-moved neighbors (and entities relocated by skipped
442
+ // intermediate transactions) that the user did not directly
443
+ // touch: their backing row is freshly established → full-write.
444
+ for (const entity of relocated) {
445
+ if (handled.has(entity))
446
+ continue;
447
+ handleEntityUpdate(entity, null, txId);
448
+ }
449
+ pendingRelocations.clear();
450
+ sendCommit(txId);
451
+ txsSinceCheckpoint += 1;
452
+ if (everyNTransactions > 0 && txsSinceCheckpoint >= everyNTransactions) {
453
+ void doCheckpoint();
454
+ }
455
+ else {
456
+ scheduleIdleCheckpoint();
457
+ }
458
+ });
459
+ }
460
+ const save = async () => {
461
+ // Initial snapshot framed as one logical transaction so the
462
+ // commit marker covers it. If a crash happens mid-save with
463
+ // no prior checkpoint, replay either applies the entire
464
+ // initial snapshot or none of it.
465
+ const txId = allocTxId();
466
+ const named = store.archetypes;
467
+ for (const key in named) {
468
+ const archetype = named[key];
469
+ const idColumn = getIdColumn(archetype);
470
+ if (idColumn === undefined)
471
+ continue;
472
+ for (let row = 0; row < archetype.rowCount; row++) {
473
+ const entity = idColumn.get(row);
474
+ handleEntityUpdate(entity, null, txId);
475
+ }
476
+ }
477
+ sendCommit(txId);
478
+ await doCheckpoint();
479
+ };
480
+ /**
481
+ * Restore the database from the persisted snapshot, then apply any
482
+ * journal entries appended after the last checkpoint. Steps:
483
+ *
484
+ * 1. Probe the root for `meta.json`; absent → no snapshot, return.
485
+ * 2. Read and parse the manifest.
486
+ * 3. For each archetype: look up the live archetype by name,
487
+ * grow column buffers to the persisted capacity, bulk-copy
488
+ * each column file's bytes into the typed array, and restore
489
+ * `rowCount`.
490
+ * 4. Read the per-quadrant entity-location files and build an in-memory snapshot
491
+ * of the entity-location table (entities Int32Array + free
492
+ * list).
493
+ * 5. Replay the journal forward over the snapshot — this both
494
+ * repairs partial column writes from a crashed transaction
495
+ * and rolls forward any complete transactions that landed in
496
+ * the journal but whose checkpoint never ran.
497
+ * 6. Reconstruct each archetype's implicit `id` column from the
498
+ * final ELT and hand the table back through `store.fromData`.
499
+ *
500
+ * After load(), the database is at the same state as the last
501
+ * fully-recorded transaction in the journal — durable across
502
+ * crashes that may have killed the process mid-checkpoint or
503
+ * mid-transaction.
504
+ */
505
+ const load = async () => {
506
+ const manifest = await readManifestIfPresent();
507
+ if (manifest === null)
508
+ return;
509
+ // Re-seed the global component intern table so newly-emitted
510
+ // journal entries (from post-load transactions) reuse the
511
+ // same numeric ids as the persisted ones. Without this we'd
512
+ // double-allocate ids for components we already know about.
513
+ rehydrateComponentIds(manifest);
514
+ for (const aManId in manifest.archetypes) {
515
+ const aMan = manifest.archetypes[aManId];
516
+ await restoreArchetype(aMan);
517
+ }
518
+ const eltStates = await readEntityLocationSnapshots();
519
+ await replayJournal(manifest, eltStates);
520
+ finalizeEntityLocationTable(manifest, eltStates);
521
+ };
522
+ const rehydrateComponentIds = (manifest) => {
523
+ let maxId = 0;
524
+ for (const idStr in manifest.components) {
525
+ const id = Number(idStr);
526
+ const name = manifest.components[id];
527
+ globalComponentIds.set(name, id);
528
+ if (id > maxId)
529
+ maxId = id;
530
+ }
531
+ nextComponentId = maxId + 1;
532
+ };
533
+ const readManifestIfPresent = async () => {
534
+ const list = await transport.request({
535
+ id: allocOpId(),
536
+ kind: "listDir",
537
+ path: ".",
538
+ });
539
+ if (!list.entries.includes(META_FILE))
540
+ return null;
541
+ const reply = await transport.request({
542
+ id: allocOpId(),
543
+ kind: "readFile",
544
+ path: META_FILE,
545
+ });
546
+ if (reply.bytes.byteLength === 0)
547
+ return null;
548
+ const text = new TextDecoder().decode(new Uint8Array(reply.bytes));
549
+ return parseManifest(text);
550
+ };
551
+ /**
552
+ * Parse and minimally validate a manifest. We only check the
553
+ * top-level `version` and `archetypes` shape — once those are
554
+ * present the structure was written by this same package and we
555
+ * trust the rest. A future schema-evolution version bump would
556
+ * dispatch on `version` here.
557
+ *
558
+ * Forward-compat: the `components` intern table was added after
559
+ * v1's initial release. Older v1 manifests written by previous
560
+ * builds may not have it; default to an empty table so journal
561
+ * replay can still proceed (entries with componentId === 0 — the
562
+ * pre-intern-table sentinel — are filtered out anyway).
563
+ */
564
+ const parseManifest = (text) => {
565
+ const raw = JSON.parse(text);
566
+ if (typeof raw !== "object" || raw === null) {
567
+ throw new Error("WorkerPersistenceService.load: meta.json is not an object");
568
+ }
569
+ const obj = raw;
570
+ if (obj.version !== 1) {
571
+ throw new Error(`WorkerPersistenceService.load: unsupported manifest version ${String(obj.version)}`);
572
+ }
573
+ if (typeof obj.archetypes !== "object" || obj.archetypes === null) {
574
+ throw new Error("WorkerPersistenceService.load: meta.json missing archetypes table");
575
+ }
576
+ if (typeof obj.components !== "object" || obj.components === null) {
577
+ // Patch in the empty table without mutating the parsed object's
578
+ // existing structure — keeps the unknown-shape upcast honest.
579
+ return { ...raw, components: {} };
580
+ }
581
+ return raw;
582
+ };
583
+ const restoreArchetype = async (aMan) => {
584
+ // The plugin must have re-declared the same archetype shape
585
+ // before load() runs; we only look up, never create.
586
+ const lookup = store.archetypes[aMan.name];
587
+ if (lookup === undefined) {
588
+ throw new Error(`WorkerPersistenceService.load: archetype "${aMan.name}" present in manifest but not in the live database. ` +
589
+ "Re-extend the database with the same plugin before calling load().");
590
+ }
591
+ const liveArchetype = asMutableArchetype(lookup);
592
+ // Grow column capacity so bulk byte writes don't run past the
593
+ // typed array's length. createTable starts at 16, so a small
594
+ // archetype may not need this — but we always bump if needed.
595
+ if (aMan.rowCapacity > liveArchetype.rowCapacity) {
596
+ for (const component of liveArchetype.components) {
597
+ const buffer = getColumn(liveArchetype, component);
598
+ if (buffer === undefined)
599
+ continue;
600
+ buffer.capacity = aMan.rowCapacity;
601
+ }
602
+ liveArchetype.rowCapacity = aMan.rowCapacity;
603
+ }
604
+ for (const componentName in aMan.columns) {
605
+ const colMan = aMan.columns[componentName];
606
+ // The implicit `id` column is reconstructed from the entity
607
+ // location table on a separate pass below.
608
+ if (colMan.component === "id")
609
+ continue;
610
+ await restoreColumn(aMan, colMan, liveArchetype);
611
+ }
612
+ liveArchetype.rowCount = aMan.rowCount;
613
+ };
614
+ const restoreColumn = async (aMan, colMan, liveArchetype) => {
615
+ if (colMan.storage === "manifest")
616
+ return;
617
+ const buffer = getColumn(liveArchetype, colMan.component);
618
+ if (buffer === undefined)
619
+ return;
620
+ const reply = await transport.request({
621
+ id: allocOpId(),
622
+ kind: "readFile",
623
+ path: columnPath(aMan.id, colMan.component),
624
+ });
625
+ if (reply.bytes.byteLength === 0)
626
+ return;
627
+ if (colMan.storage === "fixed") {
628
+ const ta = buffer.getTypedArray();
629
+ const dest = new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
630
+ const src = new Uint8Array(reply.bytes);
631
+ // Copy at most as many bytes as fit. The manifest's rowCount *
632
+ // stride should equal src.byteLength under normal operation; a
633
+ // shorter file is tolerated (treated as zero-fill).
634
+ dest.set(src.subarray(0, Math.min(src.byteLength, dest.byteLength)), 0);
635
+ }
636
+ else if (colMan.storage === "journal") {
637
+ // Journal-column snapshot: length-prefixed binary records,
638
+ // one per row, each containing the UTF-8 JSON for that row's
639
+ // value. Rows marked ABSENT in the snapshot are skipped;
640
+ // the archetype's default value stands for those slots.
641
+ const rows = decodeJournalSnapshot(reply.bytes);
642
+ const textDecoder = new TextDecoder();
643
+ for (let row = 0; row < rows.length; row++) {
644
+ const rowBytes = rows[row];
645
+ if (rowBytes === undefined)
646
+ continue;
647
+ try {
648
+ buffer.set(row, JSON.parse(textDecoder.decode(rowBytes)));
649
+ }
650
+ catch {
651
+ // Corrupt JSON — skip; default value stands.
652
+ }
653
+ }
654
+ }
655
+ };
656
+ const readEntityLocationSnapshot = async (quadrant) => {
657
+ const reply = await transport.request({
658
+ id: allocOpId(),
659
+ kind: "readFile",
660
+ path: entityLocationFile(quadrant),
661
+ });
662
+ const eltBytes = new Uint8Array(reply.bytes);
663
+ const nextSlot = Math.floor(eltBytes.byteLength / ELT_STRIDE);
664
+ let capacity = 16;
665
+ while (capacity < Math.max(nextSlot, 16))
666
+ capacity *= 2;
667
+ const entities = new Int32Array(new ArrayBuffer(capacity * 2 * 4));
668
+ const view = new DataView(eltBytes.buffer, eltBytes.byteOffset, eltBytes.byteLength);
669
+ let freeListHead = -1;
670
+ // Each 8-byte record is one local slot, in the same order the router
671
+ // wrote them (offset = localIndex * ELT_STRIDE), so this is a straight scan.
672
+ for (let slot = 0; slot < nextSlot; slot++) {
673
+ const offset = slot * ELT_STRIDE;
674
+ // Stored archetype is biased by +1: 0 = empty (never written or
675
+ // deleted), N+1 = archetype N.
676
+ const storedArchetype = view.getUint32(offset + 0, true);
677
+ const rowIndex = view.getInt32(offset + 4, true);
678
+ if (storedArchetype === 0) {
679
+ entities[slot * 2 + 0] = -1;
680
+ entities[slot * 2 + 1] = freeListHead;
681
+ freeListHead = slot;
682
+ }
683
+ else {
684
+ entities[slot * 2 + 0] = storedArchetype - 1;
685
+ entities[slot * 2 + 1] = rowIndex;
686
+ }
687
+ }
688
+ return { entities, capacity, nextSlot, freeListHead };
689
+ };
690
+ const readEntityLocationSnapshots = async () => {
691
+ const states = new Map();
692
+ for (const quadrant of Entity.persistentQuadrants) {
693
+ states.set(quadrant, await readEntityLocationSnapshot(quadrant));
694
+ }
695
+ return states;
696
+ };
697
+ /**
698
+ * Read journal.bin, decode every entry, and apply only the
699
+ * transactions that have a matching `commit` entry. The buffer
700
+ * pattern (group by txId, apply on commit, drop the trailing
701
+ * un-committed group) gives us crash-atomic replay: a crash
702
+ * anywhere inside a transaction's append sequence loses the
703
+ * commit marker, and the entire tx is dropped.
704
+ *
705
+ * Also advances `nextTxId` past every txId observed so newly-
706
+ * emitted post-load entries don't recycle ids and collide on a
707
+ * subsequent crash-recovery pass.
708
+ */
709
+ const replayJournal = async (manifest, eltStates) => {
710
+ const reply = await transport.request({
711
+ id: allocOpId(),
712
+ kind: "readFile",
713
+ path: JOURNAL_FILE,
714
+ });
715
+ if (reply.bytes.byteLength === 0)
716
+ return;
717
+ const entries = decodeJournalStream(reply.bytes);
718
+ if (entries.length === 0)
719
+ return;
720
+ let buffered = [];
721
+ let bufferedTxId = null;
722
+ let maxTxId = 0;
723
+ const flushIfMatch = (commitTxId) => {
724
+ if (bufferedTxId === commitTxId) {
725
+ for (const e of buffered)
726
+ applyJournalEntry(manifest, eltStates, e);
727
+ }
728
+ buffered = [];
729
+ bufferedTxId = null;
730
+ };
731
+ for (const entry of entries) {
732
+ if (entry.txId > maxTxId)
733
+ maxTxId = entry.txId;
734
+ if (entry.kind === "commit") {
735
+ flushIfMatch(entry.txId);
736
+ continue;
737
+ }
738
+ if (bufferedTxId === null) {
739
+ bufferedTxId = entry.txId;
740
+ }
741
+ else if (entry.txId !== bufferedTxId) {
742
+ // A new tx started before the previous tx's commit
743
+ // arrived — the previous tx is incomplete and must
744
+ // be dropped.
745
+ buffered = [];
746
+ bufferedTxId = entry.txId;
747
+ }
748
+ buffered.push(entry);
749
+ }
750
+ // Trailing un-committed buffer is silently dropped.
751
+ if (maxTxId >= nextTxId)
752
+ nextTxId = maxTxId + 1;
753
+ };
754
+ const ensureEntityCapacity = (eltState, slot) => {
755
+ // Grow `entities` to fit `slot` if needed. The persistent
756
+ // location table grows by *2; mirror that policy so any later
757
+ // round-trip through fromData matches what create() would do.
758
+ while (slot >= eltState.capacity) {
759
+ const grown = new Int32Array(new ArrayBuffer(eltState.capacity * 2 * 2 * 4));
760
+ grown.set(eltState.entities);
761
+ eltState.entities = grown;
762
+ eltState.capacity *= 2;
763
+ }
764
+ if (slot >= eltState.nextSlot) {
765
+ // Any slots between the previous high-water-mark and this
766
+ // slot were never allocated and become free-list entries.
767
+ for (let s = eltState.nextSlot; s < slot; s++) {
768
+ eltState.entities[s * 2 + 0] = -1;
769
+ eltState.entities[s * 2 + 1] = eltState.freeListHead;
770
+ eltState.freeListHead = s;
771
+ }
772
+ eltState.nextSlot = slot + 1;
773
+ }
774
+ };
775
+ const applyJournalEntry = (manifest, states, entry) => {
776
+ if (entry.kind === "commit")
777
+ return; // tx-end markers carry no state to apply
778
+ if (Entity.isNonPersistent(entry.entity))
779
+ return; // non-persistent — should not appear
780
+ // Route to the entity's quadrant state, indexed by its local index.
781
+ const eltState = states.get(Entity.quadrantOf(entry.entity));
782
+ if (eltState === undefined)
783
+ return;
784
+ const slot = Entity.toLocalIndex(entry.entity);
785
+ if (entry.kind === "delete") {
786
+ ensureEntityCapacity(eltState, slot);
787
+ const prevArch = eltState.entities[slot * 2 + 0];
788
+ if (prevArch >= 0) {
789
+ eltState.entities[slot * 2 + 0] = -1;
790
+ eltState.entities[slot * 2 + 1] = eltState.freeListHead;
791
+ eltState.freeListHead = slot;
792
+ }
793
+ return;
794
+ }
795
+ // insert / update / migrate — set the location and re-apply
796
+ // the component bytes.
797
+ ensureEntityCapacity(eltState, slot);
798
+ const prevArch = eltState.entities[slot * 2 + 0];
799
+ eltState.entities[slot * 2 + 0] = entry.archetypeId;
800
+ eltState.entities[slot * 2 + 1] = entry.rowIndex;
801
+ if (prevArch === -1) {
802
+ // Slot is moving out of the free list. Splice it out so
803
+ // the free chain stays consistent. The list is short in
804
+ // practice (bounded by the number of holes since the last
805
+ // checkpoint), so a linear scan is fine.
806
+ if (eltState.freeListHead === slot) {
807
+ eltState.freeListHead = eltState.entities[slot * 2 + 1];
808
+ eltState.entities[slot * 2 + 1] = entry.rowIndex;
809
+ }
810
+ else {
811
+ let cursor = eltState.freeListHead;
812
+ while (cursor !== -1) {
813
+ const next = eltState.entities[cursor * 2 + 1];
814
+ if (next === slot) {
815
+ eltState.entities[cursor * 2 + 1] = eltState.entities[slot * 2 + 1];
816
+ eltState.entities[slot * 2 + 1] = entry.rowIndex;
817
+ break;
818
+ }
819
+ cursor = next;
820
+ }
821
+ }
822
+ }
823
+ const aMan = manifest.archetypes[entry.archetypeId];
824
+ if (aMan === undefined)
825
+ return;
826
+ const liveArchetype = store.archetypes[aMan.name];
827
+ if (liveArchetype === undefined)
828
+ return;
829
+ const mutableArchetype = asMutableArchetype(liveArchetype);
830
+ // Bump rowCount + capacity to fit the journaled row.
831
+ if (entry.rowIndex >= mutableArchetype.rowCapacity) {
832
+ let newCap = Math.max(mutableArchetype.rowCapacity, 16);
833
+ while (entry.rowIndex >= newCap)
834
+ newCap *= 2;
835
+ for (const component of mutableArchetype.components) {
836
+ const buffer = getColumn(mutableArchetype, component);
837
+ if (buffer === undefined)
838
+ continue;
839
+ buffer.capacity = newCap;
840
+ }
841
+ mutableArchetype.rowCapacity = newCap;
842
+ }
843
+ if (entry.rowIndex >= mutableArchetype.rowCount) {
844
+ mutableArchetype.rowCount = entry.rowIndex + 1;
845
+ }
846
+ // Re-apply the component payload. componentId 0 means "no
847
+ // component" (e.g. an entity created with no components yet).
848
+ if (entry.componentId === 0)
849
+ return;
850
+ const componentName = manifest.components[entry.componentId];
851
+ if (componentName === undefined)
852
+ return;
853
+ if (componentName === "id")
854
+ return;
855
+ const colMan = aMan.columns[componentName];
856
+ if (colMan === undefined)
857
+ return;
858
+ const buffer = getColumn(mutableArchetype, componentName);
859
+ if (buffer === undefined)
860
+ return;
861
+ if (colMan.storage === "fixed") {
862
+ if (entry.bytes.byteLength === 0)
863
+ return;
864
+ const ta = buffer.getTypedArray();
865
+ const dest = new Uint8Array(ta.buffer, ta.byteOffset, ta.byteLength);
866
+ const offset = entry.rowIndex * colMan.stride;
867
+ const writeLen = Math.min(entry.bytes.byteLength, dest.byteLength - offset);
868
+ if (writeLen > 0)
869
+ dest.set(entry.bytes.subarray(0, writeLen), offset);
870
+ }
871
+ else if (colMan.storage === "journal") {
872
+ if (entry.bytes.byteLength === 0)
873
+ return;
874
+ try {
875
+ const value = JSON.parse(new TextDecoder().decode(entry.bytes));
876
+ // `buffer` is typed as `TypedBuffer<unknown>` here so
877
+ // an `unknown` value assigns without a cast. The
878
+ // runtime contract is the inverse of the encoder:
879
+ // `JournalColumnEncoder.encodeRowValue` JSON-encodes
880
+ // the live value, so JSON.parse round-trips back to
881
+ // a value of the same shape the buffer expects.
882
+ buffer.set(entry.rowIndex, value);
883
+ }
884
+ catch {
885
+ // Corrupt JSON — skip this entry rather than crashing
886
+ // the whole load. The column file (if present) still
887
+ // reflects the last column-slice write.
888
+ }
889
+ }
890
+ // storage === "manifest" carries no per-row payload.
891
+ };
892
+ /**
893
+ * After journal replay, reconstruct each archetype's implicit `id`
894
+ * column from the final ELT, then hand the rebuilt table back
895
+ * through `store.fromData`.
896
+ */
897
+ const finalizeEntityLocationTable = (manifest, eltStates) => {
898
+ // Rebuild each archetype's implicit `id` column and collect a flat list
899
+ // of live persistent entity locations. Each quadrant's state is indexed
900
+ // by local index, so recover the entity id from (localIndex, quadrant).
901
+ // The ECS buckets these into its quadrant-partitioned location tables.
902
+ const locations = [];
903
+ for (const [quadrant, eltState] of eltStates) {
904
+ const { entities, nextSlot } = eltState;
905
+ for (let slot = 0; slot < nextSlot; slot++) {
906
+ const archetypeId = entities[slot * 2 + 0];
907
+ if (archetypeId === undefined || archetypeId < 0)
908
+ continue;
909
+ const rowIndex = entities[slot * 2 + 1];
910
+ const aMan = manifest.archetypes[archetypeId];
911
+ if (aMan === undefined)
912
+ continue;
913
+ const liveArchetype = store.archetypes[aMan.name];
914
+ if (liveArchetype === undefined)
915
+ continue;
916
+ const idColumn = getIdColumn(liveArchetype);
917
+ if (idColumn === undefined)
918
+ continue;
919
+ const entity = Entity.toEntity(slot, quadrant);
920
+ idColumn.set(rowIndex, entity);
921
+ locations.push({ entity, archetype: archetypeId, row: rowIndex });
922
+ }
923
+ }
924
+ store.fromData({
925
+ version: ECS_SNAPSHOT_VERSION,
926
+ componentSchemas: {},
927
+ entityLocationTables: serializedEntityLocationTables(locations),
928
+ archetypesData: [],
929
+ });
930
+ // nonPersistent-schema columns were never checkpointed/journaled, so the
931
+ // freshly-restored archetypes have them empty: reset defaulted ones to
932
+ // their default and strip no-default ones.
933
+ store.reconstructNonPersistentColumns();
934
+ };
935
+ const dispose = async () => {
936
+ if (unsubscribe !== null) {
937
+ unsubscribe();
938
+ unsubscribe = null;
939
+ }
940
+ if (idleTimer !== null) {
941
+ clearTimeout(idleTimer);
942
+ idleTimer = null;
943
+ }
944
+ if (checkpointInFlight !== null)
945
+ await checkpointInFlight;
946
+ offMessage();
947
+ errorListeners.clear();
948
+ if (ownsTransport)
949
+ await transport.close();
950
+ };
951
+ return {
952
+ serviceName: "WorkerPersistenceService",
953
+ save,
954
+ load,
955
+ checkpoint: doCheckpoint,
956
+ flush: () => transport.flush(),
957
+ dispose,
958
+ };
959
+ };
960
+ //# sourceMappingURL=create-incremental-persistence-service.js.map