@feltdb/core 0.4.17 → 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/cli/index.js +1 -1
- package/dist/create/package-versions.js +1 -1
- package/dist/feltdb.d.ts +4 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +3 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +130 -0
- package/dist/operation-admission.d.ts +141 -0
- package/dist/operation-admission.d.ts.map +1 -0
- package/dist/operation-admission.js +135 -0
- package/dist/studio-app/assets/{index-5siPkRSN.js → index-CGQV6zGa.js} +10 -10
- package/dist/studio-app/index.html +1 -1
- package/package.json +1 -1
package/dist/cli/index.js
CHANGED
|
@@ -23,7 +23,7 @@ import * as path from 'path';
|
|
|
23
23
|
import * as readline from 'readline';
|
|
24
24
|
import { getClient } from './api-client.js';
|
|
25
25
|
import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
|
|
26
|
-
const VERSION = '0.4.
|
|
26
|
+
const VERSION = '0.4.20';
|
|
27
27
|
function prompt(question) {
|
|
28
28
|
const rl = readline.createInterface({
|
|
29
29
|
input: process.stdin,
|
|
@@ -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.4.
|
|
3
|
+
export const FELTDB_PACKAGE_VERSION = '0.4.20';
|
|
4
4
|
export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
|
package/dist/feltdb.d.ts
CHANGED
|
@@ -85,6 +85,10 @@ export interface JsDb {
|
|
|
85
85
|
updated: boolean;
|
|
86
86
|
currentVersion: number;
|
|
87
87
|
}>;
|
|
88
|
+
/** Atomic idempotent operation admission: durably admit unique operations. */
|
|
89
|
+
admitOperation?(input: any): Promise<any>;
|
|
90
|
+
/** Atomic operation lifecycle transition: versioned conditional state changes. */
|
|
91
|
+
transitionOperation?(input: any): Promise<any>;
|
|
88
92
|
}
|
|
89
93
|
/**
|
|
90
94
|
* A row stored in the database
|
package/dist/feltdb.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"feltdb.d.ts","sourceRoot":"","sources":["../src/feltdb.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH;;GAEG;AACH,MAAM,WAAW,IAAI;IACnB;;OAEG;IACH,UAAU,CAAC,SAAS,EAAE,GAAG,GAAG,GAAG,CAAC;IAEhC;;OAEG;IACH,SAAS,IAAI,GAAG,CAAC;IAEjB;;OAEG;IACH,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC;IAE/B;;OAEG;IACH,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC;IAEpC;;OAEG;IACH,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC;IAEvC;;OAEG;IACH,oBAAoB,CAAC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,GAAG,GAAG,CAAC;IAEnE;;OAEG;IACH,2BAA2B,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,GAAG,CAAC;IAEpE;;OAEG;IACH,KAAK,CAAC,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEzC;;OAEG;IACH,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAErC;;OAEG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEvD,6DAA6D;IAC7D,MAAM,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAExD,qDAAqD;IACrD,MAAM,CAAC,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEzC,2EAA2E;IAC3E,iBAAiB,CAAC,CAAC,QAAQ,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,IAAI,CAAC;IAC5F,KAAK,CAAC,IAAI,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAE/B;;OAEG;IACH,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,GAAG,CAAC;IAEhD;;OAEG;IACH,WAAW,IAAI,MAAM,CAAC;IAEtB;;OAEG;IACH,YAAY,IAAI,MAAM,CAAC;IAEvB,0EAA0E;IAC1E,YAAY,CAAC,IAAI,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEpC,6DAA6D;IAC7D,iBAAiB,CAAC,CAAC,cAAc,EAAE,MAAM,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAC/D,uBAAuB,CAAC,CAAC,UAAU,EAAE,GAAG,EAAE,GAAG,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IAEhE,oEAAoE;IACpE,WAAW,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAExF,kEAAkE;IAClE,GAAG,CAAC,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"feltdb.d.ts","sourceRoot":"","sources":["../src/feltdb.ts"],"names":[],"mappings":"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"}
|
package/dist/file-db.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { JsDb } from './feltdb.js';
|
|
2
|
+
import type { OperationAdmissionInput, OperationAdmissionResult, OperationTransitionInput, OperationTransitionResult } from './operation-admission.js';
|
|
2
3
|
interface JsResult {
|
|
3
4
|
success: boolean;
|
|
4
5
|
data?: string;
|
|
@@ -61,6 +62,8 @@ export declare class FileJsDb implements JsDb {
|
|
|
61
62
|
inserted: boolean;
|
|
62
63
|
value: string;
|
|
63
64
|
}>;
|
|
65
|
+
admitOperation(input: OperationAdmissionInput): Promise<OperationAdmissionResult>;
|
|
66
|
+
transitionOperation(input: OperationTransitionInput): Promise<OperationTransitionResult>;
|
|
64
67
|
cas(params: {
|
|
65
68
|
key: string;
|
|
66
69
|
expectedVersion: number;
|
package/dist/file-db.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"file-db.d.ts","sourceRoot":"","sources":["../src/file-db.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,KAAK,EACV,uBAAuB,EACvB,wBAAwB,EACxB,wBAAwB,EACxB,yBAAyB,EAG1B,MAAM,0BAA0B,CAAC;AA4DlC,UAAU,QAAQ;IAChB,OAAO,EAAE,OAAO,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAED,UAAU,aAAa;IAAG,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,GAAG,QAAQ,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE;AAyBrK,qBAAa,QAAS,YAAW,IAAI;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,KAAK,CAAc;IAC3B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAqB;IAC3C,OAAO,CAAC,MAAM,CAAuB;gBAEzB,IAAI,EAAE,MAAM;IAgCxB,OAAO,CAAC,SAAS;IAiCjB,OAAO,CAAC,eAAe;IAYvB,OAAO,CAAC,YAAY;IAMpB,OAAO,CAAC,WAAW;IAuBnB,OAAO,CAAC,gBAAgB;YAWV,gBAAgB;IA6B9B,OAAO,CAAC,gBAAgB;IAYxB,OAAO,CAAC,SAAS;IAkCjB,OAAO,CAAC,KAAK;IAeb,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ;IAa5C,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ;IAO5C,GAAG,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ;IAO1B,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,QAAQ;IAW7B,KAAK,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ;IAQnC,sBAAsB,CAAC,UAAU,EAAE,MAAM,GAAG,QAAQ;IAIpD,UAAU,IAAI,QAAQ;IAItB,SAAS,IAAI,QAAQ;IAiBrB,QAAQ,IAAI,QAAQ;IAIpB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ;IAKvC,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,QAAQ;IAK1C,2BAA2B,IAAI,QAAQ;IAIvC,oBAAoB,IAAI,QAAQ;IAIhC,WAAW,IAAI,MAAM;IAIrB,YAAY,IAAI,MAAM;IAItB,YAAY;IAIZ,iBAAiB,CAAC,aAAa,EAAE,MAAM;IAIvC,uBAAuB,CAAC,UAAU,EAAE,aAAa,EAAE;;;;IA0C7C,gBAAgB,CAAC,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC,MAAM,CAAC;IAS7E,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;QAAE,QAAQ,EAAE,OAAO,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC;IAwCtF,cAAc,CAAC,KAAK,EAAE,uBAAuB,GAAG,OAAO,CAAC,wBAAwB,CAAC;IA2DjF,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,GAAG,OAAO,CAAC,yBAAyB,CAAC;IA0FxF,GAAG,CAAC,MAAM,EAAE;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,eAAe,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,OAAO,CAAC;QAAC,cAAc,EAAE,MAAM,CAAA;KAAE,CAAC;IAoCjI,KAAK,IAAI,IAAI;CAId"}
|
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;
|
|
@@ -399,6 +400,135 @@ export class FileJsDb {
|
|
|
399
400
|
throw new Error(`putIfAbsent failed: ${error}`);
|
|
400
401
|
}
|
|
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
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
admitted: result.inserted,
|
|
446
|
+
operationId: existingOperation.operationId,
|
|
447
|
+
operation: existingOperation,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
catch (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}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
402
532
|
async cas(params) {
|
|
403
533
|
try {
|
|
404
534
|
const parsed = JSON.parse(params.value);
|
|
@@ -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
|
+
}
|