@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.
- package/LICENSE +21 -0
- package/README.md +258 -0
- package/dist/backend/memory-backend.d.ts +6 -0
- package/dist/backend/memory-backend.js +92 -0
- package/dist/backend/memory-backend.js.map +1 -0
- package/dist/backend/memory-backend.test.d.ts +1 -0
- package/dist/backend/memory-backend.test.js +7 -0
- package/dist/backend/memory-backend.test.js.map +1 -0
- package/dist/backend/memory-file.d.ts +7 -0
- package/dist/backend/memory-file.js +90 -0
- package/dist/backend/memory-file.js.map +1 -0
- package/dist/backend/memory-file.test.d.ts +1 -0
- package/dist/backend/memory-file.test.js +5 -0
- package/dist/backend/memory-file.test.js.map +1 -0
- package/dist/backend/persistence-backend.conformance.d.ts +10 -0
- package/dist/backend/persistence-backend.conformance.js +104 -0
- package/dist/backend/persistence-backend.conformance.js.map +1 -0
- package/dist/backend/persistence-backend.d.ts +54 -0
- package/dist/backend/persistence-backend.js +3 -0
- package/dist/backend/persistence-backend.js.map +1 -0
- package/dist/backend/random-access-file.conformance.d.ts +20 -0
- package/dist/backend/random-access-file.conformance.js +156 -0
- package/dist/backend/random-access-file.conformance.js.map +1 -0
- package/dist/backend/random-access-file.d.ts +39 -0
- package/dist/backend/random-access-file.js +3 -0
- package/dist/backend/random-access-file.js.map +1 -0
- package/dist/backend/validate-rel-path.d.ts +14 -0
- package/dist/backend/validate-rel-path.js +44 -0
- package/dist/backend/validate-rel-path.js.map +1 -0
- package/dist/backend/validate-rel-path.test.d.ts +1 -0
- package/dist/backend/validate-rel-path.test.js +37 -0
- package/dist/backend/validate-rel-path.test.js.map +1 -0
- package/dist/browser/browser-worker-bootstrap.d.ts +1 -0
- package/dist/browser/browser-worker-bootstrap.js +97 -0
- package/dist/browser/browser-worker-bootstrap.js.map +1 -0
- package/dist/browser/browser-worker-transport.browser.test.d.ts +1 -0
- package/dist/browser/browser-worker-transport.browser.test.js +156 -0
- package/dist/browser/browser-worker-transport.browser.test.js.map +1 -0
- package/dist/browser/browser-worker-transport.d.ts +33 -0
- package/dist/browser/browser-worker-transport.js +120 -0
- package/dist/browser/browser-worker-transport.js.map +1 -0
- package/dist/browser/create-opfs-provider.d.ts +23 -0
- package/dist/browser/create-opfs-provider.js +49 -0
- package/dist/browser/create-opfs-provider.js.map +1 -0
- package/dist/browser/index.d.ts +6 -0
- package/dist/browser/index.js +12 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/mount-opfs.d.ts +12 -0
- package/dist/browser/mount-opfs.js +15 -0
- package/dist/browser/mount-opfs.js.map +1 -0
- package/dist/browser/opfs-backend-lock.browser.test.d.ts +1 -0
- package/dist/browser/opfs-backend-lock.browser.test.js +127 -0
- package/dist/browser/opfs-backend-lock.browser.test.js.map +1 -0
- package/dist/browser/opfs-backend.d.ts +6 -0
- package/dist/browser/opfs-backend.js +162 -0
- package/dist/browser/opfs-backend.js.map +1 -0
- package/dist/browser/opfs-lock-test-worker.d.ts +1 -0
- package/dist/browser/opfs-lock-test-worker.js +50 -0
- package/dist/browser/opfs-lock-test-worker.js.map +1 -0
- package/dist/browser/opfs-sync-file.d.ts +10 -0
- package/dist/browser/opfs-sync-file.js +58 -0
- package/dist/browser/opfs-sync-file.js.map +1 -0
- package/dist/encoder/create-column-encoder.d.ts +15 -0
- package/dist/encoder/create-column-encoder.js +110 -0
- package/dist/encoder/create-column-encoder.js.map +1 -0
- package/dist/encoder/journal-snapshot-codec.d.ts +21 -0
- package/dist/encoder/journal-snapshot-codec.js +103 -0
- package/dist/encoder/journal-snapshot-codec.js.map +1 -0
- package/dist/encoder/journal-snapshot-codec.test.d.ts +1 -0
- package/dist/encoder/journal-snapshot-codec.test.js +96 -0
- package/dist/encoder/journal-snapshot-codec.test.js.map +1 -0
- package/dist/encoder/types.d.ts +72 -0
- package/dist/encoder/types.js +3 -0
- package/dist/encoder/types.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +15 -0
- package/dist/index.js.map +1 -0
- package/dist/journal/journal-codec.d.ts +28 -0
- package/dist/journal/journal-codec.js +82 -0
- package/dist/journal/journal-codec.js.map +1 -0
- package/dist/journal/journal-codec.test.d.ts +1 -0
- package/dist/journal/journal-codec.test.js +106 -0
- package/dist/journal/journal-codec.test.js.map +1 -0
- package/dist/journal/journal-entry.d.ts +63 -0
- package/dist/journal/journal-entry.js +28 -0
- package/dist/journal/journal-entry.js.map +1 -0
- package/dist/node/create-node-fs-provider.d.ts +34 -0
- package/dist/node/create-node-fs-provider.js +48 -0
- package/dist/node/create-node-fs-provider.js.map +1 -0
- package/dist/node/index.d.ts +6 -0
- package/dist/node/index.js +10 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/mount-node-fs.d.ts +12 -0
- package/dist/node/mount-node-fs.js +15 -0
- package/dist/node/mount-node-fs.js.map +1 -0
- package/dist/node/node-fs-backend-lock.node.test.d.ts +1 -0
- package/dist/node/node-fs-backend-lock.node.test.js +160 -0
- package/dist/node/node-fs-backend-lock.node.test.js.map +1 -0
- package/dist/node/node-fs-backend.d.ts +21 -0
- package/dist/node/node-fs-backend.js +186 -0
- package/dist/node/node-fs-backend.js.map +1 -0
- package/dist/node/node-fs-backend.node.test.d.ts +1 -0
- package/dist/node/node-fs-backend.node.test.js +17 -0
- package/dist/node/node-fs-backend.node.test.js.map +1 -0
- package/dist/node/node-fs-file.d.ts +6 -0
- package/dist/node/node-fs-file.js +57 -0
- package/dist/node/node-fs-file.js.map +1 -0
- package/dist/node/node-fs-file.node.test.d.ts +1 -0
- package/dist/node/node-fs-file.node.test.js +13 -0
- package/dist/node/node-fs-file.node.test.js.map +1 -0
- package/dist/node/node-fs-provider.node.test.d.ts +1 -0
- package/dist/node/node-fs-provider.node.test.js +119 -0
- package/dist/node/node-fs-provider.node.test.js.map +1 -0
- package/dist/node/node-worker-bootstrap.d.ts +1 -0
- package/dist/node/node-worker-bootstrap.js +104 -0
- package/dist/node/node-worker-bootstrap.js.map +1 -0
- package/dist/node/node-worker-transport.d.ts +27 -0
- package/dist/node/node-worker-transport.js +152 -0
- package/dist/node/node-worker-transport.js.map +1 -0
- package/dist/node/node-worker-transport.node.test.d.ts +1 -0
- package/dist/node/node-worker-transport.node.test.js +147 -0
- package/dist/node/node-worker-transport.node.test.js.map +1 -0
- package/dist/perftest/perf-harness.d.ts +74 -0
- package/dist/perftest/perf-harness.js +147 -0
- package/dist/perftest/perf-harness.js.map +1 -0
- package/dist/perftest/persistence-perf.d.ts +2 -0
- package/dist/perftest/persistence-perf.js +290 -0
- package/dist/perftest/persistence-perf.js.map +1 -0
- package/dist/perftest/run.d.ts +1 -0
- package/dist/perftest/run.js +28 -0
- package/dist/perftest/run.js.map +1 -0
- package/dist/provider/create-memory-provider.d.ts +16 -0
- package/dist/provider/create-memory-provider.js +44 -0
- package/dist/provider/create-memory-provider.js.map +1 -0
- package/dist/provider/mount.d.ts +20 -0
- package/dist/provider/mount.js +20 -0
- package/dist/provider/mount.js.map +1 -0
- package/dist/provider/persistence-provider.d.ts +63 -0
- package/dist/provider/persistence-provider.js +3 -0
- package/dist/provider/persistence-provider.js.map +1 -0
- package/dist/provider/persistence-provider.test.d.ts +9 -0
- package/dist/provider/persistence-provider.test.js +139 -0
- package/dist/provider/persistence-provider.test.js.map +1 -0
- package/dist/service/component-nonpersistent-durability.test.d.ts +1 -0
- package/dist/service/component-nonpersistent-durability.test.js +57 -0
- package/dist/service/component-nonpersistent-durability.test.js.map +1 -0
- package/dist/service/create-incremental-persistence-service.d.ts +7 -0
- package/dist/service/create-incremental-persistence-service.js +960 -0
- package/dist/service/create-incremental-persistence-service.js.map +1 -0
- package/dist/service/create-worker-persistence-service.d.ts +1 -0
- package/dist/service/create-worker-persistence-service.js +7 -0
- package/dist/service/create-worker-persistence-service.js.map +1 -0
- package/dist/service/create-worker-persistence-service.test.d.ts +1 -0
- package/dist/service/create-worker-persistence-service.test.js +291 -0
- package/dist/service/create-worker-persistence-service.test.js.map +1 -0
- package/dist/service/entity-location-cache.d.ts +15 -0
- package/dist/service/entity-location-cache.js +75 -0
- package/dist/service/entity-location-cache.js.map +1 -0
- package/dist/service/entity-location-density.test.d.ts +1 -0
- package/dist/service/entity-location-density.test.js +42 -0
- package/dist/service/entity-location-density.test.js.map +1 -0
- package/dist/service/incremental-persistence-service.d.ts +57 -0
- package/dist/service/incremental-persistence-service.js +3 -0
- package/dist/service/incremental-persistence-service.js.map +1 -0
- package/dist/service/intermediate-relocation-durability.test.d.ts +1 -0
- package/dist/service/intermediate-relocation-durability.test.js +89 -0
- package/dist/service/intermediate-relocation-durability.test.js.map +1 -0
- package/dist/service/internal-access.d.ts +34 -0
- package/dist/service/internal-access.js +43 -0
- package/dist/service/internal-access.js.map +1 -0
- package/dist/service/journal-replay.test.d.ts +1 -0
- package/dist/service/journal-replay.test.js +424 -0
- package/dist/service/journal-replay.test.js.map +1 -0
- package/dist/service/journal-storage-durability.test.d.ts +1 -0
- package/dist/service/journal-storage-durability.test.js +171 -0
- package/dist/service/journal-storage-durability.test.js.map +1 -0
- package/dist/service/load-roundtrip.test.d.ts +1 -0
- package/dist/service/load-roundtrip.test.js +224 -0
- package/dist/service/load-roundtrip.test.js.map +1 -0
- package/dist/service/persistence-complexity.test.d.ts +1 -0
- package/dist/service/persistence-complexity.test.js +275 -0
- package/dist/service/persistence-complexity.test.js.map +1 -0
- package/dist/service/worker-persistence-service.d.ts +24 -0
- package/dist/service/worker-persistence-service.js +8 -0
- package/dist/service/worker-persistence-service.js.map +1 -0
- package/dist/transport/inprocess-transport.d.ts +15 -0
- package/dist/transport/inprocess-transport.js +64 -0
- package/dist/transport/inprocess-transport.js.map +1 -0
- package/dist/transport/inprocess-transport.test.d.ts +1 -0
- package/dist/transport/inprocess-transport.test.js +100 -0
- package/dist/transport/inprocess-transport.test.js.map +1 -0
- package/dist/transport/router.d.ts +12 -0
- package/dist/transport/router.js +146 -0
- package/dist/transport/router.js.map +1 -0
- package/dist/transport/transport.d.ts +105 -0
- package/dist/transport/transport.js +3 -0
- package/dist/transport/transport.js.map +1 -0
- 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
|