@feltdb/core 0.11.6 → 0.11.8

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/dist/file-db.js CHANGED
@@ -4,6 +4,33 @@ 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';
9
+ /** The newest local file snapshot format this build reads and writes. */
10
+ export const FILE_SNAPSHOT_FORMAT_VERSION = 2;
11
+ /**
12
+ * The runtime directory was written in a durable format newer than this build
13
+ * understands.
14
+ *
15
+ * Refused rather than recovered around: treating a newer snapshot as invalid
16
+ * and falling back to an older backup — or to an empty database — silently
17
+ * discards every commit the newer format carries. That is exactly what a
18
+ * package-only rollback onto upgraded data would do, so it fails closed.
19
+ */
20
+ export class UnsupportedDurableFormatError extends Error {
21
+ statePath;
22
+ found;
23
+ code = 'UNSUPPORTED_DURABLE_FORMAT';
24
+ constructor(statePath, found) {
25
+ super(`UNSUPPORTED_DURABLE_FORMAT: ${statePath} is in local durable format ${found}; this FeltDB `
26
+ + `build reads formats 1-${FILE_SNAPSHOT_FORMAT_VERSION}. It was written by a newer FeltDB. `
27
+ + 'The database was not opened and was not modified. Upgrade FeltDB, or restore the backup '
28
+ + 'taken before the upgrade.');
29
+ this.statePath = statePath;
30
+ this.found = found;
31
+ this.name = 'UnsupportedDurableFormatError';
32
+ }
33
+ }
7
34
  // Synchronous versions for sync API (will throw if called in browser)
