@feltdb/core 0.11.5 → 0.11.7

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 (47) hide show
  1. package/dist/create/package-versions.js +1 -1
  2. package/dist/create/server-source/crates/feltdb/src/lib.rs +10 -1
  3. package/dist/create/server-source/crates/feltdb/src/semantic_decision.rs +124 -12
  4. package/dist/create/server-source/crates/feltdb/src/semantic_query.rs +599 -0
  5. package/dist/create/server-source/crates/feltdb/src/state_contract.rs +222 -0
  6. package/dist/create/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +1 -0
  7. package/dist/create/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +1 -0
  8. package/dist/create/server-source/crates/feltdb/tests/semantic_query.rs +767 -0
  9. package/dist/create/server-source/crates/feltdb/tests/semantic_query_contract.rs +363 -0
  10. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/main.rs +150 -2
  12. package/dist/db.d.ts +43 -0
  13. package/dist/db.d.ts.map +1 -1
  14. package/dist/db.js +50 -0
  15. package/dist/embedded-query.d.ts +1 -0
  16. package/dist/embedded-query.d.ts.map +1 -1
  17. package/dist/embedded-query.js +15 -4
  18. package/dist/feltdb-vision-invariants.d.ts +1 -1
  19. package/dist/feltdb-vision-invariants.d.ts.map +1 -1
  20. package/dist/feltdb-vision-invariants.js +27 -0
  21. package/dist/file-db.d.ts +143 -14
  22. package/dist/file-db.d.ts.map +1 -1
  23. package/dist/file-db.js +690 -244
  24. package/dist/file-journal.d.ts +70 -0
  25. package/dist/file-journal.d.ts.map +1 -0
  26. package/dist/file-journal.js +184 -0
  27. package/dist/http-db.d.ts +8 -0
  28. package/dist/http-db.d.ts.map +1 -1
  29. package/dist/http-db.js +7 -0
  30. package/dist/index-core.d.ts +1 -0
  31. package/dist/index-core.d.ts.map +1 -1
  32. package/dist/index-core.js +1 -0
  33. package/dist/runtime-snapshot.d.ts +337 -0
  34. package/dist/runtime-snapshot.d.ts.map +1 -0
  35. package/dist/runtime-snapshot.js +482 -0
  36. package/dist/semantic-decision.d.ts +57 -0
  37. package/dist/semantic-decision.d.ts.map +1 -1
  38. package/dist/state-contract.d.ts +3 -2
  39. package/dist/state-contract.d.ts.map +1 -1
  40. package/dist/studio-app/assets/{feltdb_wasm-BGgGSBOP.js → feltdb_wasm-D58hv7aa.js} +1 -1
  41. package/dist/studio-app/assets/feltdb_wasm_bg-BgFXYOtc.wasm +0 -0
  42. package/dist/studio-app/assets/index-gR6d3Tys.js +30 -0
  43. package/dist/studio-app/index.html +1 -1
  44. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  45. package/package.json +1 -1
  46. package/dist/studio-app/assets/feltdb_wasm_bg-ClM6Qpod.wasm +0 -0
  47. package/dist/studio-app/assets/index-Cg6EWmLb.js +0 -30
package/dist/file-db.js CHANGED
@@ -4,6 +4,8 @@ import * as nodeFs from 'fs';
4
4
  import * as nodePath from 'path';
5
5
  import { decideTransaction, embeddedResult, EmbeddedTransactionRejected, } from './embedded-transaction.js';
6
6
  import { EmbeddedQueryEngine } from './embedded-query.js';
7
+ import { encodeRecord, scanJournal, JournalCorruptionError, PersistenceProfiler, } from './file-journal.js';
8
+ export { JournalCorruptionError } from './file-journal.js';
7
9
  // Synchronous versions for sync API (will throw if called in browser)
