@feltdb/core 0.8.2 → 0.8.3

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.
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.8.2';
3
+ export const FELTDB_PACKAGE_VERSION = '0.8.3';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
package/dist/file-db.d.ts CHANGED
@@ -25,6 +25,7 @@ export declare class FileJsDb implements JsDb {
25
25
  private readonly origin;
26
26
  private readonly peers;
27
27
  private lockFd;
28
+ private lockToken;
28
29
  private readonly applied;
29
30
  constructor(path: string);
30
31
  private loadState;
@@ -34,6 +35,10 @@ export declare class FileJsDb implements JsDb {
34
35
  private cleanupStaleLock;
35
36
  private acquireLockAsync;
36
37
  private releaseLockAsync;
38
+ private ownsLock;
39
+ private acquireLockSync;
40
+ private withMutationLock;
41
+ private refresh;
37
42
  private saveState;
38
43
  private event;
39
44
  /**
@@ -64,21 +69,9 @@ export declare class FileJsDb implements JsDb {
64
69
  instance_id(): string;
65
70
  get_sequence(): number;
66
71
  /**
67
- * The file runtime declines revision validation, and this is the honest answer
68
- * rather than a missing feature.
69
- *
70
- * A shared directory has no serialization authority over its writers.
71
- * `insert`, `update`, `delete`, and `commit_transaction` do not take the
72
- * cross-process lock, and publication is a whole-snapshot rename, so two
73
- * processes writing the same directory lose data. `state.sequence` therefore
74
- * cannot distinguish "one writer committed 50" from "two writers committed 50
75
- * each and one snapshot won" — it produces a plausible but false answer,
76
- * which is worse than producing none.
77
- *
78
- * Measured in tools/cli/test/file-runtime-concurrency.test.mjs and analysed in
79
- * docs/architecture/freshness-revision-investigation.md. Making this runtime
80
- * revision-capable requires fixing lock discipline and publication together;
81
- * advertising `refresh` is correct until then, not a placeholder.
72
+ * The file runtime uses explicit refresh validation. Cross-process mutations
73
+ * are serialized and reads reload the committed snapshot, but the public read
74
+ * contract does not yet return a scoped revision alongside its value.
82
75
  */
83
76
  freshness(): FreshnessCapability;
84
77
  audit_events(): PortableEvent[];
@@ -1 +1 @@
1
- {"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"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"}
package/dist/file-db.js CHANGED
@@ -21,6 +21,7 @@ export class FileJsDb {
21
21
  origin;
22
22
  peers = new Set();
23
23
  lockFd = null;
24
+ lockToken = null;
24
25
  applied = new Set();
25
26
  constructor(path) {
26
27
  const nodePath = getPathSync();
@@ -105,11 +106,25 @@ export class FileJsDb {
105
106
  }
106
107
  const stat = nodeFs.statSync(this.lockPath);
107
108
  const ageMs = Date.now() - stat.mtimeMs;
108
- // Lock is stale if it's older than 5 seconds
109
- if (ageMs > STALE_LOCK_THRESHOLD_MS) {
110
- return true;
109
+ if (ageMs <= STALE_LOCK_THRESHOLD_MS)
110
+ return false;
111
+ try {
112
+ const owner = JSON.parse(nodeFs.readFileSync(this.lockPath, 'utf8'));
113
+ if (typeof owner.pid === 'number') {
114
+ try {
115
+ process.kill(owner.pid, 0);
116
+ return false;
117
+ }
118
+ catch (error) {
119
+ return error?.code === 'ESRCH';
120
+ }
121
+ }
111
122
  }
112
- return false;
123
+ catch {
124
+ // Legacy locks did not contain ownership metadata. Their age is the
125
+ // only recovery signal available.
126
+ }
127
+ return true;
113
128
  }
114
129
  catch {
115
130
  return false;
@@ -134,9 +149,13 @@ export class FileJsDb {
134
149
  this.cleanupStaleLock();
135
150
  for (let attempt = 0; attempt < maxRetries; attempt++) {
136
151
  try {
152
+ const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
137
153
  const fd = nodeFs.openSync(this.lockPath, 'wx', 0o600);
154
+ nodeFs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
155
+ nodeFs.fsyncSync(fd);
138
156
  nodeFs.closeSync(fd);
139
157
  this.lockFd = 1;
158
+ this.lockToken = token;
140
159
  return;
141
160
  }
142
161
  catch (err) {
@@ -156,7 +175,7 @@ export class FileJsDb {
156
175
  releaseLockAsync() {
157
176
  const nodeFs = getFsSync();
158
177
  try {
159
- if (nodeFs.existsSync(this.lockPath)) {
178
+ if (nodeFs.existsSync(this.lockPath) && this.ownsLock()) {
160
179
  nodeFs.unlinkSync(this.lockPath);
161
180
  }
162
181
  }
@@ -164,6 +183,59 @@ export class FileJsDb {
164
183
  console.warn('Error releasing lock:', err);
165
184
  }
166
185
  this.lockFd = null;
186
+ this.lockToken = null;
187
+ }
188
+ ownsLock() {
189
+ if (!this.lockToken)
190
+ return false;
191
+ try {
192
+ const owner = JSON.parse(getFsSync().readFileSync(this.lockPath, 'utf8'));
193
+ return owner.token === this.lockToken;
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ }
199
+ acquireLockSync() {
200
+ const nodeFs = getFsSync();
201
+ this.cleanupStaleLock();
202
+ for (let attempt = 0; attempt < 1000; attempt += 1) {
203
+ try {
204
+ const token = `${process.pid}:${Date.now()}:${Math.random().toString(36).slice(2)}`;
205
+ const fd = nodeFs.openSync(this.lockPath, 'wx', 0o600);
206
+ nodeFs.writeFileSync(fd, JSON.stringify({ pid: process.pid, token }));
207
+ nodeFs.fsyncSync(fd);
208
+ nodeFs.closeSync(fd);
209
+ this.lockFd = 1;
210
+ this.lockToken = token;
211
+ return;
212
+ }
213
+ catch (error) {
214
+ if (error?.code !== 'EEXIST' || attempt === 999)
215
+ throw error;
216
+ if (attempt % 10 === 0)
217
+ this.cleanupStaleLock();
218
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
219
+ }
220
+ }
221
+ }
222
+ withMutationLock(operation) {
223
+ this.acquireLockSync();
224
+ try {
225
+ this.refresh();
226
+ return operation();
227
+ }
228
+ finally {
229
+ this.releaseLockAsync();
230
+ }
231
+ }
232
+ refresh() {
233
+ this.state = this.loadState();
234
+ this.peers.clear();
235
+ this.restorePeers();
236
+ this.applied.clear();
237
+ for (const transactionId of this.state.applied_transactions ?? [])
238
+ this.applied.add(transactionId);
167
239
  }
168
240
  saveState() {
169
241
  const nodeFs = getFsSync();
@@ -187,7 +259,7 @@ export class FileJsDb {
187
259
  };
188
260
  const content = JSON.stringify(snapshot, null, 2);
189
261
  // Write to temporary file
190
- const tempPath = this.statePath + '.tmp';
262
+ const tempPath = `${this.statePath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
191
263
  const handle = nodeFs.openSync(tempPath, 'w');
192
264
  try {
193
265
  nodeFs.writeFileSync(handle, content, 'utf-8');
@@ -246,96 +318,120 @@ export class FileJsDb {
246
318
  * adopted in memory, so a failed write cannot leave memory ahead of disk.
247
319
  */
248
320
  async commit_transaction(request) {
249
- const before = this.state.sequence;
250
- const plan = decideTransaction(request, {
251
- exists: key => key in this.state.rows,
252
- hasApplied: transactionId => this.applied.has(transactionId),
253
- read: key => this.state.rows[key],
254
- });
255
- if (plan.kind === 'duplicate') {
256
- return embeddedResult(request, true, before, this.state.sequence);
257
- }
258
- if (plan.kind === 'reject') {
259
- throw new EmbeddedTransactionRejected(plan.reason);
260
- }
261
- // Build the post-transaction state without touching the live one.
262
- const rows = { ...this.state.rows };
263
- const events = [...this.state.events];
264
- let sequence = this.state.sequence;
265
- const timestamp = Date.now();
266
- for (const mutation of plan.mutations) {
267
- sequence += 1;
268
- if (mutation.value === undefined) {
269
- delete rows[mutation.key];
321
+ await this.acquireLockAsync();
322
+ try {
323
+ this.refresh();
324
+ const before = this.state.sequence;
325
+ const plan = decideTransaction(request, {
326
+ exists: key => key in this.state.rows,
327
+ hasApplied: transactionId => this.applied.has(transactionId),
328
+ read: key => this.state.rows[key],
329
+ });
330
+ if (plan.kind === 'duplicate')
331
+ return embeddedResult(request, true, before, this.state.sequence);
332
+ if (plan.kind === 'reject')
333
+ throw new EmbeddedTransactionRejected(plan.reason);
334
+ // Build the post-transaction state without touching the live one.
335
+ const rows = { ...this.state.rows };
336
+ const events = [...this.state.events];
337
+ let sequence = this.state.sequence;
338
+ const timestamp = Date.now();
339
+ for (const mutation of plan.mutations) {
340
+ sequence += 1;
341
+ if (mutation.value === undefined) {
342
+ delete rows[mutation.key];
343
+ }
344
+ else {
345
+ rows[mutation.key] = mutation.value;
346
+ }
347
+ events.push({
348
+ sequence,
349
+ collection: mutation.collection,
350
+ key: mutation.key,
351
+ type: mutation.value === undefined ? 'delete' : 'put',
352
+ value: mutation.value,
353
+ timestamp,
354
+ origin: this.origin,
355
+ id: `${this.origin}:${timestamp}:${sequence}`,
356
+ });
270
357
  }
271
- else {
272
- rows[mutation.key] = mutation.value;
358
+ const previous = this.state;
359
+ this.state = { ...this.state, rows, events, sequence };
360
+ // The transaction id has to be part of the snapshot being written, not
361
+ // added after it. Recording it afterwards would leave a committed
362
+ // transaction whose identity did not survive a restart, and redelivery
363
+ // would then apply it a second time.
364
+ this.applied.add(request.transactionId);
365
+ try {
366
+ this.saveState();
273
367
  }
274
- events.push({
275
- sequence,
276
- collection: mutation.collection,
277
- key: mutation.key,
278
- type: mutation.value === undefined ? 'delete' : 'put',
279
- value: mutation.value,
280
- timestamp,
281
- origin: this.origin,
282
- id: `${this.origin}:${timestamp}:${sequence}`,
283
- });
284
- }
285
- const previous = this.state;
286
- this.state = { ...this.state, rows, events, sequence };
287
- // The transaction id has to be part of the snapshot being written, not
288
- // added after it. Recording it afterwards would leave a committed
289
- // transaction whose identity did not survive a restart, and redelivery
290
- // would then apply it a second time.
291
- this.applied.add(request.transactionId);
292
- try {
293
- this.saveState();
368
+ catch (error) {
369
+ // The commit did not become durable, so it must not be visible either.
370
+ this.state = previous;
371
+ this.applied.delete(request.transactionId);
372
+ throw error;
373
+ }
374
+ return embeddedResult(request, false, before, sequence);
294
375
  }
295
- catch (error) {
296
- // The commit did not become durable, so it must not be visible either.
297
- this.state = previous;
298
- this.applied.delete(request.transactionId);
299
- throw error;
376
+ finally {
377
+ this.releaseLockAsync();
300
378
  }
301
- return embeddedResult(request, false, before, sequence);
302
379
  }
303
380
  insert(key, value) {
304
381
  try {
305
382
  const parsed = JSON.parse(value);
306
- this.state.rows[key] = parsed;
307
- const collection = key.split(':')[0];
308
- this.event(collection, key, 'put', parsed);
309
- this.saveState();
310
- return { success: true, data: key };
383
+ return this.withMutationLock(() => {
384
+ this.state.rows[key] = parsed;
385
+ const collection = key.split(':')[0];
386
+ this.event(collection, key, 'put', parsed);
387
+ this.saveState();
388
+ return { success: true, data: key };
389
+ });
311
390
  }
312
391
  catch (error) {
313
392
  return { success: false, error: String(error) };
314
393
  }
315
394
  }
316
395
  update(key, value) {
317
- if (!(key in this.state.rows)) {
318
- return { success: false, error: `Record ${key} not found` };
396
+ try {
397
+ const parsed = JSON.parse(value);
398
+ return this.withMutationLock(() => {
399
+ if (!(key in this.state.rows))
400
+ return { success: false, error: `Record ${key} not found` };
401
+ this.state.rows[key] = parsed;
402
+ this.event(key.split(':')[0], key, 'put', parsed);
403
+ this.saveState();
404
+ return { success: true, data: key };
405
+ });
406
+ }
407
+ catch (error) {
408
+ return { success: false, error: String(error) };
319
409
  }
320
- return this.insert(key, value);
321
410
  }
322
411
  get(key) {
412
+ this.refresh();
323
413
  const value = this.state.rows[key];
324
414
  return value === undefined
325
415
  ? { success: true }
326
416
  : { success: true, data: JSON.stringify(value) };
327
417
  }
328
418
  delete(key) {
329
- if (!(key in this.state.rows)) {
330
- return { success: false, error: `Record ${key} not found` };
419
+ try {
420
+ return this.withMutationLock(() => {
421
+ if (!(key in this.state.rows))
422
+ return { success: false, error: `Record ${key} not found` };
423
+ delete this.state.rows[key];
424
+ this.event(key.split(':')[0], key, 'delete');
425
+ this.saveState();
426
+ return { success: true, data: key };
427
+ });
428
+ }
429
+ catch (error) {
430
+ return { success: false, error: String(error) };
331
431
  }
332
- delete this.state.rows[key];
333
- const collection = key.split(':')[0];
334
- this.event(collection, key, 'delete');
335
- this.saveState();
336
- return { success: true, data: key };
337
432
  }
338
433
  query(capability) {
434
+ this.refresh();
339
435
  const prefix = `${capability}:`;
340
436
  const values = Object.entries(this.state.rows)
341
437
  .filter(([key]) => key.startsWith(prefix))
@@ -349,6 +445,7 @@ export class FileJsDb {
349
445
  return { success: false, error: 'Raw operations are unavailable in the file runtime' };
350
446
  }
351
447
  sync_info() {
448
+ this.refresh();
352
449
  return {
353
450
  success: true,
354
451
  data: JSON.stringify({
@@ -368,12 +465,10 @@ export class FileJsDb {
368
465
  return { success: true };
369
466
  }
370
467
  add_sync_peer(peerId) {
371
- this.peers.add(peerId);
372
- return { success: true };
468
+ return this.withMutationLock(() => { this.peers.add(peerId); this.saveState(); return { success: true }; });
373
469
  }
374
470
  remove_sync_peer(peerId) {
375
- this.peers.delete(peerId);
376
- return { success: true };
471
+ return this.withMutationLock(() => { this.peers.delete(peerId); this.saveState(); return { success: true }; });
377
472
  }
378
473
  acknowledge_peer_operations() {
379
474
  return { success: true };
@@ -385,83 +480,82 @@ export class FileJsDb {
385
480
  return this.origin;
386
481
  }
387
482
  get_sequence() {
483
+ this.refresh();
388
484
  return this.state.sequence;
389
485
  }
390
486
  /**
391
- * The file runtime declines revision validation, and this is the honest answer
392
- * rather than a missing feature.
393
- *
394
- * A shared directory has no serialization authority over its writers.
395
- * `insert`, `update`, `delete`, and `commit_transaction` do not take the
396
- * cross-process lock, and publication is a whole-snapshot rename, so two
397
- * processes writing the same directory lose data. `state.sequence` therefore
398
- * cannot distinguish "one writer committed 50" from "two writers committed 50
399
- * each and one snapshot won" — it produces a plausible but false answer,
400
- * which is worse than producing none.
401
- *
402
- * Measured in tools/cli/test/file-runtime-concurrency.test.mjs and analysed in
403
- * docs/architecture/freshness-revision-investigation.md. Making this runtime
404
- * revision-capable requires fixing lock discipline and publication together;
405
- * advertising `refresh` is correct until then, not a placeholder.
487
+ * The file runtime uses explicit refresh validation. Cross-process mutations
488
+ * are serialized and reads reload the committed snapshot, but the public read
489
+ * contract does not yet return a scoped revision alongside its value.
406
490
  */
407
491
  freshness() {
408
492
  return {
409
493
  domain: 'multi-process-file',
410
494
  validation: 'refresh',
411
495
  invalidation: false,
412
- reason: 'a shared directory has no serialization authority over its writers, '
413
- + 'so state.sequence cannot be an authoritative revision',
496
+ reason: 'reads refresh from the serialized file authority; the public read '
497
+ + 'contract does not yet expose a scoped revision token',
414
498
  };
415
499
  }
416
500
  audit_events() {
501
+ this.refresh();
417
502
  return [...this.state.events];
418
503
  }
419
504
  export_operations(sinceSequence) {
505
+ this.refresh();
420
506
  return this.state.events.filter(event => event.sequence > sinceSequence);
421
507
  }
422
508
  apply_remote_operations(operations) {
423
- let applied = 0;
424
- let ignored = 0;
425
- for (const operation of operations) {
426
- // Skip if we've already applied this operation
427
- if (this.state.events.some(event => event.id === operation.id)) {
428
- ignored++;
429
- continue;
430
- }
431
- // Find the current state of this key
432
- const current = [...this.state.events].reverse().find(event => event.key === operation.key);
433
- // Use lamport-clock-style ordering: timestamp, then origin, then id
434
- const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
435
- const currentOrder = current ? `${String(current.timestamp).padStart(16, '0')}:${current.origin}:${current.id}` : '';
436
- this.state.sequence += 1;
437
- this.state.events.push({ ...operation, sequence: this.state.sequence });
438
- // Apply if incoming is newer
439
- if (incomingOrder >= currentOrder) {
440
- if (operation.type === 'delete') {
441
- delete this.state.rows[operation.key];
509
+ return this.withMutationLock(() => {
510
+ let applied = 0;
511
+ let ignored = 0;
512
+ for (const operation of operations) {
513
+ // Skip if we've already applied this operation
514
+ if (this.state.events.some(event => event.id === operation.id)) {
515
+ ignored++;
516
+ continue;
517
+ }
518
+ // Find the current state of this key
519
+ const current = [...this.state.events].reverse().find(event => event.key === operation.key);
520
+ // Use lamport-clock-style ordering: timestamp, then origin, then id
521
+ const incomingOrder = `${String(operation.timestamp).padStart(16, '0')}:${operation.origin}:${operation.id}`;
522
+ const currentOrder = current ? `${String(current.timestamp).padStart(16, '0')}:${current.origin}:${current.id}` : '';
523
+ this.state.sequence += 1;
524
+ this.state.events.push({ ...operation, sequence: this.state.sequence });
525
+ // Apply if incoming is newer
526
+ if (incomingOrder >= currentOrder) {
527
+ if (operation.type === 'delete') {
528
+ delete this.state.rows[operation.key];
529
+ }
530
+ else {
531
+ this.state.rows[operation.key] = operation.value;
532
+ }
533
+ applied++;
442
534
  }
443
535
  else {
444
- this.state.rows[operation.key] = operation.value;
536
+ ignored++;
445
537
  }
446
- applied++;
447
538
  }
448
- else {
449
- ignored++;
539
+ // Save state after applying remote operations
540
+ if (applied > 0) {
541
+ this.saveState();
450
542
  }
451
- }
452
- // Save state after applying remote operations
453
- if (applied > 0) {
454
- this.saveState();
455
- }
456
- return { applied, ignored };
543
+ return { applied, ignored };
544
+ });
457
545
  }
458
546
  async allocateSequence(params) {
459
- const key = `${params.scope}:${params.scopeId}`;
460
- const current = this.state.sequences[key] ?? 0;
461
- const next = current + 1;
462
- this.state.sequences[key] = next;
463
- this.saveState();
464
- return next;
547
+ await this.acquireLockAsync();
548
+ try {
549
+ this.refresh();
550
+ const key = `${params.scope}:${params.scopeId}`;
551
+ const next = (this.state.sequences[key] ?? 0) + 1;
552
+ this.state.sequences[key] = next;
553
+ this.saveState();
554
+ return next;
555
+ }
556
+ finally {
557
+ this.releaseLockAsync();
558
+ }
465
559
  }
466
560
  async putIfAbsent(key, value) {
467
561
  const nodeFs = getFsSync();
@@ -524,10 +618,17 @@ export class FileJsDb {
524
618
  const operationIdKey = `op-id:${operationId}`;
525
619
  const operationValue = JSON.stringify(durableOperation);
526
620
  try {
527
- const result = await this.putIfAbsent(idempotencyKey, operationValue);
528
- // Also store by operationId for direct lookups
621
+ await this.acquireLockAsync();
622
+ this.refresh();
623
+ const present = this.state.rows[idempotencyKey];
624
+ const result = present === undefined
625
+ ? { inserted: true, value: operationValue }
626
+ : { inserted: false, value: JSON.stringify(present) };
529
627
  if (result.inserted) {
628
+ this.state.rows[idempotencyKey] = durableOperation;
530
629
  this.state.rows[operationIdKey] = durableOperation;
630
+ this.event('operation', idempotencyKey, 'put', durableOperation);
631
+ this.event('op-id', operationIdKey, 'put', durableOperation);
531
632
  this.saveState();
532
633
  }
533
634
  // Parse the existing or newly inserted operation
@@ -548,6 +649,9 @@ export class FileJsDb {
548
649
  catch (error) {
549
650
  throw new Error(`Operation admission failed: ${error}`);
550
651
  }
652
+ finally {
653
+ this.releaseLockAsync();
654
+ }
551
655
  }
552
656
  async transitionOperation(input) {
553
657
  // Validate input
@@ -722,7 +826,7 @@ export class FileJsDb {
722
826
  }
723
827
  }
724
828
  close() {
725
- // Ensure final state is persisted
726
- this.saveState();
829
+ // Mutations are durable before they return. Closing a stale handle must
830
+ // never rewrite authority state produced by another process.
727
831
  }
728
832
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feltdb/core",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "FeltDB Core - Application-facing database with Browser, Local, and Server runtimes. Durable state, reactive APIs.",
5
5
  "license": "MIT",
6
6
  "type": "module",