8
35
  function getFsSync() {
9
36
  if (typeof nodeFs.existsSync === 'function')
@@ -15,11 +42,24 @@ function getPathSync() {
15
42
  return nodePath;
16
43
  throw new Error('FileJsDb is only available in Node.js environments');
17
44
  }
45
+ const DEFAULT_MIN_JOURNAL_BYTES = 8 * 1024 * 1024;
46
+ const JOURNAL_FILE = /^state\.(\d+)\.journal$/;
47
+ function isMissing(error) {
48
+ return error?.code === 'ENOENT';
49
+ }
50
+ function writeFully(fd, bytes, position) {
51
+ let written = 0;
52
+ while (written < bytes.length) {
53
+ written += getFsSync().writeSync(fd, bytes, written, bytes.length - written, position + written);
54
+ }
55
+ }
18
56
  export class FileJsDb {
19
57
  dataPath;
20
58
  statePath;
59
+ backupPath;
21
60
  lockPath;
22
61
  state;
62
+ view;
23
63
  origin;
24
64
  peers = new Set();
25
65
  lockFd = null;
@@ -27,7 +67,10 @@ export class FileJsDb {
27
67
  applied = new Set();
28
68
  queryEngine;
29
69
  indexedSequence = -1;
30
- constructor(path) {
70
+ profiler;
71
+ compaction;
72
+ compactionScheduled = false;
73
+ constructor(path, options = {}) {
31
74
  const nodePath = getPathSync();
32
75
  const nodeFs = getFsSync();
33
76
  // Normalize and resolve the path to prevent directory traversal
@@ -38,7 +81,14 @@ export class FileJsDb {
38
81
  }
39
82
  this.dataPath = resolvedPath;
40
83
  this.statePath = nodePath.join(this.dataPath, 'state.json');
84
+ this.backupPath = `${this.statePath}.backup`;
41
85
  this.lockPath = nodePath.join(this.dataPath, 'state.lock');
86
+ this.profiler = new PersistenceProfiler(options.profile ?? (typeof process !== 'undefined' && process.env?.FELTDB_PERSISTENCE_PROFILE === '1'));
87
+ this.compaction = {
88
+ mode: options.compaction?.mode ?? 'auto',
89
+ minJournalBytes: options.compaction?.minJournalBytes ?? DEFAULT_MIN_JOURNAL_BYTES,
90
+ journalToSnapshotRatio: options.compaction?.journalToSnapshotRatio ?? 1,
91
+ };
42
92
  // Create directory if it doesn't exist
43
93
  if (!nodeFs.existsSync(this.dataPath)) {
44
94
  nodeFs.mkdirSync(this.dataPath, { recursive: true });
@@ -48,80 +98,80 @@ export class FileJsDb {
48
98
  // Clean up any stale locks from previous crashes on startup
49
99
  this.cleanupStaleLock();
50
100
  // 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
- }
101
+ this.state = this.initializeState();
102
+ this.loadDurableState();
56
103
  this.queryEngine = new EmbeddedQueryEngine(() => Object.entries(this.state.rows));
57
104
  this.indexedSequence = this.state.sequence;
58
105
  }
59
- loadState() {
106
+ /* ------------------------------------------------------------------------
107
+ * Durable layout
108
+ * ---------------------------------------------------------------------- */
109
+ journalPath(generation) {
110
+ return getPathSync().join(this.dataPath, `state.${generation}.journal`);
111
+ }
112
+ snapshotKey() {
113
+ try {
114
+ const stat = getFsSync().statSync(this.statePath, { bigint: true });
115
+ return `${stat.ino}:${stat.size}:${stat.mtimeNs}`;
116
+ }
117
+ catch (error) {
118
+ if (isMissing(error))
119
+ return 'none';
120
+ throw error;
121
+ }
122
+ }
123
+ parseSnapshot(path) {
124
+ const content = getFsSync().readFileSync(path, 'utf-8');
125
+ const snapshot = JSON.parse(content);
126
+ if (typeof snapshot.version === 'number' && snapshot.version > FILE_SNAPSHOT_FORMAT_VERSION) {
127
+ throw new UnsupportedDurableFormatError(path, snapshot.version);
128
+ }
129
+ const known = snapshot.version === 1
130
+ || (snapshot.version === 2 && Number.isInteger(snapshot.journal?.generation) && Number.isInteger(snapshot.journal?.commit));
131
+ if (!known || !snapshot.meta || !snapshot.rows || !snapshot.events)
132
+ return null;
133
+ return { snapshot, bytes: Buffer.byteLength(content) };
134
+ }
135
+ /**
136
+ * Read the snapshot this handle should start from: the primary, or the
137
+ * backup when the primary cannot be read (unchanged from the single-file
138
+ * runtime), or nothing at all.
139
+ */
140
+ readSnapshot() {
60
141
  const nodeFs = getFsSync();
61
- const backupPath = `${this.statePath}.backup`;
62
- // Load atomic snapshot if it exists
63
142
  if (nodeFs.existsSync(this.statePath)) {
64
143
  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
- };
144
+ const parsed = this.parseSnapshot(this.statePath);
145
+ if (parsed)
146
+ return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'primary' };
147
+ console.warn(`Invalid snapshot structure at ${this.statePath}, attempting backup recovery`);
89
148
  }
90
149
  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();
150
+ if (error instanceof UnsupportedDurableFormatError)
151
+ throw error;
152
+ if (!isMissing(error)) {
153
+ console.warn(`Failed to load snapshot from ${this.statePath}: ${error}`);
154
+ console.warn(`Attempting to recover from backup...`);
155
+ }
156
+ else {
157
+ return { snapshot: null, bytes: 0, source: 'empty' };
158
+ }
94
159
  }
95
- }
96
- return this.initializeState();
97
- }
98
- loadStateFromBackup() {
99
- const nodeFs = getFsSync();
100
- const backupPath = `${this.statePath}.backup`;
101
- if (nodeFs.existsSync(backupPath)) {
102
160
  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
- };
161
+ const parsed = this.parseSnapshot(this.backupPath);
162
+ if (parsed) {
163
+ console.warn(`Recovered state from backup at ${this.backupPath}`);
164
+ return { snapshot: parsed.snapshot, bytes: parsed.bytes, source: 'backup' };
117
165
  }
118
166
  }
119
- catch {
167
+ catch (error) {
168
+ if (error instanceof UnsupportedDurableFormatError)
169
+ throw error;
120
170
  // Backup is also corrupted or invalid
121
171
  }
172
+ console.warn(`Backup recovery failed, reinitializing state`);
122
173
  }
123
- console.warn(`Backup recovery failed, reinitializing state`);
124
- return this.initializeState();
174
+ return { snapshot: null, bytes: 0, source: 'empty' };
125
175
  }
126
176
  initializeState() {
127
177
  return {
@@ -135,11 +185,540 @@ export class FileJsDb {
135
185
  applied_transactions: []
136
186
  };
137
187
  }
138
- restorePeers() {
139
- for (const peer of this.state.peers) {
188
+ /**
189
+ * Materialize the durable state from scratch: snapshot, then every complete
190
+ * commit in its journal, in order. A journal that disappears while it is
191
+ * being read has been superseded by a compaction, so the read starts over
192
+ * from the new snapshot. One that is missing for the current snapshot is
193
+ * corruption.
194
+ */
195
+ loadDurableState() {
196
+ const started = this.profiler.now();
197
+ for (let attempt = 0;; attempt += 1) {
198
+ const snapshotKey = this.snapshotKey();
199
+ const { snapshot, bytes, source } = this.readSnapshot();
200
+ const state = snapshot ? {
201
+ version: snapshot.version,
202
+ instance_id: this.origin,
203
+ sequence: snapshot.meta.sequence,
204
+ rows: snapshot.rows,
205
+ events: snapshot.events,
206
+ peers: snapshot.meta.peers ?? [],
207
+ sequences: snapshot.meta.sequences ?? {},
208
+ applied_transactions: snapshot.applied_transactions ?? [],
209
+ } : this.initializeState();
210
+ const view = {
211
+ snapshotKey,
212
+ format: snapshot ? (snapshot.version === 2 ? 'v2' : 'v1') : 'none',
213
+ source,
214
+ generation: snapshot?.journal?.generation ?? 0,
215
+ commit: snapshot?.journal?.commit ?? 0,
216
+ journalOffset: 0,
217
+ journalRecords: 0,
218
+ snapshotBytes: bytes,
219
+ tornBytes: 0,
220
+ };
221
+ let records = [];
222
+ if (view.format === 'v2') {
223
+ const journalPath = this.journalPath(view.generation);
224
+ let journal;
225
+ try {
226
+ journal = getFsSync().readFileSync(journalPath);
227
+ }
228
+ catch (error) {
229
+ if (!isMissing(error))
230
+ throw error;
231
+ if (attempt < 8 && this.snapshotKey() !== snapshotKey)
232
+ continue;
233
+ if (source === 'backup') {
234
+ console.warn(`Backup snapshot journal ${journalPath} is missing, reinitializing state`);
235
+ this.adopt(this.initializeState(), { ...view, format: 'none', source: 'empty', generation: 0, commit: 0 });
236
+ this.profiler.record('recovery', started);
237
+ return;
238
+ }
239
+ throw new JournalCorruptionError(journalPath, 1, 0, 'the journal named by the current snapshot is missing');
240
+ }
241
+ let scan;
242
+ try {
243
+ scan = scanJournal(journal, {
244
+ journalPath, generation: view.generation, expectedCommit: view.commit + 1,
245
+ });
246
+ }
247
+ catch (error) {
248
+ // A reader holds no lock, so a writer repairing a torn tail can be
249
+ // observed mid-way. Real corruption is still there on a re-read.
250
+ if (attempt < 3 && error instanceof JournalCorruptionError)
251
+ continue;
252
+ throw error;
253
+ }
254
+ records = scan.records;
255
+ view.journalOffset = scan.consumed;
256
+ view.journalRecords = scan.records.length;
257
+ view.tornBytes = scan.tornBytes;
258
+ view.commit += scan.records.length;
259
+ }
260
+ if (source === 'backup') {
261
+ // The unreadable primary was the compaction of this backup and its
262
+ // journal, so the journals written after it continue the backup's
263
+ // commits exactly. Replaying them recovers everything that primary
264
+ // and its journal held, instead of stopping at the backup.
265
+ for (let generation = view.generation + 1;; generation += 1) {
266
+ const journalPath = this.journalPath(generation);
267
+ let journal;
268
+ try {
269
+ journal = getFsSync().readFileSync(journalPath);
270
+ }
271
+ catch (error) {
272
+ if (isMissing(error))
273
+ break;
274
+ throw error;
275
+ }
276
+ const scan = scanJournal(journal, {
277
+ journalPath, generation, expectedCommit: view.commit + 1,
278
+ });
279
+ records.push(...scan.records);
280
+ view.format = 'v2';
281
+ view.generation = generation;
282
+ view.journalOffset = scan.consumed;
283
+ view.journalRecords = scan.records.length;
284
+ view.tornBytes = scan.tornBytes;
285
+ view.commit += scan.records.length;
286
+ }
287
+ }
288
+ this.adopt(state, view);
289
+ for (const record of records)
290
+ this.applyCommit(record.body, false);
291
+ this.profiler.record('recovery', started);
292
+ return;
293
+ }
294
+ }
295
+ adopt(state, view) {
296
+ this.state = state;
297
+ this.view = view;
298
+ this.peers.clear();
299
+ for (const peer of state.peers)
140
300
  this.peers.add(peer);
301
+ this.applied.clear();
302
+ for (const transactionId of state.applied_transactions ?? [])
303
+ this.applied.add(transactionId);
304
+ }
305
+ /** Apply one commit to memory. The only way any mutation reaches memory. */
306
+ applyCommit(body, index) {
307
+ for (const mutation of body.m ?? []) {
308
+ const [key] = mutation;
309
+ if (mutation.length === 1)
310
+ delete this.state.rows[key];
311
+ else
312
+ this.state.rows[key] = mutation[1];
313
+ if (index)
314
+ this.queryEngine?.apply(key, mutation.length === 1 ? undefined : mutation[1]);
315
+ }
316
+ for (const event of body.e ?? [])
317
+ this.state.events.push(event);
318
+ if (body.q)
319
+ Object.assign(this.state.sequences, body.q);
320
+ if (body.p) {
321
+ this.state.peers = [...body.p];
322
+ this.peers.clear();
323
+ for (const peer of body.p)
324
+ this.peers.add(peer);
325
+ }
326
+ if (body.t !== undefined && !this.applied.has(body.t)) {
327
+ this.applied.add(body.t);
328
+ this.state.applied_transactions.push(body.t);
329
+ }
330
+ this.state.sequence = body.s;
331
+ if (index)
332
+ this.indexedSequence = this.state.sequence;
333
+ }
334
+ /**
335
+ * Bring memory up to date with the durable files.
336
+ *
337
+ * While the snapshot is unchanged, only journal bytes after the last
338
+ * complete record already applied are read, so a read costs what was
339
+ * committed since the previous read — not the size of all history. Any
340
+ * change to the snapshot, or a journal that no longer continues what was
341
+ * applied, falls back to a complete load.
342
+ */
343
+ refresh() {
344
+ const key = this.snapshotKey();
345
+ if (key === this.view.snapshotKey) {
346
+ if (this.view.format !== 'v2')
347
+ return;
348
+ if (this.tailJournal())
349
+ return;
350
+ }
351
+ this.loadDurableState();
352
+ this.queryEngine.rebuild();
353
+ this.indexedSequence = this.state.sequence;
354
+ }
355
+ /** Apply newly committed journal records. False when a full load is required. */
356
+ tailJournal() {
357
+ const nodeFs = getFsSync();
358
+ const journalPath = this.journalPath(this.view.generation);
359
+ let fd;
360
+ try {
361
+ fd = nodeFs.openSync(journalPath, 'r');
362
+ }
363
+ catch (error) {
364
+ if (isMissing(error))
365
+ return false;
366
+ throw error;
367
+ }
368
+ try {
369
+ for (let attempt = 0;; attempt += 1) {
370
+ const size = nodeFs.fstatSync(fd).size;
371
+ if (size < this.view.journalOffset)
372
+ return false;
373
+ const length = size - this.view.journalOffset;
374
+ if (length === 0) {
375
+ this.view.tornBytes = 0;
376
+ return true;
377
+ }
378
+ const bytes = Buffer.allocUnsafe(length);
379
+ let read = 0;
380
+ while (read < length) {
381
+ const count = nodeFs.readSync(fd, bytes, read, length - read, this.view.journalOffset + read);
382
+ if (count === 0)
383
+ break;
384
+ read += count;
385
+ }
386
+ let scan;
387
+ try {
388
+ scan = scanJournal(bytes.subarray(0, read), {
389
+ journalPath,
390
+ generation: this.view.generation,
391
+ expectedCommit: this.view.commit + 1,
392
+ baseOffset: this.view.journalOffset,
393
+ firstRecord: this.view.journalRecords + 1,
394
+ });
395
+ }
396
+ catch (error) {
397
+ if (attempt < 3 && error instanceof JournalCorruptionError && this.lockToken === null)
398
+ continue;
399
+ throw error;
400
+ }
401
+ for (const record of scan.records)
402
+ this.applyCommit(record.body, true);
403
+ this.view.journalOffset += scan.consumed;
404
+ this.view.journalRecords += scan.records.length;
405
+ this.view.commit += scan.records.length;
406
+ this.view.tornBytes = scan.tornBytes;
407
+ return true;
408
+ }
409
+ }
410
+ finally {
411
+ nodeFs.closeSync(fd);
412
+ }
413
+ }
414
+ /* ------------------------------------------------------------------------
415
+ * Commit protocol
416
+ * ---------------------------------------------------------------------- */
417
+ /** A commit body starting from the current sequence. */
418
+ stage() {
419
+ return { s: this.state.sequence };
420
+ }
421
+ stageRow(body, key, value) {
422
+ (body.m ??= []).push(value === undefined ? [key] : [key, value]);
423
+ }
424
+ stageEvent(body, collection, key, type, value, timestamp = Date.now()) {
425
+ body.s += 1;
426
+ (body.e ??= []).push({
427
+ sequence: body.s,
428
+ collection,
429
+ key,
430
+ type,
431
+ value,
432
+ timestamp,
433
+ origin: this.origin,
434
+ id: `${this.origin}:${timestamp}:${body.s}`,
435
+ });
436
+ }
437
+ /** Stage a row write together with the event that records it. */
438
+ stagePut(body, key, value) {
439
+ this.stageRow(body, key, value);
440
+ this.stageEvent(body, key.split(':')[0], key, 'put', value);
441
+ }
442
+ /**
443
+ * Make one commit durable, then visible. Caller holds the lock and has
444
+ * refreshed, so memory reflects every earlier commit.
445
+ *
446
+ * 1 body framed and written, without its terminator
447
+ * 2 fdatasync ← the record's bytes are durable
448
+ * 3 terminator written ← the commit boundary exists
449
+ * 4 fdatasync ← the boundary is durable
450
+ * 5 applied to memory, success returned
451
+ *
452
+ * A crash before 3 leaves an unterminated record that recovery discards; it
453
+ * was never acknowledged. Readers only accept terminated records, so no
454
+ * process can observe a commit whose bytes were not already durable. A
455
+ * failure at any step removes whatever was written, so a failed commit is
456
+ * not visible after it returns either.
457
+ */
458
+ persist(body) {
459
+ const nodeFs = getFsSync();
460
+ if (this.view.format !== 'v2' || this.view.source !== 'primary') {
461
+ // First write to a fresh, legacy, or backup-recovered directory
462
+ // establishes a journal-continued snapshot of the current state.
463
+ this.compactLocked();
464
+ }
465
+ const commitStarted = this.profiler.now();
466
+ const commit = this.view.commit + 1;
467
+ const serializationStarted = this.profiler.now();
468
+ const { header, body: bodyBytes } = encodeRecord(this.view.generation, commit, body);
469
+ const record = Buffer.concat([header, bodyBytes]);
470
+ this.profiler.record('serialization', serializationStarted);
471
+ const offset = this.view.journalOffset;
472
+ const fd = nodeFs.openSync(this.journalPath(this.view.generation), 'r+');
473
+ try {
474
+ try {
475
+ if (this.view.tornBytes > 0) {
476
+ // An earlier writer died mid-append. Its record was never
477
+ // acknowledged; remove it before anything is written after it.
478
+ console.warn(`FeltDB recovered an incomplete journal append at byte ${offset} `
479
+ + `(${this.view.tornBytes} bytes discarded) in ${this.journalPath(this.view.generation)}`);
480
+ nodeFs.ftruncateSync(fd, offset);
481
+ this.timedSync(fd);
482
+ this.view.tornBytes = 0;
483
+ }
484
+ let started = this.profiler.now();
485
+ writeFully(fd, record, offset);
486
+ this.profiler.record('write', started);
487
+ this.timedSync(fd);
488
+ started = this.profiler.now();
489
+ writeFully(fd, Buffer.from('\n'), offset + record.length);
490
+ this.profiler.record('write', started);
491
+ this.timedSync(fd);
492
+ }
493
+ catch (error) {
494
+ try {
495
+ nodeFs.ftruncateSync(fd, offset);
496
+ nodeFs.fdatasyncSync(fd);
497
+ }
498
+ catch {
499
+ // The unterminated remainder is discarded by the next writer.
500
+ }
501
+ throw new Error(`Failed to save FeltDB state: ${error}`);
502
+ }
503
+ }
504
+ finally {
505
+ nodeFs.closeSync(fd);
506
+ }
507
+ this.view.journalOffset = offset + record.length + 1;
508
+ this.view.journalRecords += 1;
509
+ this.view.commit = commit;
510
+ this.profiler.bytes('journal', record.length + 1);
511
+ // Memory receives exactly the bytes that were made durable.
512
+ this.applyCommit(JSON.parse(bodyBytes.toString('utf8')), true);
513
+ this.profiler.record('commit', commitStarted);
514
+ this.scheduleCompaction();
515
+ }
516
+ timedSync(fd) {
517
+ const started = this.profiler.now();
518
+ getFsSync().fdatasyncSync(fd);
519
+ this.profiler.record('fsync', started);
520
+ }
521
+ /* ------------------------------------------------------------------------
522
+ * Compaction
523
+ * ---------------------------------------------------------------------- */
524
+ compactionDue() {
525
+ return this.view.format === 'v2'
526
+ && this.view.journalOffset >= this.compaction.minJournalBytes
527
+ && this.view.journalOffset >= this.view.snapshotBytes * this.compaction.journalToSnapshotRatio;
528
+ }
529
+ /**
530
+ * Compaction runs after the commit that made it due has been acknowledged,
531
+ * never as part of it, and under the same lock as every other writer.
532
+ */
533
+ scheduleCompaction() {
534
+ if (this.compaction.mode !== 'auto' || this.compactionScheduled || !this.compactionDue())
535
+ return;
536
+ this.compactionScheduled = true;
537
+ const timer = setTimeout(() => {
538
+ this.compactionScheduled = false;
539
+ try {
540
+ this.withMutationLock(() => { if (this.compactionDue())
541
+ this.compactLocked(); });
542
+ }
543
+ catch (error) {
544
+ // Committed data is already durable in the journal; compaction only
545
+ // reclaims it. A failed attempt is retried after a later commit.
546
+ console.warn(`FeltDB journal compaction failed: ${error}`);
547
+ }
548
+ }, 0);
549
+ timer.unref?.();
550
+ }
551
+ /**
552
+ * Rewrite the current state as a new snapshot and start an empty journal.
553
+ * Caller holds the lock and has refreshed.
554
+ *
555
+ * 1 new empty journal G+1 created and synced
556
+ * 2 snapshot naming (G+1, commit C) written to a temporary file, synced
557
+ * 3 previous snapshot linked as the backup
558
+ * 4 snapshot renamed into place, directory synced ← compaction commits
559
+ * 5 journals other than G and G+1 removed
560
+ *
561
+ * A crash before 4 leaves the previous snapshot and journal authoritative;
562
+ * an orphaned journal G+1 is never read and is removed by the next
563
+ * compaction. After 4 the new snapshot is complete by itself.
564
+ */
565
+ compactLocked() {
566
+ const nodeFs = getFsSync();
567
+ const started = this.profiler.now();
568
+ const previousGeneration = this.view.generation;
569
+ let generation = previousGeneration;
570
+ for (const name of nodeFs.readdirSync(this.dataPath)) {
571
+ const match = JOURNAL_FILE.exec(name);
572
+ if (match)
573
+ generation = Math.max(generation, Number(match[1]));
574
+ }
575
+ generation += 1;
576
+ const commit = this.view.commit;
577
+ const journal = nodeFs.openSync(this.journalPath(generation), 'w');
578
+ try {
579
+ nodeFs.fsyncSync(journal);
580
+ }
581
+ finally {
582
+ nodeFs.closeSync(journal);
583
+ }
584
+ // The journal's directory entry must be durable before a snapshot names it.
585
+ this.syncDirectory();
586
+ this.state.peers = [...this.peers];
587
+ const snapshot = {
588
+ version: FILE_SNAPSHOT_FORMAT_VERSION,
589
+ timestamp: Date.now(),
590
+ meta: {
591
+ instance_id: this.origin,
592
+ sequence: this.state.sequence,
593
+ peers: this.state.peers,
594
+ sequences: this.state.sequences,
595
+ },
596
+ rows: this.state.rows,
597
+ events: this.state.events,
598
+ applied_transactions: [...this.applied],
599
+ journal: { generation, commit },
600
+ };
601
+ // Compaction is profiled as one phase, so that it never inflates the
602
+ // serialization, write and fsync figures of ordinary commits.
603
+ const content = Buffer.from(JSON.stringify(snapshot), 'utf8');
604
+ const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
605
+ try {
606
+ const handle = nodeFs.openSync(tempPath, 'w');
607
+ try {
608
+ writeFully(handle, content, 0);
609
+ // Force the contents to disk before the rename publishes them.
610
+ nodeFs.fsyncSync(handle);
611
+ }
612
+ finally {
613
+ nodeFs.closeSync(handle);
614
+ }
615
+ if (this.view.source === 'primary' && nodeFs.existsSync(this.statePath)) {
616
+ // The snapshot being replaced becomes the backup. Its journal is kept
617
+ // below, so the backup remains a complete state on its own.
618
+ const backupTemp = `${this.backupPath}.${process.pid}.tmp`;
619
+ try {
620
+ nodeFs.unlinkSync(backupTemp);
621
+ }
622
+ catch { /* absent */ }
623
+ try {
624
+ nodeFs.linkSync(this.statePath, backupTemp);
625
+ }
626
+ catch {
627
+ nodeFs.copyFileSync(this.statePath, backupTemp);
628
+ }
629
+ nodeFs.renameSync(backupTemp, this.backupPath);
630
+ }
631
+ // Atomic rename commits the new snapshot.
632
+ nodeFs.renameSync(tempPath, this.statePath);
633
+ this.syncDirectory();
634
+ }
635
+ catch (error) {
636
+ try {
637
+ nodeFs.unlinkSync(tempPath);
638
+ }
639
+ catch { /* absent */ }
640
+ throw new Error(`Failed to save FeltDB state: ${error}`);
641
+ }
642
+ for (const name of nodeFs.readdirSync(this.dataPath)) {
643
+ const match = JOURNAL_FILE.exec(name);
644
+ if (!match)
645
+ continue;
646
+ const kept = Number(match[1]);
647
+ if (kept === generation || kept === previousGeneration)
648
+ continue;
649
+ try {
650
+ nodeFs.unlinkSync(getPathSync().join(this.dataPath, name));
651
+ }
652
+ catch { /* already gone */ }
141
653
  }
654
+ this.view = {
655
+ snapshotKey: this.snapshotKey(),
656
+ format: 'v2',
657
+ source: 'primary',
658
+ generation,
659
+ commit,
660
+ journalOffset: 0,
661
+ journalRecords: 0,
662
+ snapshotBytes: content.length,
663
+ tornBytes: 0,
664
+ };
665
+ this.profiler.bytes('snapshot', content.length);
666
+ this.profiler.record('compaction', started);
667
+ }
668
+ syncDirectory() {
669
+ const nodeFs = getFsSync();
670
+ // Persist the rename itself, so the commit survives power loss.
671
+ try {
672
+ const directory = nodeFs.openSync(this.dataPath, 'r');
673
+ try {
674
+ nodeFs.fsyncSync(directory);
675
+ }
676
+ finally {
677
+ nodeFs.closeSync(directory);
678
+ }
679
+ }
680
+ catch {
681
+ // Directory fsync is not available on every platform. The rename is
682
+ // still atomic; only the durability of the rename itself is weaker.
683
+ }
684
+ }
685
+ /**
686
+ * Fold the journal into a new snapshot now. Logical state is unchanged;
687
+ * only its physical representation is. Normal commits never do this.
688
+ */
689
+ compact() {
690
+ this.withMutationLock(() => this.compactLocked());
691
+ return this.persistence_status();
142
692
  }
693
+ /** How the durable state is currently represented on disk. */
694
+ persistence_status() {
695
+ this.refresh();
696
+ const view = this.view;
697
+ return {
698
+ format: view.format === 'v2' ? 'snapshot+journal' : view.format === 'v1' ? 'legacy-snapshot' : 'none',
699
+ source: view.source,
700
+ generation: view.generation,
701
+ commit: view.commit,
702
+ sequence: this.state.sequence,
703
+ snapshotBytes: view.snapshotBytes,
704
+ journalBytes: view.journalOffset,
705
+ journalRecords: view.journalRecords,
706
+ recovery: view.tornBytes > 0
707
+ ? { tornTail: { byteOffset: view.journalOffset, discardedBytes: view.tornBytes } }
708
+ : 'clean',
709
+ };
710
+ }
711
+ /**
712
+ * Persistence timings collected by this handle: mutation, serialization,
713
+ * write, fsync, commit, compaction and recovery. Empty unless profiling was
714
+ * enabled with the `profile` option or `FELTDB_PERSISTENCE_PROFILE=1`.
715
+ */
716
+ persistence_profile(options = {}) {
717
+ return this.profiler.summary(options.reset ?? false);
718
+ }
719
+ /* ------------------------------------------------------------------------
720
+ * Cross-process lock
721
+ * ---------------------------------------------------------------------- */
143
722
  isStalelock() {
144
723
  const nodeFs = getFsSync();
145
724
  const STALE_LOCK_THRESHOLD_MS = 5000;
@@ -264,110 +843,32 @@ export class FileJsDb {
264
843
  }
265
844
  withMutationLock(operation) {
266
845
  this.acquireLockSync();
846
+ const started = this.profiler.now();
267
847
  try {
268
848
  this.refresh();
269
849
  return operation();
270
850
  }
271
851
  finally {
852
+ this.profiler.record('mutation', started);
272
853
  this.releaseLockAsync();
273
854
  }
274
855
  }
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
- }
856
+ /* ------------------------------------------------------------------------
857
+ * JsDb
858
+ * ---------------------------------------------------------------------- */
358
859
  /**
359
860
  * Commit several operations atomically and durably.
360
861
  *
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.
862
+ * The whole transaction is one journal record, and a record is either
863
+ * terminated — committed, every mutation in it — or discarded on recovery.
864
+ * A reader or a restart sees the whole transaction or none of it.
365
865
  *
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.
866
+ * Order matters here. The commit is made durable *before* it is applied in
867
+ * memory, so a failed write cannot leave memory ahead of disk.
368
868
  */
369
869
  async commit_transaction(request) {
370
870
  await this.acquireLockAsync();
871
+ const started = this.profiler.now();
371
872
  try {
372
873
  this.refresh();
373
874
  const before = this.state.sequence;
@@ -380,53 +881,22 @@ export class FileJsDb {
380
881
  return embeddedResult(request, true, before, this.state.sequence);
381
882
  if (plan.kind === 'reject')
382
883
  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;
884
+ const body = this.stage();
391
885
  const timestamp = Date.now();
392
886
  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;
887
+ this.stageRow(body, mutation.key, mutation.value);
888
+ this.stageEvent(body, mutation.collection, mutation.key, mutation.value === undefined ? 'delete' : 'put', mutation.value, timestamp);
426
889
  }
427
- return embeddedResult(request, false, before, sequence);
890
+ // The transaction id is part of the commit being written, not added
891
+ // after it. Recording it afterwards would leave a committed transaction
892
+ // whose identity did not survive a restart, and redelivery would then
893
+ // apply it a second time.
894
+ body.t = request.transactionId;
895
+ this.persist(body);
896
+ return embeddedResult(request, false, before, body.s);
428
897
  }
429
898
  finally {
899
+ this.profiler.record('mutation', started);
430
900
  this.releaseLockAsync();
431
901
  }
432
902
  }
@@ -434,10 +904,9 @@ export class FileJsDb {
434
904
  try {
435
905
  const parsed = JSON.parse(value);
436
906
  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();
907
+ const body = this.stage();
908
+ this.stagePut(body, key, parsed);
909
+ this.persist(body);
441
910
  return { success: true, data: key };
442
911
  });
443
912
  }
@@ -451,9 +920,9 @@ export class FileJsDb {
451
920
  return this.withMutationLock(() => {
452
921
  if (!(key in this.state.rows))
453
922
  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();
923
+ const body = this.stage();
924
+ this.stagePut(body, key, parsed);
925
+ this.persist(body);
457
926
  return { success: true, data: key };
458
927
  });
459
928
  }
@@ -473,9 +942,10 @@ export class FileJsDb {
473
942
  return this.withMutationLock(() => {
474
943
  if (!(key in this.state.rows))
475
944
  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();
945
+ const body = this.stage();
946
+ this.stageRow(body, key, undefined);
947
+ this.stageEvent(body, key.split(':')[0], key, 'delete');
948
+ this.persist(body);
479
949
  return { success: true, data: key };
480
950
  });
481
951
  }
@@ -531,10 +1001,20 @@ export class FileJsDb {
531
1001
  return { success: true };
532
1002
  }
533
1003
  add_sync_peer(peerId) {
534
- return this.withMutationLock(() => { this.peers.add(peerId); this.saveState(); return { success: true }; });
1004
+ return this.withMutationLock(() => {
1005
+ const body = this.stage();
1006
+ body.p = [...new Set([...this.peers, peerId])];
1007
+ this.persist(body);
1008
+ return { success: true };
1009
+ });
535
1010
  }
536
1011
  remove_sync_peer(peerId) {
537
- return this.withMutationLock(() => { this.peers.delete(peerId); this.saveState(); return { success: true }; });
1012
+ return this.withMutationLock(() => {
1013
+ const body = this.stage();
1014
+ body.p = [...this.peers].filter(peer => peer !== peerId);
1015
+ this.persist(body);
1016
+ return { success: true };
1017
+ });
538
1018
  }
539
1019
  acknowledge_peer_operations() {
540
1020
  return { success: true };
@@ -593,62 +1073,63 @@ export class FileJsDb {
593
1073
  return this.withMutationLock(() => {
594
1074
  let applied = 0;
595
1075
  let ignored = 0;
1076
+ const body = this.stage();
1077
+ const staged = () => body.e ?? [];
596
1078
  for (const operation of operations) {
597
1079
  // Skip if we've already applied this operation
598
- if (this.state.events.some(event => event.id === operation.id)) {
1080
+ if (staged().some(event => event.id === operation.id)
1081
+ || this.state.events.some(event => event.id === operation.id)) {
599
1082
  ignored++;
600
1083
  continue;
601
1084
  }
602
1085
  // Find the current state of this key
603
- const current = [...this.state.events].reverse().find(event => event.key === operation.key);
1086
+ const current = [...staged()].reverse().find(event => event.key === operation.key)
1087
+ ?? [...this.state.events].reverse().find(event => event.key === operation.key);
604
1088
  // Use lamport-clock-style ordering: timestamp, then origin, then id
605
1089
  const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
606
1090
  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 });
1091
+ body.s += 1;
1092
+ (body.e ??= []).push({ ...operation, sequence: body.s });
609
1093
  // Apply if incoming is newer
610
1094
  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
- }
1095
+ this.stageRow(body, operation.key, operation.type === 'delete' ? undefined : operation.value);
617
1096
  applied++;
618
1097
  }
619
1098
  else {
620
1099
  ignored++;
621
1100
  }
622
1101
  }
623
- // Save state after applying remote operations
1102
+ // Persist after applying remote operations
624
1103
  if (applied > 0) {
625
- this.saveState();
1104
+ this.persist(body);
626
1105
  }
627
1106
  return { applied, ignored };
628
1107
  });
629
1108
  }
630
1109
  async allocateSequence(params) {
631
1110
  await this.acquireLockAsync();
1111
+ const started = this.profiler.now();
632
1112
  try {
633
1113
  this.refresh();
634
1114
  const key = `${params.scope}:${params.scopeId}`;
635
1115
  const next = (this.state.sequences[key] ?? 0) + 1;
636
- this.state.sequences[key] = next;
637
- this.saveState();
1116
+ const body = this.stage();
1117
+ body.q = { [key]: next };
1118
+ this.persist(body);
638
1119
  return next;
639
1120
  }
640
1121
  finally {
1122
+ this.profiler.record('mutation', started);
641
1123
  this.releaseLockAsync();
642
1124
  }
643
1125
  }
644
1126
  async putIfAbsent(key, value) {
645
- const nodeFs = getFsSync();
646
1127
  try {
647
1128
  await this.acquireLockAsync();
648
1129
  try {
649
1130
  const parsed = JSON.parse(value);
650
- // Reload state from disk to ensure we have the latest
651
- this.state = this.loadState();
1131
+ // Bring state up to date with disk to ensure we have the latest
1132
+ this.refresh();
652
1133
  // Check if key exists (atomic read within lock boundary)
653
1134
  if (key in this.state.rows) {
654
1135
  // Already exists, return the existing value
@@ -659,10 +1140,9 @@ export class FileJsDb {
659
1140
  };
660
1141
  }
661
1142
  // 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();
1143
+ const body = this.stage();
1144
+ this.stagePut(body, key, parsed);
1145
+ this.persist(body);
666
1146
  return {
667
1147
  inserted: true,
668
1148
  value: value
@@ -696,7 +1176,6 @@ export class FileJsDb {
696
1176
  metadata: input.metadata,
697
1177
  authorityScope: input.authorityScope,
698
1178
  };
699
- // Use putIfAbsent to atomically store the operation
700
1179
  // Key format: "operation:{idempotencyKey}" for idempotency lookup
701
1180
  // Key format: "op-id:{operationId}" for direct operation lookup
702
1181
  const idempotencyKey = `operation:${input.idempotencyKey}`;
@@ -710,11 +1189,10 @@ export class FileJsDb {
710
1189
  ? { inserted: true, value: operationValue }
711
1190
  : { inserted: false, value: JSON.stringify(present) };
712
1191
  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();
1192
+ const body = this.stage();
1193
+ this.stagePut(body, idempotencyKey, durableOperation);
1194
+ this.stagePut(body, operationIdKey, durableOperation);
1195
+ this.persist(body);
718
1196
  }
719
1197
  // Parse the existing or newly inserted operation
720
1198
  const existingOperation = JSON.parse(result.value);
@@ -749,8 +1227,8 @@ export class FileJsDb {
749
1227
  try {
750
1228
  await this.acquireLockAsync();
751
1229
  try {
752
- // Reload state from disk to ensure we have latest
753
- this.state = this.loadState();
1230
+ // Bring state up to date with disk to ensure we have latest
1231
+ this.refresh();
754
1232
  // Look up operation by its operationId
755
1233
  const operationIdKey = `op-id:${input.operationId}`;
756
1234
  // Get current operation state
@@ -797,14 +1275,15 @@ export class FileJsDb {
797
1275
  error: input.error ?? currentOperation.error,
798
1276
  };
799
1277
  // Persist the transition atomically within lock (update both indices)
800
- this.state.rows[operationIdKey] = transitionedOperation;
1278
+ const body = this.stage();
1279
+ this.stageRow(body, operationIdKey, transitionedOperation);
801
1280
  const idempotencyIndexKey = `operation:${currentOperation.idempotencyKey}`;
802
1281
  if (idempotencyIndexKey in this.state.rows) {
803
- this.state.rows[idempotencyIndexKey] = transitionedOperation;
1282
+ this.stageRow(body, idempotencyIndexKey, transitionedOperation);
804
1283
  }
805
1284
  const collection = operationIdKey.split(':')[0];
806
- this.event(collection, operationIdKey, 'put', transitionedOperation);
807
- this.saveState();
1285
+ this.stageEvent(body, collection, operationIdKey, 'put', transitionedOperation);
1286
+ this.persist(body);
808
1287
  return {
809
1288
  transitioned: true,
810
1289
  operation: transitionedOperation,
@@ -831,11 +1310,11 @@ export class FileJsDb {
831
1310
  console.log(`[CAS-${casId}] lock acquired`);
832
1311
  try {
833
1312
  const parsed = JSON.parse(params.value);
834
- // CRITICAL: Reload state from disk while holding lock
1313
+ // CRITICAL: Bring state up to date with disk while holding lock
835
1314
  // This ensures we see the authoritative version, not just our in-memory cache
836
1315
  if (debugMode)
837
1316
  console.log(`[CAS-${casId}] reloading state from disk...`);
838
- this.state = this.loadState();
1317
+ this.refresh();
839
1318
  if (debugMode)
840
1319
  console.log(`[CAS-${casId}] state reloaded`);
841
1320
  // Check if key exists
@@ -883,13 +1362,12 @@ export class FileJsDb {
883
1362
  if (debugMode)
884
1363
  console.log(`[CAS-${casId}] version match - updating from ${currentVersion} to ${params.expectedVersion + 1}`);
885
1364
  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)
1365
+ const body = this.stage();
1366
+ this.stagePut(body, params.key, newValue);
1367
+ // Persist durably (journal commit)
890
1368
  if (debugMode)
891
1369
  console.log(`[CAS-${casId}] saving state to disk...`);
892
- this.saveState();
1370
+ this.persist(body);
893
1371
  if (debugMode)
894
1372
  console.log(`[CAS-${casId}] state saved, returning SUCCESS`);
895
1373
  return {