@feltdb/core 0.4.16 → 0.4.20

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
@@ -1,3 +1,4 @@
1
+ import { validateOperationAdmissionInput, operationsConflict, generateOperationId, validateTransitionInput, isValidTransition, isTerminalStatus, isIdempotentTerminalTransition, } from './operation-admission.js';
1
2
  import { createRequire } from 'module';
2
3
  // Create a require function for use in ESM context
3
4
  let requireFunc = null;
@@ -46,6 +47,7 @@ function getPathSync() {
46
47
  export class FileJsDb {
47
48
  constructor(path) {
48
49
  this.peers = new Set();
50
+ this.lockFd = null;
49
51
  const nodePath = getPathSync();
50
52
  const nodeFs = getFsSync();
51
53
  // Normalize and resolve the path to prevent directory traversal
@@ -56,12 +58,15 @@ export class FileJsDb {
56
58
  }
57
59
  this.dataPath = resolvedPath;
58
60
  this.statePath = nodePath.join(this.dataPath, 'state.json');
61
+ this.lockPath = nodePath.join(this.dataPath, 'state.lock');
59
62
  // Create directory if it doesn't exist
60
63
  if (!nodeFs.existsSync(this.dataPath)) {
61
64
  nodeFs.mkdirSync(this.dataPath, { recursive: true });
62
65
  }
63
66
  // Generate stable instance ID based on path
64
67
  this.origin = `file:${this.dataPath}`;
68
+ // Clean up any stale locks from previous crashes on startup
69
+ this.cleanupStaleLock();
65
70
  // Load or initialize state
66
71
  this.state = this.loadState();
67
72
  this.restorePeers();
@@ -111,6 +116,75 @@ export class FileJsDb {
111
116
  this.peers.add(peer);
112
117
  }
113
118
  }
119
+ isStalelock() {
120
+ const nodeFs = getFsSync();
121
+ const STALE_LOCK_THRESHOLD_MS = 5000;
122
+ try {
123
+ if (!nodeFs.existsSync(this.lockPath)) {
124
+ return false;
125
+ }
126
+ const stat = nodeFs.statSync(this.lockPath);
127
+ const ageMs = Date.now() - stat.mtimeMs;
128
+ // Lock is stale if it's older than 5 seconds
129
+ if (ageMs > STALE_LOCK_THRESHOLD_MS) {
130
+ return true;
131
+ }
132
+ return false;
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
138
+ cleanupStaleLock() {
139
+ const nodeFs = getFsSync();
140
+ try {
141
+ if (this.isStalelock()) {
142
+ nodeFs.unlinkSync(this.lockPath);
143
+ }
144
+ }
145
+ catch (err) {
146
+ console.warn('Failed to clean up stale lock:', err);
147
+ }
148
+ }
149
+ async acquireLockAsync() {
150
+ const nodeFs = getFsSync();
151
+ const maxRetries = 1000;
152
+ const retryDelayMs = 5;
153
+ // Attempt to clean up any stale locks before trying to acquire
154
+ this.cleanupStaleLock();
155
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
156
+ try {
157
+ const fd = nodeFs.openSync(this.lockPath, 'wx', 0o600);
158
+ nodeFs.closeSync(fd);
159
+ this.lockFd = 1;
160
+ return;
161
+ }
162
+ catch (err) {
163
+ if (err.code === 'EEXIST' && attempt < maxRetries - 1) {
164
+ // After a few attempts, check if the lock is stale
165
+ if (attempt % 10 === 0) {
166
+ this.cleanupStaleLock();
167
+ }
168
+ await new Promise(resolve => setTimeout(resolve, retryDelayMs));
169
+ continue;
170
+ }
171
+ throw new Error(`Failed to acquire lock: ${err.message}`);
172
+ }
173
+ }
174
+ throw new Error(`Failed to acquire lock after ${maxRetries} attempts`);
175
+ }
176
+ releaseLockAsync() {
177
+ const nodeFs = getFsSync();
178
+ try {
179
+ if (nodeFs.existsSync(this.lockPath)) {
180
+ nodeFs.unlinkSync(this.lockPath);
181
+ }
182
+ }
183
+ catch (err) {
184
+ console.warn('Error releasing lock:', err);
185
+ }
186
+ this.lockFd = null;
187
+ }
114
188
  saveState() {
115
189
  const nodeFs = getFsSync();
116
190
  try {
@@ -292,29 +366,167 @@ export class FileJsDb {
292
366
  return next;
293
367
  }
294
368
  async putIfAbsent(key, value) {
369
+ const nodeFs = getFsSync();
295
370
  try {
296
- const parsed = JSON.parse(value);
297
- // Check if key exists (atomic read within single-state persistence)
298
- if (key in this.state.rows) {
299
- // Already exists, return the existing value
300
- const existing = this.state.rows[key];
371
+ await this.acquireLockAsync();
372
+ try {
373
+ const parsed = JSON.parse(value);
374
+ // Reload state from disk to ensure we have the latest
375
+ this.state = this.loadState();
376
+ // Check if key exists (atomic read within lock boundary)
377
+ if (key in this.state.rows) {
378
+ // Already exists, return the existing value
379
+ const existing = this.state.rows[key];
380
+ return {
381
+ inserted: false,
382
+ value: JSON.stringify(existing)
383
+ };
384
+ }
385
+ // Key doesn't exist, insert it atomically within lock
386
+ this.state.rows[key] = parsed;
387
+ const collection = key.split(':')[0];
388
+ this.event(collection, key, 'put', parsed);
389
+ this.saveState();
301
390
  return {
302
- inserted: false,
303
- value: JSON.stringify(existing)
391
+ inserted: true,
392
+ value: value
304
393
  };
305
394
  }
306
- // Key doesn't exist, insert it atomically
307
- this.state.rows[key] = parsed;
308
- const collection = key.split(':')[0];
309
- this.event(collection, key, 'put', parsed);
310
- this.saveState();
395
+ finally {
396
+ this.releaseLockAsync();
397
+ }
398
+ }
399
+ catch (error) {
400
+ throw new Error(`putIfAbsent failed: ${error}`);
401
+ }
402
+ }
403
+ async admitOperation(input) {
404
+ // Validate input
405
+ const validation = validateOperationAdmissionInput(input);
406
+ if (!validation.valid) {
407
+ throw new Error(`Operation admission validation failed: ${validation.error}`);
408
+ }
409
+ // Generate operation ID for this admission attempt
410
+ const operationId = generateOperationId();
411
+ // Create durable operation record
412
+ const durableOperation = {
413
+ operationId,
414
+ idempotencyKey: input.idempotencyKey,
415
+ kind: input.kind,
416
+ status: 'accepted',
417
+ version: 0,
418
+ createdAt: Date.now(),
419
+ operationFingerprint: input.operationFingerprint,
420
+ metadata: input.metadata,
421
+ };
422
+ // Use putIfAbsent to atomically store the operation
423
+ // Key format: "operation:{idempotencyKey}" for idempotency lookup
424
+ // Key format: "op-id:{operationId}" for direct operation lookup
425
+ const idempotencyKey = `operation:${input.idempotencyKey}`;
426
+ const operationIdKey = `op-id:${operationId}`;
427
+ const operationValue = JSON.stringify(durableOperation);
428
+ try {
429
+ const result = await this.putIfAbsent(idempotencyKey, operationValue);
430
+ // Also store by operationId for direct lookups
431
+ if (result.inserted) {
432
+ this.state.rows[operationIdKey] = durableOperation;
433
+ this.saveState();
434
+ }
435
+ // Parse the existing or newly inserted operation
436
+ const existingOperation = JSON.parse(result.value);
437
+ // Check for conflicts if operation already existed
438
+ if (!result.inserted) {
439
+ const conflict = operationsConflict(existingOperation, input);
440
+ if (conflict) {
441
+ throw new Error(conflict.message);
442
+ }
443
+ }
311
444
  return {
312
- inserted: true,
313
- value: value
445
+ admitted: result.inserted,
446
+ operationId: existingOperation.operationId,
447
+ operation: existingOperation,
314
448
  };
315
449
  }
316
450
  catch (error) {
317
- throw new Error(`putIfAbsent failed: ${error}`);
451
+ throw new Error(`Operation admission failed: ${error}`);
452
+ }
453
+ }
454
+ async transitionOperation(input) {
455
+ // Validate input
456
+ const validation = validateTransitionInput(input);
457
+ if (!validation.valid) {
458
+ throw new Error(`Transition validation failed: ${validation.error}`);
459
+ }
460
+ try {
461
+ await this.acquireLockAsync();
462
+ try {
463
+ // Reload state from disk to ensure we have latest
464
+ this.state = this.loadState();
465
+ // Look up operation by its operationId
466
+ const operationIdKey = `op-id:${input.operationId}`;
467
+ // Get current operation state
468
+ if (!(operationIdKey in this.state.rows)) {
469
+ throw new Error(`Operation ${input.operationId} not found`);
470
+ }
471
+ const currentOperation = this.state.rows[operationIdKey];
472
+ // Check if version matches (CAS)
473
+ if (currentOperation.version !== input.expectedVersion) {
474
+ return {
475
+ transitioned: false,
476
+ reason: 'VERSION_CONFLICT',
477
+ operation: currentOperation,
478
+ };
479
+ }
480
+ // Check if transition is legal
481
+ if (!isValidTransition(currentOperation.status, input.to)) {
482
+ return {
483
+ transitioned: false,
484
+ reason: 'INVALID_TRANSITION',
485
+ operation: currentOperation,
486
+ };
487
+ }
488
+ // If transitioning to terminal state, check for idempotent re-entry
489
+ if (isTerminalStatus(input.to) && isTerminalStatus(currentOperation.status)) {
490
+ if (isIdempotentTerminalTransition(currentOperation, input.resultSnapshot, input.error)) {
491
+ return {
492
+ transitioned: false,
493
+ reason: 'VERSION_CONFLICT', // Treat as version conflict (already transitioned)
494
+ operation: currentOperation,
495
+ };
496
+ }
497
+ // Different result data = conflict
498
+ throw new Error(`Terminal state conflict: operation already in ${currentOperation.status} with different result`);
499
+ }
500
+ // Perform the transition
501
+ const transitionedOperation = {
502
+ ...currentOperation,
503
+ status: input.to,
504
+ version: currentOperation.version + 1,
505
+ completedAt: isTerminalStatus(input.to) ? Date.now() : currentOperation.completedAt,
506
+ startedAt: input.to === 'executing' ? Date.now() : currentOperation.startedAt,
507
+ resultSnapshot: input.resultSnapshot ?? currentOperation.resultSnapshot,
508
+ error: input.error ?? currentOperation.error,
509
+ };
510
+ // Persist the transition atomically within lock (update both indices)
511
+ this.state.rows[operationIdKey] = transitionedOperation;
512
+ const idempotencyIndexKey = `operation:${currentOperation.idempotencyKey}`;
513
+ if (idempotencyIndexKey in this.state.rows) {
514
+ this.state.rows[idempotencyIndexKey] = transitionedOperation;
515
+ }
516
+ const collection = operationIdKey.split(':')[0];
517
+ this.event(collection, operationIdKey, 'put', transitionedOperation);
518
+ this.saveState();
519
+ return {
520
+ transitioned: true,
521
+ operation: transitionedOperation,
522
+ };
523
+ }
524
+ finally {
525
+ this.releaseLockAsync();
526
+ }
527
+ }
528
+ catch (error) {
529
+ throw new Error(`Transition failed: ${error}`);
318
530
  }
319
531
  }
320
532
  async cas(params) {
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Atomic Idempotent Operation Admission
3
+ *
4
+ * Defines the durable operation admission contract for FeltDB.
5
+ * Guarantees: exactly one operation identity per idempotency key,
6
+ * atomic durability, and convergence across concurrent callers.
7
+ */
8
+ /**
9
+ * Input contract for admitting a durable operation
10
+ */
11
+ export interface OperationAdmissionInput {
12
+ /** Unique idempotency key supplied by caller */
13
+ idempotencyKey: string;
14
+ /** Operation kind (e.g., "execution", "coordination", "verification") */
15
+ kind: string;
16
+ /** Optional immutable operation fingerprint for conflict detection */
17
+ operationFingerprint?: string;
18
+ /** Caller-supplied metadata */
19
+ metadata?: Record<string, unknown>;
20
+ }
21
+ /**
22
+ * Operation lifecycle status
23
+ */
24
+ export type OperationStatus = 'accepted' | 'executing' | 'completed' | 'failed' | 'cancelled';
25
+ /**
26
+ * Durable operation record with versioned lifecycle
27
+ */
28
+ export interface DurableOperation {
29
+ /** FeltDB-generated operation identity (ULIDv7 or similar) */
30
+ operationId: string;
31
+ /** Caller's idempotency key */
32
+ idempotencyKey: string;
33
+ /** Operation kind (immutable) */
34
+ kind: string;
35
+ /** Current operation status in lifecycle */
36
+ status: OperationStatus;
37
+ /** Version number (increments by 1 on each successful transition) */
38
+ version: number;
39
+ /** Timestamp when operation was admitted */
40
+ createdAt: number;
41
+ /** Timestamp when execution started */
42
+ startedAt?: number;
43
+ /** Timestamp when operation terminal state reached */
44
+ completedAt?: number;
45
+ /** Operation fingerprint for conflict detection */
46
+ operationFingerprint?: string;
47
+ /** Caller metadata */
48
+ metadata?: Record<string, unknown>;
49
+ /** Result/outcome for terminal states */
50
+ resultSnapshot?: unknown;
51
+ /** Error message for failed operations */
52
+ error?: string;
53
+ }
54
+ /**
55
+ * Result of an operation admission attempt
56
+ */
57
+ export interface OperationAdmissionResult {
58
+ /** True if this caller admitted the operation; false if it already existed */
59
+ admitted: boolean;
60
+ /** FeltDB-generated operation identity (same for all callers with same idempotencyKey) */
61
+ operationId: string;
62
+ /** The durable operation record */
63
+ operation: DurableOperation;
64
+ }
65
+ /**
66
+ * Conflict error when idempotency key is reused with different operation semantics
67
+ */
68
+ export interface IdempotencyConflictError {
69
+ code: 'IDEMPOTENCY_CONFLICT';
70
+ idempotencyKey: string;
71
+ existingKind: string;
72
+ requestedKind: string;
73
+ existingFingerprint?: string;
74
+ requestedFingerprint?: string;
75
+ message: string;
76
+ }
77
+ /**
78
+ * Input for atomic operation lifecycle transition
79
+ */
80
+ export interface OperationTransitionInput {
81
+ /** Operation ID to transition */
82
+ operationId: string;
83
+ /** Expected current version (for CAS) */
84
+ expectedVersion: number;
85
+ /** Target status after transition */
86
+ to: OperationStatus;
87
+ /** Result data for terminal states */
88
+ resultSnapshot?: unknown;
89
+ /** Error message for failed operations */
90
+ error?: string;
91
+ /** Metadata for the transition event */
92
+ metadata?: Record<string, unknown>;
93
+ }
94
+ /**
95
+ * Result of a transition attempt
96
+ */
97
+ export interface OperationTransitionResult {
98
+ /** True if transition succeeded */
99
+ transitioned: boolean;
100
+ /** Reason for failure (if transitioned = false) */
101
+ reason?: 'VERSION_CONFLICT' | 'INVALID_TRANSITION' | 'NOT_FOUND';
102
+ /** Current operation state (after transition or at rejection) */
103
+ operation: DurableOperation;
104
+ }
105
+ /**
106
+ * Validates operation admission inputs
107
+ */
108
+ export declare function validateOperationAdmissionInput(input: OperationAdmissionInput): {
109
+ valid: boolean;
110
+ error?: string;
111
+ };
112
+ /**
113
+ * Checks if two operations conflict based on immutable semantics
114
+ */
115
+ export declare function operationsConflict(existing: DurableOperation, requested: OperationAdmissionInput): IdempotencyConflictError | null;
116
+ /**
117
+ * Generates a stable operation ID
118
+ * In production, use ULIDv7 for sortability and distributed uniqueness
119
+ */
120
+ export declare function generateOperationId(): string;
121
+ /**
122
+ * Validates that a transition is legal according to the state machine
123
+ */
124
+ export declare function isValidTransition(fromStatus: OperationStatus, toStatus: OperationStatus): boolean;
125
+ /**
126
+ * Checks if a status is a terminal state
127
+ */
128
+ export declare function isTerminalStatus(status: OperationStatus): boolean;
129
+ /**
130
+ * Validates transition input
131
+ */
132
+ export declare function validateTransitionInput(input: OperationTransitionInput): {
133
+ valid: boolean;
134
+ error?: string;
135
+ };
136
+ /**
137
+ * Checks if a terminal transition is idempotent
138
+ * (same result data means it's the same transition, not a new one)
139
+ */
140
+ export declare function isIdempotentTerminalTransition(existingOp: DurableOperation, requestedResult?: unknown, requestedError?: string): boolean;
141
+ //# sourceMappingURL=operation-admission.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"operation-admission.d.ts","sourceRoot":"","sources":["../src/operation-admission.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Atomic Idempotent Operation Admission
3
+ *
4
+ * Defines the durable operation admission contract for FeltDB.
5
+ * Guarantees: exactly one operation identity per idempotency key,
6
+ * atomic durability, and convergence across concurrent callers.
7
+ */
8
+ /**
9
+ * Validates operation admission inputs
10
+ */
11
+ export function validateOperationAdmissionInput(input) {
12
+ if (!input.idempotencyKey || typeof input.idempotencyKey !== 'string') {
13
+ return { valid: false, error: 'idempotencyKey must be a non-empty string' };
14
+ }
15
+ if (!input.kind || typeof input.kind !== 'string') {
16
+ return { valid: false, error: 'kind must be a non-empty string' };
17
+ }
18
+ if (input.idempotencyKey.length > 255) {
19
+ return { valid: false, error: 'idempotencyKey must be <= 255 characters' };
20
+ }
21
+ if (input.kind.length > 64) {
22
+ return { valid: false, error: 'kind must be <= 64 characters' };
23
+ }
24
+ if (input.operationFingerprint && input.operationFingerprint.length > 255) {
25
+ return { valid: false, error: 'operationFingerprint must be <= 255 characters' };
26
+ }
27
+ return { valid: true };
28
+ }
29
+ /**
30
+ * Checks if two operations conflict based on immutable semantics
31
+ */
32
+ export function operationsConflict(existing, requested) {
33
+ // Same idempotencyKey must have same kind
34
+ if (existing.kind !== requested.kind) {
35
+ return {
36
+ code: 'IDEMPOTENCY_CONFLICT',
37
+ idempotencyKey: existing.idempotencyKey,
38
+ existingKind: existing.kind,
39
+ requestedKind: requested.kind,
40
+ message: `Operation kind mismatch for idempotencyKey "${existing.idempotencyKey}": existing="${existing.kind}", requested="${requested.kind}"`,
41
+ };
42
+ }
43
+ // If fingerprint is supplied, it must match
44
+ if (requested.operationFingerprint) {
45
+ if (existing.operationFingerprint !== requested.operationFingerprint) {
46
+ return {
47
+ code: 'IDEMPOTENCY_CONFLICT',
48
+ idempotencyKey: existing.idempotencyKey,
49
+ existingKind: existing.kind,
50
+ requestedKind: requested.kind,
51
+ existingFingerprint: existing.operationFingerprint,
52
+ requestedFingerprint: requested.operationFingerprint,
53
+ message: `Operation fingerprint mismatch for idempotencyKey "${existing.idempotencyKey}": existing="${existing.operationFingerprint}", requested="${requested.operationFingerprint}"`,
54
+ };
55
+ }
56
+ }
57
+ return null;
58
+ }
59
+ /**
60
+ * Generates a stable operation ID
61
+ * In production, use ULIDv7 for sortability and distributed uniqueness
62
+ */
63
+ export function generateOperationId() {
64
+ // Format: timestamp (13 bytes) + random (12 bytes) = 25 chars
65
+ // For now, use a simple format; consider switching to proper ULID
66
+ const timestamp = Date.now();
67
+ const random = Math.random().toString(36).substring(2, 15) +
68
+ Math.random().toString(36).substring(2, 15);
69
+ return `op-${timestamp}-${random}`;
70
+ }
71
+ /**
72
+ * Legal state transitions in the operation lifecycle
73
+ *
74
+ * Invariant: Each successful transition increments version by exactly 1.
75
+ * Terminal states (completed, failed, cancelled) have no outgoing transitions.
76
+ */
77
+ const LEGAL_TRANSITIONS = {
78
+ accepted: ['executing', 'cancelled'],
79
+ executing: ['completed', 'failed', 'cancelled'],
80
+ completed: [], // Terminal: no transitions allowed
81
+ failed: [], // Terminal: no transitions allowed
82
+ cancelled: [], // Terminal: no transitions allowed
83
+ };
84
+ /**
85
+ * Validates that a transition is legal according to the state machine
86
+ */
87
+ export function isValidTransition(fromStatus, toStatus) {
88
+ const allowedTargets = LEGAL_TRANSITIONS[fromStatus] || [];
89
+ return allowedTargets.includes(toStatus);
90
+ }
91
+ /**
92
+ * Checks if a status is a terminal state
93
+ */
94
+ export function isTerminalStatus(status) {
95
+ return ['completed', 'failed', 'cancelled'].includes(status);
96
+ }
97
+ /**
98
+ * Validates transition input
99
+ */
100
+ export function validateTransitionInput(input) {
101
+ if (!input.operationId || typeof input.operationId !== 'string') {
102
+ return { valid: false, error: 'operationId must be a non-empty string' };
103
+ }
104
+ if (typeof input.expectedVersion !== 'number' || input.expectedVersion < 0) {
105
+ return { valid: false, error: 'expectedVersion must be a non-negative number' };
106
+ }
107
+ if (!input.to || typeof input.to !== 'string') {
108
+ return { valid: false, error: 'to must specify a target status' };
109
+ }
110
+ const validStatuses = ['accepted', 'executing', 'completed', 'failed', 'cancelled'];
111
+ if (!validStatuses.includes(input.to)) {
112
+ return { valid: false, error: `to must be one of: ${validStatuses.join(', ')}` };
113
+ }
114
+ return { valid: true };
115
+ }
116
+ /**
117
+ * Checks if a terminal transition is idempotent
118
+ * (same result data means it's the same transition, not a new one)
119
+ */
120
+ export function isIdempotentTerminalTransition(existingOp, requestedResult, requestedError) {
121
+ // If operation is already in terminal state
122
+ if (!isTerminalStatus(existingOp.status)) {
123
+ return false; // Not a terminal op
124
+ }
125
+ // For completed operations, check result
126
+ if (existingOp.status === 'completed') {
127
+ return JSON.stringify(existingOp.resultSnapshot) === JSON.stringify(requestedResult);
128
+ }
129
+ // For failed operations, check error message
130
+ if (existingOp.status === 'failed') {
131
+ return existingOp.error === requestedError;
132
+ }
133
+ // For cancelled operations, idempotent
134
+ return true;
135
+ }