8
10
  function getFsSync() {
9
11
  if (typeof nodeFs.existsSync === 'function')
@@ -15,11 +17,24 @@ function getPathSync() {
15
17
  return nodePath;
16
18
  throw new Error('FileJsDb is only available in Node.js environments');
17
19
  }
20
+ const DEFAULT_MIN_JOURNAL_BYTES = 8 * 1024 * 1024;
21
+ const JOURNAL_FILE = /^state\.(\d+)\.journal$/;
22
+ function isMissing(error) {
23
+ return error?.code === 'ENOENT';
24
+ }
25
+ function writeFully(fd, bytes, position) {
26
+ let written = 0;
27
+ while (written < bytes.length) {
28
+ written += getFsSync().writeSync(fd, bytes, written, bytes.length - written, position + written);
29
+ }
30
+ }
18
31
  export class FileJsDb {
19
32
  dataPath;
20
33
  statePath;
34
+ backupPath;
21
35
  lockPath;
22
36
  state;
37
+ view;
23
38
  origin;
24
39
  peers = new Set();
25
40
  lockFd = null;
@@ -27,7 +42,10 @@ export class FileJsDb {
27
42
  applied = new Set();
28
43
  queryEngine;
29
44
  indexedSequence = -1;
30
- constructor(path) {
45
+ profiler;
46
+ compaction;
47
+ compactionScheduled = false;
48
+ constructor(path, options = {}) {
31
49
  const nodePath = getPathSync();
32
50
  const nodeFs = getFsSync();
33
51
  // Normalize and resolve the path to prevent directory traversal
@@ -38,7 +56,14 @@ export class FileJsDb {
38
56
  }
39
57
  this.dataPath = resolvedPath;
40
58
  this.statePath = nodePath.join(this.dataPath, 'state.json');
59
+ this.backupPath = `${this.statePath}.backup`;
41
60
  this.lockPath = nodePath.join(this.dataPath, 'state.lock');
61
+ this.profiler = new PersistenceProfiler(options.profile ?? (typeof process !== 'undefined' && process.env?.FELTDB_PERSISTENCE_PROFILE === '1'));
62
+ this.compaction = {
63
+ mode: options.compaction?.mode ?? 'auto',
64
+ minJournalBytes: options.compaction?.minJournalBytes ?? DEFAULT_MIN_JOURNAL_BYTES,
65
+ journalToSnapshotRatio: options.compaction?.journalToSnapshotRatio ?? 1,
66
+ };
42
67
  // Create directory if it doesn't exist
43
68
  if (!nodeFs.existsSync(this.dataPath)) {
44
69
  nodeFs.mkdirSync(this.dataPath, { recursive: true });
@@ -48,80 +73,73 @@ export class FileJsDb {
48
73
  // Clean up any stale locks from previous crashes on startup
49
74
  this.cleanupStaleLock();
50
75
  // Load or initialize state
51
- this.state = this.loadState();
52
- this.restorePeers();
53
- for (const transactionId of this.state.applied_transactions ?? []) {
54
- this.applied.add(transactionId);
55
- }
76
+ this.state = this.initializeState();
77
+ this.loadDurableState();
56
78
  this.queryEngine = new EmbeddedQueryEngine(() => Object.entries(this.state.rows));
57
79
  this.indexedSequence = this.state.sequence;
58
80
  }
59
- loadState() {
81
+ /* ------------------------------------------------------------------------
82
+ * Durable layout
83
+ * ---------------------------------------------------------------------- */
84
+ journalPath(generation) {
85
+ return getPathSync().join(this.dataPath, `state.${generation}.journal`);
86
+ }
87
+ snapshotKey() {
88
+ try {
89
+ const stat = getFsSync().statSync(this.statePath, { bigint: true });
90
+ return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
91
+ }
92
+ catch (error) {
93
+ if (isMissing(error))
94
+ return 'none';
95
+ throw error;
96
+ }
97
+ }
98
+ parseSnapshot(path) {
99
+ const content = getFsSync().readFileSync(path, 'utf-8');
100
+ const snapshot = JSON.parse(content);
101
+ const known = snapshot.version === 1
102
+ || (snapshot.version === 2 && Number.isInteger(snapshot.journal?.generation) && Number.isInteger(snapshot.journal?.commit));
103
+ if (!known || !snapshot.meta || !snapshot.rows || !snapshot.events)
104
+ return null;
105
+ return { snapshot, bytes: Buffer.byteLength(content) };
106
+ }
107
+ /**
108
+ * Read the snapshot this handle should start from: the primary, or the
109
+ * backup when the primary cannot be read (unchanged from the single-file
110
+ * runtime), or nothing at all.
111
+ */
112
+ readSnapshot() {
60
113
  const nodeFs = getFsSync();
61
- const backupPath = `${this.statePath}.backup`;
62
- // Load atomic snapshot if it exists
63
114
  if (nodeFs.existsSync(this.statePath)) {
64
115
  try {
65
- const content = nodeFs.readFileSync(this.statePath, 'utf-8');
66
- const snapshot = JSON.parse(content);
67
- // Validate snapshot structure
68
- if (snapshot.version !== 1 || !snapshot.meta || !snapshot.rows || !snapshot.events) {
69
- console.warn(`Invalid snapshot structure at ${this.statePath}, attempting backup recovery`);
70
- return this.loadStateFromBackup();
71
- }
72
- // Successfully loaded valid state, preserve it as backup
73
- try {
74
- nodeFs.copyFileSync(this.statePath, backupPath);
75
- }
76
- catch {
77
- // Backup failure is non-fatal
78
- }
79
- return {
80
- version: snapshot.version,
81
- instance_id: this.origin,
82
- sequence: snapshot.meta.sequence,
83
- rows: snapshot.rows,
84
- events: snapshot.events,
85
- peers: snapshot.meta.peers ?? [],
86
- sequences: snapshot.meta.sequences ?? {},
87
- applied_transactions: snapshot.applied_transactions ?? []
88
- };
116
+ const parsed = this.parseSnapshot(this.statePath);
117
+ if (parsed)
118
+ return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'primary' };
119
+ console.warn(`Invalid snapshot structure at ${this.statePath}, attempting backup recovery`);
89
120
  }
90
121
  catch (error) {
91
- console.warn(`Failed to load snapshot from ${this.statePath}: ${error}`);
92
- console.warn(`Attempting to recover from backup...`);
93
- return this.loadStateFromBackup();
122
+ if (!isMissing(error)) {
123
+ console.warn(`Failed to load snapshot from ${this.statePath}: ${error}`);
124
+ console.warn(`Attempting to recover from backup...`);
125
+ }
126
+ else {
127
+ return { snapshot: null, bytes: 0, source: 'empty' };
128
+ }
94
129
  }
95
- }
96
- return this.initializeState();
97
- }
98
- loadStateFromBackup() {
99
- const nodeFs = getFsSync();
100
- const backupPath = `${this.statePath}.backup`;
101
- if (nodeFs.existsSync(backupPath)) {
102
130
  try {
103
- const content = nodeFs.readFileSync(backupPath, 'utf-8');
104
- const snapshot = JSON.parse(content);
105
- if (snapshot.version === 1 && snapshot.meta && snapshot.rows && snapshot.events) {
106
- console.warn(`Recovered state from backup at ${backupPath}`);
107
- return {
108
- version: snapshot.version,
109
- instance_id: this.origin,
110
- sequence: snapshot.meta.sequence,
111
- rows: snapshot.rows,
112
- events: snapshot.events,
113
- peers: snapshot.meta.peers ?? [],
114
- sequences: snapshot.meta.sequences ?? {},
115
- applied_transactions: snapshot.applied_transactions ?? []
116
- };
131
+ const parsed = this.parseSnapshot(this.backupPath);
132
+ if (parsed) {
133
+ console.warn(`Recovered state from backup at ${this.backupPath}`);
134
+ return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'backup' };
117
135
  }
118
136
  }
119
137
  catch {
120
138
  // Backup is also corrupted or invalid
121
139
  }
140
+ console.warn(`Backup recovery failed, reinitializing state`);
122
141
  }
123
- console.warn(`Backup recovery failed, reinitializing state`);
124
- return this.initializeState();
142
+ return { snapshot: null, bytes: 0, source: 'empty' };
125
143
  }
126
144
  initializeState() {
127
145
  return {
@@ -135,11 +153,540 @@ export class FileJsDb {
135
153
  applied_transactions: []
136
154
  };
137
155
  }
138
- restorePeers() {
139
- for (const peer of this.state.peers) {
156
+ /**
157
+ * Materialize the durable state from scratch: snapshot, then every complete
158
+ * commit in its journal, in order. A journal that disappears while it is
159
+ * being read has been superseded by a compaction, so the read starts over
160
+ * from the new snapshot. One that is missing for the current snapshot is
161
+ * corruption.
162
+ */
163
+ loadDurableState() {
164
+ const started = this.profiler.now();
165
+ for (let attempt = 0;; attempt += 1) {
166
+ const snapshotKey = this.snapshotKey();
167
+ const { snapshot, bytes, source } = this.readSnapshot();
168
+ const state = snapshot ? {
169
+ version: snapshot.version,
170
+ instance_id: this.origin,
171
+ sequence: snapshot.meta.sequence,
172
+ rows: snapshot.rows,
173
+ events: snapshot.events,
174
+ peers: snapshot.meta.peers ?? [],
175
+ sequences: snapshot.meta.sequences ?? {},
176
+ applied_transactions: snapshot.applied_transactions ?? [],
177
+ } : this.initializeState();
178
+ const view = {
179
+ snapshotKey,
180
+ format: snapshot ? (snapshot.version === 2 ? 'v2' : 'v1') : 'none',
181
+ source,
182
+ generation: snapshot?.journal?.generation ?? 0,
183
+ commit: snapshot?.journal?.commit ?? 0,
184
+ journalOffset: 0,
185
+ journalRecords: 0,
186
+ snapshotBytes: bytes,
187
+ tornBytes: 0,
188
+ };
189
+ let records = [];
190
+ if (view.format === 'v2') {
191
+ const journalPath = this.journalPath(view.generation);
192
+ let journal;
193
+ try {
194
+ journal = getFsSync().readFileSync(journalPath);
195
+ }
196
+ catch (error) {
197
+ if (!isMissing(error))
198
+ throw error;
199
+ if (attempt < 8 && this.snapshotKey() !== snapshotKey)
200
+ continue;
201
+ if (source === 'backup') {
202
+ console.warn(`Backup snapshot journal ${journalPath} is missing, reinitializing state`);
203
+ this.adopt(this.initializeState(), { ...view, format: 'none', source: 'empty', generation: 0, commit: 0 });
204
+ this.profiler.record('recovery', started);
205
+ return;
206
+ }
207
+ throw new JournalCorruptionError(journalPath, 1, 0, 'the journal named by the current snapshot is missing');
208
+ }
209
+ let scan;
210
+ try {
211
+ scan = scanJournal(journal, {
212
+ journalPath, generation: view.generation, expectedCommit: view.commit + 1,
213
+ });
214
+ }
215
+ catch (error) {
216
+ // A reader holds no lock, so a writer repairing a torn tail can be
217
+ // observed mid-way. Real corruption is still there on a re-read.
218
+ if (attempt < 3 && error instanceof JournalCorruptionError)
219
+ continue;
220
+ throw error;
221
+ }
222
+ records = scan.records;
223
+ view.journalOffset = scan.consumed;
224
+ view.journalRecords = scan.records.length;
225
+ view.tornBytes = scan.tornBytes;
226
+ view.commit += scan.records.length;
227
+ }
228
+ if (source === 'backup') {
229
+ // The unreadable primary was the compaction of this backup and its
230
+ // journal, so the journals written after it continue the backup's
231
+ // commits exactly. Replaying them recovers everything that primary
232
+ // and its journal held, instead of stopping at the backup.
233
+ for (let generation = view.generation + 1;; generation += 1) {
234
+ const journalPath = this.journalPath(generation);
235
+ let journal;
236
+ try {
237
+ journal = getFsSync().readFileSync(journalPath);
238
+ }
239
+ catch (error) {
240
+ if (isMissing(error))
241
+ break;
242
+ throw error;
243
+ }
244
+ const scan = scanJournal(journal, {
245
+ journalPath, generation, expectedCommit: view.commit + 1,
246
+ });
247
+ records.push(...scan.records);
248
+ view.format = 'v2';
249
+ view.generation = generation;
250
+ view.journalOffset = scan.consumed;
251
+ view.journalRecords = scan.records.length;
252
+ view.tornBytes = scan.tornBytes;
253
+ view.commit += scan.records.length;
254
+ }
255
+ }
256
+ this.adopt(state, view);
257
+ for (const record of records)
258
+ this.applyCommit(record.body, false);
259
+ this.profiler.record('recovery', started);
260
+ return;
261
+ }
262
+ }
263
+ adopt(state, view) {
264
+ this.state = state;
265
+ this.view = view;
266
+ this.peers.clear();
267
+ for (const peer of state.peers)
140
268
  this.peers.add(peer);
269
+ this.applied.clear();
270
+ for (const transactionId of state.applied_transactions ?? [])
271
+ this.applied.add(transactionId);
272
+ }
273
+ /** Apply one commit to memory. The only way any mutation reaches memory. */
274
+ applyCommit(body, index) {
275
+ for (const mutation of body.m ?? []) {
276
+ const [key] = mutation;
277
+ if (mutation.length === 1)
278
+ delete this.state.rows[key];
279
+ else
280
+ this.state.rows[key] = mutation[1];
281
+ if (index)
282
+ this.queryEngine?.apply(key, mutation.length === 1 ? undefined : mutation[1]);
283
+ }
284
+ for (const event of body.e ?? [])
285
+ this.state.events.push(event);
286
+ if (body.q)
287
+ Object.assign(this.state.sequences, body.q);
288
+ if (body.p) {
289
+ this.state.peers = [...body.p];
290
+ this.peers.clear();
291
+ for (const peer of body.p)
292
+ this.peers.add(peer);
293
+ }
294
+ if (body.t !== undefined && !this.applied.has(body.t)) {
295
+ this.applied.add(body.t);
296
+ this.state.applied_transactions.push(body.t);
297
+ }
298
+ this.state.sequence = body.s;
299
+ if (index)
300
+ this.indexedSequence = this.state.sequence;
301
+ }
302
+ /**
303
+ * Bring memory up to date with the durable files.
304
+ *
305
+ * While the snapshot is unchanged, only journal bytes after the last
306
+ * complete record already applied are read, so a read costs what was
307
+ * committed since the previous read — not the size of all history. Any
308
+ * change to the snapshot, or a journal that no longer continues what was
309
+ * applied, falls back to a complete load.
310
+ */
311
+ refresh() {
312
+ const key = this.snapshotKey();
313
+ if (key === this.view.snapshotKey) {
314
+ if (this.view.format !== 'v2')
315
+ return;
316
+ if (this.tailJournal())
317
+ return;
318
+ }
319
+ this.loadDurableState();
320
+ this.queryEngine.rebuild();
321
+ this.indexedSequence = this.state.sequence;
322
+ }
323
+ /** Apply newly committed journal records. False when a full load is required. */
324
+ tailJournal() {
325
+ const nodeFs = getFsSync();
326
+ const journalPath = this.journalPath(this.view.generation);
327
+ let fd;
328
+ try {
329
+ fd = nodeFs.openSync(journalPath, 'r');
330
+ }
331
+ catch (error) {
332
+ if (isMissing(error))
333
+ return false;
334
+ throw error;
335
+ }
336
+ try {
337
+ for (let attempt = 0;; attempt += 1) {
338
+ const size = nodeFs.fstatSync(fd).size;
339
+ if (size < this.view.journalOffset)
340
+ return false;
341
+ const length = size - this.view.journalOffset;
342
+ if (length === 0) {
343
+ this.view.tornBytes = 0;
344
+ return true;
345
+ }
346
+ const bytes = Buffer.allocUnsafe(length);
347
+ let read = 0;
348
+ while (read < length) {
349
+ const count = nodeFs.readSync(fd, bytes, read, length - read, this.view.journalOffset + read);
350
+ if (count === 0)
351
+ break;
352
+ read += count;
353
+ }
354
+ let scan;
355
+ try {
356
+ scan = scanJournal(bytes.subarray(0, read), {
357
+ journalPath,
358
+ generation: this.view.generation,
359
+ expectedCommit: this.view.commit + 1,
360
+ baseOffset: this.view.journalOffset,
361
+ firstRecord: this.view.journalRecords + 1,
362
+ });
363
+ }
364
+ catch (error) {
365
+ if (attempt < 3 && error instanceof JournalCorruptionError && this.lockToken === null)
366
+ continue;
367
+ throw error;
368
+ }
369
+ for (const record of scan.records)
370
+ this.applyCommit(record.body, true);
371
+ this.view.journalOffset += scan.consumed;
372
+ this.view.journalRecords += scan.records.length;
373
+ this.view.commit += scan.records.length;
374
+ this.view.tornBytes = scan.tornBytes;
375
+ return true;
376
+ }
377
+ }
378
+ finally {
379
+ nodeFs.closeSync(fd);
380
+ }
381
+ }
382
+ /* ------------------------------------------------------------------------
383
+ * Commit protocol
384
+ * ---------------------------------------------------------------------- */
385
+ /** A commit body starting from the current sequence. */
386
+ stage() {
387
+ return { s: this.state.sequence };
388
+ }
389
+ stageRow(body, key, value) {
390
+ (body.m ??= []).push(value === undefined ? [key] : [key, value]);
391
+ }
392
+ stageEvent(body, collection, key, type, value, timestamp = Date.now()) {
393
+ body.s += 1;
394
+ (body.e ??= []).push({
395
+ sequence: body.s,
396
+ collection,
397
+ key,
398
+ type,
399
+ value,
400
+ timestamp,
401
+ origin: this.origin,
402
+ id: `${this.origin}:${timestamp}:${body.s}`,
403
+ });
404
+ }
405
+ /** Stage a row write together with the event that records it. */
406
+ stagePut(body, key, value) {
407
+ this.stageRow(body, key, value);
408
+ this.stageEvent(body, key.split(':')[0], key, 'put', value);
409
+ }
410
+ /**
411
+ * Make one commit durable, then visible. Caller holds the lock and has
412
+ * refreshed, so memory reflects every earlier commit.
413
+ *
414
+ * 1 body framed and written, without its terminator
415
+ * 2 fdatasync ← the record's bytes are durable
416
+ * 3 terminator written ← the commit boundary exists
417
+ * 4 fdatasync ← the boundary is durable
418
+ * 5 applied to memory, success returned
419
+ *
420
+ * A crash before 3 leaves an unterminated record that recovery discards; it
421
+ * was never acknowledged. Readers only accept terminated records, so no
422
+ * process can observe a commit whose bytes were not already durable. A
423
+ * failure at any step removes whatever was written, so a failed commit is
424
+ * not visible after it returns either.
425
+ */
426
+ persist(body) {
427
+ const nodeFs = getFsSync();
428
+ if (this.view.format !== 'v2' || this.view.source !== 'primary') {
429
+ // First write to a fresh, legacy, or backup-recovered directory
430
+ // establishes a journal-continued snapshot of the current state.
431
+ this.compactLocked();
432
+ }
433
+ const commitStarted = this.profiler.now();
434
+ const commit = this.view.commit + 1;
435
+ const serializationStarted = this.profiler.now();
436
+ const { header, body: bodyBytes } = encodeRecord(this.view.generation, commit, body);
437
+ const record = Buffer.concat([header, bodyBytes]);
438
+ this.profiler.record('serialization', serializationStarted);
439
+ const offset = this.view.journalOffset;
440
+ const fd = nodeFs.openSync(this.journalPath(this.view.generation), 'r+');
441
+ try {
442
+ try {
443
+ if (this.view.tornBytes > 0) {
444
+ // An earlier writer died mid-append. Its record was never
445
+ // acknowledged; remove it before anything is written after it.
446
+ console.warn(`FeltDB recovered an incomplete journal append at byte ${offset} `
447
+ + `(${this.view.tornBytes} bytes discarded) in ${this.journalPath(this.view.generation)}`);
448
+ nodeFs.ftruncateSync(fd, offset);
449
+ this.timedSync(fd);
450
+ this.view.tornBytes = 0;
451
+ }
452
+ let started = this.profiler.now();
453
+ writeFully(fd, record, offset);
454
+ this.profiler.record('write', started);
455
+ this.timedSync(fd);
456
+ started = this.profiler.now();
457
+ writeFully(fd, Buffer.from('\n'), offset + record.length);
458
+ this.profiler.record('write', started);
459
+ this.timedSync(fd);
460
+ }
461
+ catch (error) {
462
+ try {
463
+ nodeFs.ftruncateSync(fd, offset);
464
+ nodeFs.fdatasyncSync(fd);
465
+ }
466
+ catch {
467
+ // The unterminated remainder is discarded by the next writer.
468
+ }
469
+ throw new Error(`Failed to save FeltDB state: ${error}`);
470
+ }
471
+ }
472
+ finally {
473
+ nodeFs.closeSync(fd);
474
+ }
475
+ this.view.journalOffset = offset + record.length + 1;
476
+ this.view.journalRecords += 1;
477
+ this.view.commit = commit;
478
+ this.profiler.bytes('journal', record.length + 1);
479
+ // Memory receives exactly the bytes that were made durable.
480
+ this.applyCommit(JSON.parse(bodyBytes.toString('utf8')), true);
481
+ this.profiler.record('commit', commitStarted);
482
+ this.scheduleCompaction();
483
+ }
484
+ timedSync(fd) {
485
+ const started = this.profiler.now();
486
+ getFsSync().fdatasyncSync(fd);
487
+ this.profiler.record('fsync', started);
488
+ }
489
+ /* ------------------------------------------------------------------------
490
+ * Compaction
491
+ * ---------------------------------------------------------------------- */
492
+ compactionDue() {
493
+ return this.view.format === 'v2'
494
+ && this.view.journalOffset >= this.compaction.minJournalBytes
495
+ && this.view.journalOffset >= this.view.snapshotBytes * this.compaction.journalToSnapshotRatio;
496
+ }
497
+ /**
498
+ * Compaction runs after the commit that made it due has been acknowledged,
499
+ * never as part of it, and under the same lock as every other writer.
500
+ */
501
+ scheduleCompaction() {
502
+ if (this.compaction.mode !== 'auto' || this.compactionScheduled || !this.compactionDue())
503
+ return;
504
+ this.compactionScheduled = true;
505
+ const timer = setTimeout(() => {
506
+ this.compactionScheduled = false;
507
+ try {
508
+ this.withMutationLock(() => { if (this.compactionDue())
509
+ this.compactLocked(); });
510
+ }
511
+ catch (error) {
512
+ // Committed data is already durable in the journal; compaction only
513
+ // reclaims it. A failed attempt is retried after a later commit.
514
+ console.warn(`FeltDB journal compaction failed: ${error}`);
515
+ }
516
+ }, 0);
517
+ timer.unref?.();
518
+ }
519
+ /**
520
+ * Rewrite the current state as a new snapshot and start an empty journal.
521
+ * Caller holds the lock and has refreshed.
522
+ *
523
+ * 1 new empty journal G+1 created and synced
524
+ * 2 snapshot naming (G+1, commit C) written to a temporary file, synced
525
+ * 3 previous snapshot linked as the backup
526
+ * 4 snapshot renamed into place, directory synced ← compaction commits
527
+ * 5 journals other than G and G+1 removed
528
+ *
529
+ * A crash before 4 leaves the previous snapshot and journal authoritative;
530
+ * an orphaned journal G+1 is never read and is removed by the next
531
+ * compaction. After 4 the new snapshot is complete by itself.
532
+ */
533
+ compactLocked() {
534
+ const nodeFs = getFsSync();
535
+ const started = this.profiler.now();
536
+ const previousGeneration = this.view.generation;
537
+ let generation = previousGeneration;
538
+ for (const name of nodeFs.readdirSync(this.dataPath)) {
539
+ const match = JOURNAL_FILE.exec(name);
540
+ if (match)
541
+ generation = Math.max(generation, Number(match[1]));
542
+ }
543
+ generation += 1;
544
+ const commit = this.view.commit;
545
+ const journal = nodeFs.openSync(this.journalPath(generation), 'w');
546
+ try {
547
+ nodeFs.fsyncSync(journal);
548
+ }
549
+ finally {
550
+ nodeFs.closeSync(journal);
551
+ }
552
+ // The journal's directory entry must be durable before a snapshot names it.
553
+ this.syncDirectory();
554
+ this.state.peers = [...this.peers];
555
+ const snapshot = {
556
+ version: 2,
557
+ timestamp: Date.now(),
558
+ meta: {
559
+ instance_id: this.origin,
560
+ sequence: this.state.sequence,
561
+ peers: this.state.peers,
562
+ sequences: this.state.sequences,
563
+ },
564
+ rows: this.state.rows,
565
+ events: this.state.events,
566
+ applied_transactions: [...this.applied],
567
+ journal: { generation, commit },
568
+ };
569
+ // Compaction is profiled as one phase, so that it never inflates the
570
+ // serialization, write and fsync figures of ordinary commits.
571
+ const content = Buffer.from(JSON.stringify(snapshot), 'utf8');
572
+ const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
573
+ try {
574
+ const handle = nodeFs.openSync(tempPath, 'w');
575
+ try {
576
+ writeFully(handle, content, 0);
577
+ // Force the contents to disk before the rename publishes them.
578
+ nodeFs.fsyncSync(handle);
579
+ }
580
+ finally {
581
+ nodeFs.closeSync(handle);
582
+ }
583
+ if (this.view.source === 'primary' && nodeFs.existsSync(this.statePath)) {
584
+ // The snapshot being replaced becomes the backup. Its journal is kept
585
+ // below, so the backup remains a complete state on its own.
586
+ const backupTemp = `${this.backupPath}.${process.pid}.tmp`;
587
+ try {
588
+ nodeFs.unlinkSync(backupTemp);
589
+ }
590
+ catch { /* absent */ }
591
+ try {
592
+ nodeFs.linkSync(this.statePath, backupTemp);
593
+ }
594
+ catch {
595
+ nodeFs.copyFileSync(this.statePath, backupTemp);
596
+ }
597
+ nodeFs.renameSync(backupTemp, this.backupPath);
598
+ }
599
+ // Atomic rename commits the new snapshot.
600
+ nodeFs.renameSync(tempPath, this.statePath);
601
+ this.syncDirectory();
602
+ }
603
+ catch (error) {
604
+ try {
605
+ nodeFs.unlinkSync(tempPath);
606
+ }
607
+ catch { /* absent */ }
608
+ throw new Error(`Failed to save FeltDB state: ${error}`);
609
+ }
610
+ for (const name of nodeFs.readdirSync(this.dataPath)) {
611
+ const match = JOURNAL_FILE.exec(name);
612
+ if (!match)
613
+ continue;
614
+ const kept = Number(match[1]);
615
+ if (kept === generation || kept === previousGeneration)
616
+ continue;
617
+ try {
618
+ nodeFs.unlinkSync(getPathSync().join(this.dataPath, name));
619
+ }
620
+ catch { /* already gone */ }
141
621
  }
622
+ this.view = {
623
+ snapshotKey: this.snapshotKey(),
624
+ format: 'v2',
625
+ source: 'primary',
626
+ generation,
627
+ commit,
628
+ journalOffset: 0,
629
+ journalRecords: 0,
630
+ snapshotBytes: content.length,
631
+ tornBytes: 0,
632
+ };
633
+ this.profiler.bytes('snapshot', content.length);
634
+ this.profiler.record('compaction', started);
142
635
  }
636
+ syncDirectory() {
637
+ const nodeFs = getFsSync();
638
+ // Persist the rename itself, so the commit survives power loss.
639
+ try {
640
+ const directory = nodeFs.openSync(this.dataPath, 'r');
641
+ try {
642
+ nodeFs.fsyncSync(directory);
643
+ }
644
+ finally {
645
+ nodeFs.closeSync(directory);
646
+ }
647
+ }
648
+ catch {
649
+ // Directory fsync is not available on every platform. The rename is
650
+ // still atomic; only the durability of the rename itself is weaker.
651
+ }
652
+ }
653
+ /**
654
+ * Fold the journal into a new snapshot now. Logical state is unchanged;
655
+ * only its physical representation is. Normal commits never do this.
656
+ */
657
+ compact() {
658
+ this.withMutationLock(() => this.compactLocked());
659
+ return this.persistence_status();
660
+ }
661
+ /** How the durable state is currently represented on disk. */
662
+ persistence_status() {
663
+ this.refresh();
664
+ const view = this.view;
665
+ return {
666
+ format: view.format === 'v2' ? 'snapshot+journal' : view.format === 'v1' ? 'legacy-snapshot' : 'none',
667
+ source: view.source,
668
+ generation: view.generation,
669
+ commit: view.commit,
670
+ sequence: this.state.sequence,
671
+ snapshotBytes: view.snapshotBytes,
672
+ journalBytes: view.journalOffset,
673
+ journalRecords: view.journalRecords,
674
+ recovery: view.tornBytes > 0
675
+ ? { tornTail: { byteOffset: view.journalOffset, discardedBytes: view.tornBytes } }
676
+ : 'clean',
677
+ };
678
+ }
679
+ /**
680
+ * Persistence timings collected by this handle: mutation, serialization,
681
+ * write, fsync, commit, compaction and recovery. Empty unless profiling was
682
+ * enabled with the `profile` option or `FELTDB_PERSISTENCE_PROFILE=1`.
683
+ */
684
+ persistence_profile(options = {}) {
685
+ return this.profiler.summary(options.reset ?? false);
686
+ }
687
+ /* ------------------------------------------------------------------------
688
+ * Cross-process lock
689
+ * ---------------------------------------------------------------------- */
143
690
  isStalelock() {
144
691
  const nodeFs = getFsSync();
145
692
  const STALE_LOCK_THRESHOLD_MS = 5000;
@@ -264,110 +811,32 @@ export class FileJsDb {
264
811
  }
265
812
  withMutationLock(operation) {
266
813
  this.acquireLockSync();
814
+ const started = this.profiler.now();
267
815
  try {
268
816
  this.refresh();
269
817
  return operation();
270
818
  }
271
819
  finally {
820
+ this.profiler.record('mutation', started);
272
821
  this.releaseLockAsync();
273
822
  }
274
823
  }
275
- refresh() {
276
- this.state = this.loadState();
277
- this.peers.clear();
278
- this.restorePeers();
279
- this.applied.clear();
280
- for (const transactionId of this.state.applied_transactions ?? [])
281
- this.applied.add(transactionId);
282
- if (this.queryEngine && this.indexedSequence !== this.state.sequence) {
283
- this.queryEngine.rebuild();
284
- this.indexedSequence = this.state.sequence;
285
- }
286
- }
287
- saveState() {
288
- const nodeFs = getFsSync();
289
- const nodePathModule = getPathSync();
290
- try {
291
- // Update peers in state
292
- this.state.peers = [...this.peers];
293
- // Serialize complete snapshot (single logical state)
294
- const snapshot = {
295
- version: 1,
296
- timestamp: Date.now(),
297
- meta: {
298
- instance_id: this.origin,
299
- sequence: this.state.sequence,
300
- peers: this.state.peers,
301
- sequences: this.state.sequences
302
- },
303
- rows: this.state.rows,
304
- events: this.state.events,
305
- applied_transactions: [...this.applied]
306
- };
307
- const content = JSON.stringify(snapshot, null, 2);
308
- // Write to temporary file
309
- const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
310
- const handle = nodeFs.openSync(tempPath, 'w');
311
- try {
312
- nodeFs.writeFileSync(handle, content, 'utf-8');
313
- // Force the contents to disk before the rename publishes them.
314
- // A rename is a metadata operation: without this, a crash can leave
315
- // the new name pointing at a file whose contents never landed.
316
- nodeFs.fsyncSync(handle);
317
- }
318
- finally {
319
- nodeFs.closeSync(handle);
320
- }
321
- // Atomic rename commits the new state
322
- nodeFs.renameSync(tempPath, this.statePath);
323
- // Persist the rename itself, so the commit survives power loss.
324
- try {
325
- const directory = nodeFs.openSync(nodePathModule.dirname(this.statePath), 'r');
326
- try {
327
- nodeFs.fsyncSync(directory);
328
- }
329
- finally {
330
- nodeFs.closeSync(directory);
331
- }
332
- }
333
- catch {
334
- // Directory fsync is not available on every platform. The rename is
335
- // still atomic; only the durability of the rename itself is weaker.
336
- }
337
- }
338
- catch (error) {
339
- throw new Error(`Failed to save FeltDB state: ${error}`);
340
- }
341
- }
342
- event(collection, key, type, value) {
343
- this.state.sequence += 1;
344
- const timestamp = Date.now();
345
- this.state.events.push({
346
- sequence: this.state.sequence,
347
- collection,
348
- key,
349
- type,
350
- value,
351
- timestamp,
352
- origin: this.origin,
353
- id: `${this.origin}:${timestamp}:${this.state.sequence}`
354
- });
355
- this.queryEngine?.apply(key, type === 'put' ? this.state.rows[key] : undefined);
356
- this.indexedSequence = this.state.sequence;
357
- }
824
+ /* ------------------------------------------------------------------------
825
+ * JsDb
826
+ * ---------------------------------------------------------------------- */
358
827
  /**
359
828
  * Commit several operations atomically and durably.
360
829
  *
361
- * The snapshot is published by writing a temporary file, fsyncing it, and
362
- * renaming it over the previous one. The rename is atomic, so a reader sees
363
- * either the whole transaction or none of it, and a crash at any point
364
- * leaves one of those two states on disk — never a mixture.
830
+ * The whole transaction is one journal record, and a record is either
831
+ * terminated — committed, every mutation in it — or discarded on recovery.
832
+ * A reader or a restart sees the whole transaction or none of it.
365
833
  *
366
- * Order matters here. The next state is built and persisted *before* it is
367
- * adopted in memory, so a failed write cannot leave memory ahead of disk.
834
+ * Order matters here. The commit is made durable *before* it is applied in
835
+ * memory, so a failed write cannot leave memory ahead of disk.
368
836
  */
369
837
  async commit_transaction(request) {
370
838
  await this.acquireLockAsync();
839
+ const started = this.profiler.now();
371
840
  try {
372
841
  this.refresh();
373
842
  const before = this.state.sequence;
@@ -380,53 +849,22 @@ export class FileJsDb {
380
849
  return embeddedResult(request, true, before, this.state.sequence);
381
850
  if (plan.kind === 'reject')
382
851
  throw new EmbeddedTransactionRejected(plan.reason, plan.failure);
383
- // Build the post-transaction state without touching the live one.
384
- // Critical: Deep-copy rows to prevent stale references after refresh
385
- const rows = {};
386
- for (const [key, value] of Object.entries(this.state.rows)) {
387
- rows[key] = value;
388
- }
389
- const events = [...this.state.events];
390
- let sequence = this.state.sequence;
852
+ const body = this.stage();
391
853
  const timestamp = Date.now();
392
854
  for (const mutation of plan.mutations) {
393
- sequence += 1;
394
- if (mutation.value === undefined) {
395
- delete rows[mutation.key];
396
- }
397
- else {
398
- rows[mutation.key] = mutation.value;
399
- }
400
- events.push({
401
- sequence,
402
- collection: mutation.collection,
403
- key: mutation.key,
404
- type: mutation.value === undefined ? 'delete' : 'put',
405
- value: mutation.value,
406
- timestamp,
407
- origin: this.origin,
408
- id: `${this.origin}:${timestamp}:${sequence}`,
409
- });
410
- }
411
- const previous = this.state;
412
- this.state = { ...this.state, rows, events, sequence };
413
- // The transaction id has to be part of the snapshot being written, not
414
- // added after it. Recording it afterwards would leave a committed
415
- // transaction whose identity did not survive a restart, and redelivery
416
- // would then apply it a second time.
417
- this.applied.add(request.transactionId);
418
- try {
419
- this.saveState();
420
- }
421
- catch (error) {
422
- // The commit did not become durable, so it must not be visible either.
423
- this.state = previous;
424
- this.applied.delete(request.transactionId);
425
- throw error;
855
+ this.stageRow(body, mutation.key, mutation.value);
856
+ this.stageEvent(body, mutation.collection, mutation.key, mutation.value === undefined ? 'delete' : 'put', mutation.value, timestamp);
426
857
  }
427
- return embeddedResult(request, false, before, sequence);
858
+ // The transaction id is part of the commit being written, not added
859
+ // after it. Recording it afterwards would leave a committed transaction
860
+ // whose identity did not survive a restart, and redelivery would then
861
+ // apply it a second time.
862
+ body.t = request.transactionId;
863
+ this.persist(body);
864
+ return embeddedResult(request, false, before, body.s);
428
865
  }
429
866
  finally {
867
+ this.profiler.record('mutation', started);
430
868
  this.releaseLockAsync();
431
869
  }
432
870
  }
@@ -434,10 +872,9 @@ export class FileJsDb {
434
872
  try {
435
873
  const parsed = JSON.parse(value);
436
874
  return this.withMutationLock(() => {
437
- this.state.rows[key] = parsed;
438
- const collection = key.split(':')[0];
439
- this.event(collection, key, 'put', parsed);
440
- this.saveState();
875
+ const body = this.stage();
876
+ this.stagePut(body, key, parsed);
877
+ this.persist(body);
441
878
  return { success: true, data: key };
442
879
  });
443
880
  }
@@ -451,9 +888,9 @@ export class FileJsDb {
451
888
  return this.withMutationLock(() => {
452
889
  if (!(key in this.state.rows))
453
890
  return { success: false, error: `Record ${key} not found` };
454
- this.state.rows[key] = parsed;
455
- this.event(key.split(':')[0], key, 'put', parsed);
456
- this.saveState();
891
+ const body = this.stage();
892
+ this.stagePut(body, key, parsed);
893
+ this.persist(body);
457
894
  return { success: true, data: key };
458
895
  });
459
896
  }
@@ -473,9 +910,10 @@ export class FileJsDb {
473
910
  return this.withMutationLock(() => {
474
911
  if (!(key in this.state.rows))
475
912
  return { success: false, error: `Record ${key} not found` };
476
- delete this.state.rows[key];
477
- this.event(key.split(':')[0], key, 'delete');
478
- this.saveState();
913
+ const body = this.stage();
914
+ this.stageRow(body, key, undefined);
915
+ this.stageEvent(body, key.split(':')[0], key, 'delete');
916
+ this.persist(body);
479
917
  return { success: true, data: key };
480
918
  });
481
919
  }
@@ -531,10 +969,20 @@ export class FileJsDb {
531
969
  return { success: true };
532
970
  }
533
971
  add_sync_peer(peerId) {
534
- return this.withMutationLock(() => { this.peers.add(peerId); this.saveState(); return { success: true }; });
972
+ return this.withMutationLock(() => {
973
+ const body = this.stage();
974
+ body.p = [...new Set([...this.peers, peerId])];
975
+ this.persist(body);
976
+ return { success: true };
977
+ });
535
978
  }
536
979
  remove_sync_peer(peerId) {
537
- return this.withMutationLock(() => { this.peers.delete(peerId); this.saveState(); return { success: true }; });
980
+ return this.withMutationLock(() => {
981
+ const body = this.stage();
982
+ body.p = [...this.peers].filter(peer => peer !== peerId);
983
+ this.persist(body);
984
+ return { success: true };
985
+ });
538
986
  }
539
987
  acknowledge_peer_operations() {
540
988
  return { success: true };
@@ -593,62 +1041,63 @@ export class FileJsDb {
593
1041
  return this.withMutationLock(() => {
594
1042
  let applied = 0;
595
1043
  let ignored = 0;
1044
+ const body = this.stage();
1045
+ const staged = () => body.e ?? [];
596
1046
  for (const operation of operations) {
597
1047
  // Skip if we've already applied this operation
598
- if (this.state.events.some(event => event.id === operation.id)) {
1048
+ if (staged().some(event => event.id === operation.id)
1049
+ || this.state.events.some(event => event.id === operation.id)) {
599
1050
  ignored++;
600
1051
  continue;
601
1052
  }
602
1053
  // Find the current state of this key
603
- const current = [...this.state.events].reverse().find(event => event.key === operation.key);
1054
+ const current = [...staged()].reverse().find(event => event.key === operation.key)
1055
+ ?? [...this.state.events].reverse().find(event => event.key === operation.key);
604
1056
  // Use lamport-clock-style ordering: timestamp, then origin, then id
605
1057
  const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
606
1058
  const currentOrder = current ? `${String(current.timestamp).padStart(16, '0')}:${current.origin}:${current.id}` : '';
607
- this.state.sequence += 1;
608
- this.state.events.push({ ...operation, sequence: this.state.sequence });
1059
+ body.s += 1;
1060
+ (body.e ??= []).push({ ...operation, sequence: body.s });
609
1061
  // Apply if incoming is newer
610
1062
  if (incomingOrder >= currentOrder) {
611
- if (operation.type === 'delete') {
612
- delete this.state.rows[operation.key];
613
- }
614
- else {
615
- this.state.rows[operation.key] = operation.value;
616
- }
1063
+ this.stageRow(body, operation.key, operation.type === 'delete' ? undefined : operation.value);
617
1064
  applied++;
618
1065
  }
619
1066
  else {
620
1067
  ignored++;
621
1068
  }
622
1069
  }
623
- // Save state after applying remote operations
1070
+ // Persist after applying remote operations
624
1071
  if (applied > 0) {
625
- this.saveState();
1072
+ this.persist(body);
626
1073
  }
627
1074
  return { applied, ignored };
628
1075
  });
629
1076
  }
630
1077
  async allocateSequence(params) {
631
1078
  await this.acquireLockAsync();
1079
+ const started = this.profiler.now();
632
1080
  try {
633
1081
  this.refresh();
634
1082
  const key = `${params.scope}:${params.scopeId}`;
635
1083
  const next = (this.state.sequences[key] ?? 0) + 1;
636
- this.state.sequences[key] = next;
637
- this.saveState();
1084
+ const body = this.stage();
1085
+ body.q = { [key]: next };
1086
+ this.persist(body);
638
1087
  return next;
639
1088
  }
640
1089
  finally {
1090
+ this.profiler.record('mutation', started);
641
1091
  this.releaseLockAsync();
642
1092
  }
643
1093
  }
644
1094
  async putIfAbsent(key, value) {
645
- const nodeFs = getFsSync();
646
1095
  try {
647
1096
  await this.acquireLockAsync();
648
1097
  try {
649
1098
  const parsed = JSON.parse(value);
650
- // Reload state from disk to ensure we have the latest
651
- this.state = this.loadState();
1099
+ // Bring state up to date with disk to ensure we have the latest
1100
+ this.refresh();
652
1101
  // Check if key exists (atomic read within lock boundary)
653
1102
  if (key in this.state.rows) {
654
1103
  // Already exists, return the existing value
@@ -659,10 +1108,9 @@ export class FileJsDb {
659
1108
  };
660
1109
  }
661
1110
  // Key doesn't exist, insert it atomically within lock
662
- this.state.rows[key] = parsed;
663
- const collection = key.split(':')[0];
664
- this.event(collection, key, 'put', parsed);
665
- this.saveState();
1111
+ const body = this.stage();
1112
+ this.stagePut(body, key, parsed);
1113
+ this.persist(body);
666
1114
  return {
667
1115
  inserted: true,
668
1116
  value: value
@@ -696,7 +1144,6 @@ export class FileJsDb {
696
1144
  metadata: input.metadata,
697
1145
  authorityScope: input.authorityScope,
698
1146
  };
699
- // Use putIfAbsent to atomically store the operation
700
1147
  // Key format: "operation:{idempotencyKey}" for idempotency lookup
701
1148
  // Key format: "op-id:{operationId}" for direct operation lookup
702
1149
  const idempotencyKey = `operation:${input.idempotencyKey}`;
@@ -710,11 +1157,10 @@ export class FileJsDb {
710
1157
  ? { inserted: true, value: operationValue }
711
1158
  : { inserted: false, value: JSON.stringify(present) };
712
1159
  if (result.inserted) {
713
- this.state.rows[idempotencyKey] = durableOperation;
714
- this.state.rows[operationIdKey] = durableOperation;
715
- this.event('operation', idempotencyKey, 'put', durableOperation);
716
- this.event('op-id', operationIdKey, 'put', durableOperation);
717
- this.saveState();
1160
+ const body = this.stage();
1161
+ this.stagePut(body, idempotencyKey, durableOperation);
1162
+ this.stagePut(body, operationIdKey, durableOperation);
1163
+ this.persist(body);
718
1164
  }
719
1165
  // Parse the existing or newly inserted operation
720
1166
  const existingOperation = JSON.parse(result.value);
@@ -749,8 +1195,8 @@ export class FileJsDb {
749
1195
  try {
750
1196
  await this.acquireLockAsync();
751
1197
  try {
752
- // Reload state from disk to ensure we have latest
753
- this.state = this.loadState();
1198
+ // Bring state up to date with disk to ensure we have latest
1199
+ this.refresh();
754
1200
  // Look up operation by its operationId
755
1201
  const operationIdKey = `op-id:${input.operationId}`;
756
1202
  // Get current operation state
@@ -797,14 +1243,15 @@ export class FileJsDb {
797
1243
  error: input.error ?? currentOperation.error,
798
1244
  };
799
1245
  // Persist the transition atomically within lock (update both indices)
800
- this.state.rows[operationIdKey] = transitionedOperation;
1246
+ const body = this.stage();
1247
+ this.stageRow(body, operationIdKey, transitionedOperation);
801
1248
  const idempotencyIndexKey = `operation:${currentOperation.idempotencyKey}`;
802
1249
  if (idempotencyIndexKey in this.state.rows) {
803
- this.state.rows[idempotencyIndexKey] = transitionedOperation;
1250
+ this.stageRow(body, idempotencyIndexKey, transitionedOperation);
804
1251
  }
805
1252
  const collection = operationIdKey.split(':')[0];
806
- this.event(collection, operationIdKey, 'put', transitionedOperation);
807
- this.saveState();
1253
+ this.stageEvent(body, collection, operationIdKey, 'put', transitionedOperation);
1254
+ this.persist(body);
808
1255
  return {
809
1256
  transitioned: true,
810
1257
  operation: transitionedOperation,
@@ -831,11 +1278,11 @@ export class FileJsDb {
831
1278
  console.log(`[CAS-${casId}] lock acquired`);
832
1279
  try {
833
1280
  const parsed = JSON.parse(params.value);
834
- // CRITICAL: Reload state from disk while holding lock
1281
+ // CRITICAL: Bring state up to date with disk while holding lock
835
1282
  // This ensures we see the authoritative version, not just our in-memory cache
836
1283
  if (debugMode)
837
1284
  console.log(`[CAS-${casId}] reloading state from disk...`);
838
- this.state = this.loadState();
1285
+ this.refresh();
839
1286
  if (debugMode)
840
1287
  console.log(`[CAS-${casId}] state reloaded`);
841
1288
  // Check if key exists
@@ -883,13 +1330,12 @@ export class FileJsDb {
883
1330
  if (debugMode)
884
1331
  console.log(`[CAS-${casId}] version match - updating from ${currentVersion} to ${params.expectedVersion + 1}`);
885
1332
  const newValue = { ...parsed, __version: params.expectedVersion + 1 };
886
- this.state.rows[params.key] = newValue;
887
- const collection = params.key.split(':')[0];
888
- this.event(collection, params.key, 'put', newValue);
889
- // Persist atomically (temp file + rename)
1333
+ const body = this.stage();
1334
+ this.stagePut(body, params.key, newValue);
1335
+ // Persist durably (journal commit)
890
1336
  if (debugMode)
891
1337
  console.log(`[CAS-${casId}] saving state to disk...`);
892
- this.saveState();
1338
+ this.persist(body);
893
1339
  if (debugMode)
894
1340
  console.log(`[CAS-${casId}] state saved, returning SUCCESS`);
895
1341
  return {