@revealui/db 0.3.4 → 0.3.5
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/client/index.d.ts +10 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +13 -0
- package/dist/client/index.js.map +1 -1
- package/dist/crypto.d.ts +1 -1
- package/dist/crypto.js +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/queries/boards.d.ts +4 -0
- package/dist/queries/boards.d.ts.map +1 -1
- package/dist/queries/boards.js +4 -0
- package/dist/queries/boards.js.map +1 -1
- package/dist/queries/conversations.d.ts.map +1 -1
- package/dist/queries/conversations.js +3 -0
- package/dist/queries/conversations.js.map +1 -1
- package/dist/queries/ticket-comments.d.ts.map +1 -1
- package/dist/queries/ticket-comments.js +7 -0
- package/dist/queries/ticket-comments.js.map +1 -1
- package/dist/saga/crdt-resolver.d.ts +75 -0
- package/dist/saga/crdt-resolver.d.ts.map +1 -0
- package/dist/saga/crdt-resolver.js +168 -0
- package/dist/saga/crdt-resolver.js.map +1 -0
- package/dist/saga/idempotent-operation.d.ts +52 -0
- package/dist/saga/idempotent-operation.d.ts.map +1 -0
- package/dist/saga/idempotent-operation.js +78 -0
- package/dist/saga/idempotent-operation.js.map +1 -0
- package/dist/saga/index.d.ts +29 -0
- package/dist/saga/index.d.ts.map +1 -0
- package/dist/saga/index.js +30 -0
- package/dist/saga/index.js.map +1 -0
- package/dist/saga/neon-saga.d.ts +50 -0
- package/dist/saga/neon-saga.d.ts.map +1 -0
- package/dist/saga/neon-saga.js +234 -0
- package/dist/saga/neon-saga.js.map +1 -0
- package/dist/saga/recovery.d.ts +50 -0
- package/dist/saga/recovery.d.ts.map +1 -0
- package/dist/saga/recovery.js +92 -0
- package/dist/saga/recovery.js.map +1 -0
- package/dist/saga/resilient-step.d.ts +38 -0
- package/dist/saga/resilient-step.d.ts.map +1 -0
- package/dist/saga/resilient-step.js +75 -0
- package/dist/saga/resilient-step.js.map +1 -0
- package/dist/saga/types.d.ts +94 -0
- package/dist/saga/types.d.ts.map +1 -0
- package/dist/saga/types.js +9 -0
- package/dist/saga/types.js.map +1 -0
- package/dist/schema/agents.d.ts.map +1 -1
- package/dist/schema/agents.js +16 -6
- package/dist/schema/agents.js.map +1 -1
- package/dist/schema/api-keys.d.ts +1 -1
- package/dist/schema/api-keys.js +2 -2
- package/dist/schema/api-keys.js.map +1 -1
- package/dist/schema/audit-log.d.ts +17 -0
- package/dist/schema/audit-log.d.ts.map +1 -1
- package/dist/schema/audit-log.js +2 -0
- package/dist/schema/audit-log.js.map +1 -1
- package/dist/schema/coordination.d.ts.map +1 -1
- package/dist/schema/coordination.js +5 -2
- package/dist/schema/coordination.js.map +1 -1
- package/dist/schema/idempotency.d.ts +104 -0
- package/dist/schema/idempotency.d.ts.map +1 -0
- package/dist/schema/idempotency.js +24 -0
- package/dist/schema/idempotency.js.map +1 -0
- package/dist/schema/index.js +1 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/rest.d.ts +1 -0
- package/dist/schema/rest.d.ts.map +1 -1
- package/dist/schema/rest.js +1 -0
- package/dist/schema/rest.js.map +1 -1
- package/dist/types/database.d.ts +12 -1
- package/dist/types/database.d.ts.map +1 -1
- package/dist/types/database.js +2 -0
- package/dist/types/database.js.map +1 -1
- package/package.json +7 -3
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent Operation Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Generalizes the processedWebhookEvents dedup pattern into a reusable
|
|
5
|
+
* utility. Checks an idempotency key before executing, skips if already
|
|
6
|
+
* processed, and records the key after success.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```typescript
|
|
10
|
+
* const { result, alreadyProcessed } = await idempotentWrite(
|
|
11
|
+
* db,
|
|
12
|
+
* `send-welcome-email:${userId}`,
|
|
13
|
+
* 'email',
|
|
14
|
+
* async () => {
|
|
15
|
+
* await sendWelcomeEmail(userId);
|
|
16
|
+
* return { sent: true };
|
|
17
|
+
* },
|
|
18
|
+
* );
|
|
19
|
+
*
|
|
20
|
+
* if (alreadyProcessed) {
|
|
21
|
+
* // Email was already sent — skip
|
|
22
|
+
* }
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import { eq } from 'drizzle-orm';
|
|
26
|
+
import { idempotencyKeys } from '../schema/idempotency.js';
|
|
27
|
+
// Default TTL: 24 hours
|
|
28
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
29
|
+
/**
|
|
30
|
+
* Execute an operation idempotently.
|
|
31
|
+
*
|
|
32
|
+
* 1. Check if the key exists in idempotency_keys (skip if so)
|
|
33
|
+
* 2. Execute the operation
|
|
34
|
+
* 3. Record the key (ON CONFLICT DO NOTHING for race-condition safety)
|
|
35
|
+
*
|
|
36
|
+
* @param db - Database client
|
|
37
|
+
* @param key - Unique idempotency key for this operation
|
|
38
|
+
* @param operationType - Category string (e.g., 'saga', 'email', 'webhook')
|
|
39
|
+
* @param operation - The function to execute idempotently
|
|
40
|
+
* @param options - TTL and caching options
|
|
41
|
+
*/
|
|
42
|
+
export async function idempotentWrite(db, key, operationType, operation, options) {
|
|
43
|
+
const ttlMs = options?.ttlMs ?? DEFAULT_TTL_MS;
|
|
44
|
+
// Step 1: Check if already processed
|
|
45
|
+
const existing = await db
|
|
46
|
+
.select({ key: idempotencyKeys.key, expiresAt: idempotencyKeys.expiresAt })
|
|
47
|
+
.from(idempotencyKeys)
|
|
48
|
+
.where(eq(idempotencyKeys.key, key))
|
|
49
|
+
.limit(1);
|
|
50
|
+
const row = existing[0];
|
|
51
|
+
if (row) {
|
|
52
|
+
// Check if expired
|
|
53
|
+
if (!row.expiresAt || row.expiresAt >= new Date()) {
|
|
54
|
+
return { alreadyProcessed: true };
|
|
55
|
+
}
|
|
56
|
+
// Expired — clean up and proceed
|
|
57
|
+
await db.delete(idempotencyKeys).where(eq(idempotencyKeys.key, key));
|
|
58
|
+
}
|
|
59
|
+
// Step 2: Execute the operation
|
|
60
|
+
const result = await operation();
|
|
61
|
+
// Step 3: Record the idempotency key
|
|
62
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
63
|
+
const resultData = options?.cacheResult && result !== null && typeof result === 'object'
|
|
64
|
+
? result
|
|
65
|
+
: undefined;
|
|
66
|
+
await db
|
|
67
|
+
.insert(idempotencyKeys)
|
|
68
|
+
.values({
|
|
69
|
+
key,
|
|
70
|
+
operationType,
|
|
71
|
+
result: resultData,
|
|
72
|
+
createdAt: new Date(),
|
|
73
|
+
expiresAt,
|
|
74
|
+
})
|
|
75
|
+
.onConflictDoNothing();
|
|
76
|
+
return { result, alreadyProcessed: false };
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=idempotent-operation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotent-operation.js","sourceRoot":"","sources":["../../src/saga/idempotent-operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AAEjC,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAE3D,wBAAwB;AACxB,MAAM,cAAc,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAgB3C;;;;;;;;;;;;GAYG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACnC,EAAY,EACZ,GAAW,EACX,aAAqB,EACrB,SAA2B,EAC3B,OAAgC;IAEhC,MAAM,KAAK,GAAG,OAAO,EAAE,KAAK,IAAI,cAAc,CAAC;IAE/C,qCAAqC;IACrC,MAAM,QAAQ,GAAG,MAAM,EAAE;SACtB,MAAM,CAAC,EAAE,GAAG,EAAE,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,eAAe,CAAC,SAAS,EAAE,CAAC;SAC1E,IAAI,CAAC,eAAe,CAAC;SACrB,KAAK,CAAC,EAAE,CAAC,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;SACnC,KAAK,CAAC,CAAC,CAAC,CAAC;IAEZ,MAAM,GAAG,GAAG,QAAQ,CAAC,CAAC,CAAC,CAAC;IACxB,IAAI,GAAG,EAAE,CAAC;QACR,mBAAmB;QACnB,IAAI,CAAC,GAAG,CAAC,SAAS,IAAI,GAAG,CAAC,SAAS,IAAI,IAAI,IAAI,EAAE,EAAE,CAAC;YAClD,OAAO,EAAE,gBAAgB,EAAE,IAAI,EAAE,CAAC;QACpC,CAAC;QACD,iCAAiC;QACjC,MAAM,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;IACvE,CAAC;IAED,gCAAgC;IAChC,MAAM,MAAM,GAAG,MAAM,SAAS,EAAE,CAAC;IAEjC,qCAAqC;IACrC,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,CAAC;IAC/C,MAAM,UAAU,GACd,OAAO,EAAE,WAAW,IAAI,MAAM,KAAK,IAAI,IAAI,OAAO,MAAM,KAAK,QAAQ;QACnE,CAAC,CAAE,MAAkC;QACrC,CAAC,CAAC,SAAS,CAAC;IAEhB,MAAM,EAAE;SACL,MAAM,CAAC,eAAe,CAAC;SACvB,MAAM,CAAC;QACN,GAAG;QACH,aAAa;QACb,MAAM,EAAE,UAAU;QAClB,SAAS,EAAE,IAAI,IAAI,EAAE;QACrB,SAAS;KACV,CAAC;SACD,mBAAmB,EAAE,CAAC;IAEzB,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,KAAK,EAAE,CAAC;AAC7C,CAAC"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @revealui/db/saga — NeonDB-safe saga executor
|
|
3
|
+
*
|
|
4
|
+
* Provides transaction-like guarantees over NeonDB's stateless HTTP driver
|
|
5
|
+
* using compensating actions, idempotency keys, and the jobs table as outbox.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { executeSaga, resilientStep, idempotentWrite } from '@revealui/db/saga';
|
|
10
|
+
*
|
|
11
|
+
* const result = await executeSaga(db, 'provision-license', eventId, [
|
|
12
|
+
* resilientStep({
|
|
13
|
+
* name: 'insert-license',
|
|
14
|
+
* execute: async (ctx) => { ... },
|
|
15
|
+
* compensate: async (ctx, output) => { ... },
|
|
16
|
+
* }),
|
|
17
|
+
* ]);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export type { CRDTIncrementResult, CRDTSetResult } from './crdt-resolver.js';
|
|
21
|
+
export { crdtIncrement, crdtSetWithOptimisticLock } from './crdt-resolver.js';
|
|
22
|
+
export type { IdempotentWriteOptions, IdempotentWriteResult } from './idempotent-operation.js';
|
|
23
|
+
export { idempotentWrite } from './idempotent-operation.js';
|
|
24
|
+
export { executeSaga } from './neon-saga.js';
|
|
25
|
+
export type { RecoveredSaga } from './recovery.js';
|
|
26
|
+
export { cleanupExpiredIdempotencyKeys, recoverStaleSagas } from './recovery.js';
|
|
27
|
+
export { resilientStep } from './resilient-step.js';
|
|
28
|
+
export type { SagaCheckpointData, SagaContext, SagaOptions, SagaResult, SagaRetryOptions, SagaStatus, SagaStep, } from './types.js';
|
|
29
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/saga/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,YAAY,EAAE,mBAAmB,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAE7E,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAC9E,YAAY,EAAE,sBAAsB,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAE/F,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,YAAY,EAAE,aAAa,EAAE,MAAM,eAAe,CAAC;AAEnD,OAAO,EAAE,6BAA6B,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AAEjF,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AAEpD,YAAY,EACV,kBAAkB,EAClB,WAAW,EACX,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,UAAU,EACV,QAAQ,GACT,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @revealui/db/saga — NeonDB-safe saga executor
|
|
3
|
+
*
|
|
4
|
+
* Provides transaction-like guarantees over NeonDB's stateless HTTP driver
|
|
5
|
+
* using compensating actions, idempotency keys, and the jobs table as outbox.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { executeSaga, resilientStep, idempotentWrite } from '@revealui/db/saga';
|
|
10
|
+
*
|
|
11
|
+
* const result = await executeSaga(db, 'provision-license', eventId, [
|
|
12
|
+
* resilientStep({
|
|
13
|
+
* name: 'insert-license',
|
|
14
|
+
* execute: async (ctx) => { ... },
|
|
15
|
+
* compensate: async (ctx, output) => { ... },
|
|
16
|
+
* }),
|
|
17
|
+
* ]);
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
// CRDT conflict resolution
|
|
21
|
+
export { crdtIncrement, crdtSetWithOptimisticLock } from './crdt-resolver.js';
|
|
22
|
+
// Idempotent operation wrapper
|
|
23
|
+
export { idempotentWrite } from './idempotent-operation.js';
|
|
24
|
+
// Core saga executor
|
|
25
|
+
export { executeSaga } from './neon-saga.js';
|
|
26
|
+
// Saga recovery
|
|
27
|
+
export { cleanupExpiredIdempotencyKeys, recoverStaleSagas } from './recovery.js';
|
|
28
|
+
// Resilient step wrapper
|
|
29
|
+
export { resilientStep } from './resilient-step.js';
|
|
30
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/saga/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,2BAA2B;AAC3B,OAAO,EAAE,aAAa,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAE9E,+BAA+B;AAC/B,OAAO,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAC;AAC5D,qBAAqB;AACrB,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAE7C,gBAAgB;AAChB,OAAO,EAAE,6BAA6B,EAAE,iBAAiB,EAAE,MAAM,eAAe,CAAC;AACjF,yBAAyB;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NeonSaga — Saga Executor for NeonDB HTTP Driver
|
|
3
|
+
*
|
|
4
|
+
* Provides transaction-like guarantees over NeonDB's stateless HTTP driver
|
|
5
|
+
* by modeling multi-step writes as a saga with compensating actions.
|
|
6
|
+
*
|
|
7
|
+
* Each step's execute() and compensate() must be individually atomic (single
|
|
8
|
+
* DB statement). The saga tracks progress via the jobs table as an outbox,
|
|
9
|
+
* enabling crash recovery and idempotent replay.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const result = await executeSaga(db, 'provision-license', eventId, [
|
|
14
|
+
* {
|
|
15
|
+
* name: 'insert-license',
|
|
16
|
+
* execute: async (ctx) => {
|
|
17
|
+
* const [row] = await ctx.db.insert(licenses).values({ ... }).returning();
|
|
18
|
+
* return row;
|
|
19
|
+
* },
|
|
20
|
+
* compensate: async (ctx, row) => {
|
|
21
|
+
* await ctx.db.delete(licenses).where(eq(licenses.id, row.id));
|
|
22
|
+
* },
|
|
23
|
+
* },
|
|
24
|
+
* {
|
|
25
|
+
* name: 'activate-subscription',
|
|
26
|
+
* execute: async (ctx) => {
|
|
27
|
+
* await ctx.db.update(subscriptions).set({ status: 'active' }).where(...);
|
|
28
|
+
* return { previousStatus: 'pending' };
|
|
29
|
+
* },
|
|
30
|
+
* compensate: async (ctx, output) => {
|
|
31
|
+
* await ctx.db.update(subscriptions).set({ status: output.previousStatus }).where(...);
|
|
32
|
+
* },
|
|
33
|
+
* },
|
|
34
|
+
* ]);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import type { SagaOptions, SagaResult, SagaStep } from './types.js';
|
|
38
|
+
/**
|
|
39
|
+
* Execute a saga — a sequence of individually-atomic steps with compensating
|
|
40
|
+
* actions for rollback.
|
|
41
|
+
*
|
|
42
|
+
* @param db - Database client (works with both NeonDB HTTP and pg Pool)
|
|
43
|
+
* @param sagaName - Saga type identifier (e.g., 'provision-license')
|
|
44
|
+
* @param sagaKey - Natural idempotency key (e.g., Stripe event ID)
|
|
45
|
+
* @param steps - Ordered list of saga steps
|
|
46
|
+
* @param options - Optional retry, circuit breaker, and idempotency config
|
|
47
|
+
* @returns SagaResult with status, outputs, and completed steps
|
|
48
|
+
*/
|
|
49
|
+
export declare function executeSaga<T = unknown>(db: Parameters<SagaStep['execute']>[0]['db'], sagaName: string, sagaKey: string, steps: SagaStep[], options?: SagaOptions): Promise<SagaResult<T>>;
|
|
50
|
+
//# sourceMappingURL=neon-saga.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"neon-saga.d.ts","sourceRoot":"","sources":["../../src/saga/neon-saga.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAKH,OAAO,KAAK,EAGV,WAAW,EACX,UAAU,EACV,QAAQ,EACT,MAAM,YAAY,CAAC;AAKpB;;;;;;;;;;GAUG;AACH,wBAAsB,WAAW,CAAC,CAAC,GAAG,OAAO,EAC3C,EAAE,EAAE,UAAU,CAAC,QAAQ,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,EAC5C,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,QAAQ,EAAE,EACjB,OAAO,CAAC,EAAE,WAAW,GACpB,OAAO,CAAC,UAAU,CAAC,CAAC,CAAC,CAAC,CAsJxB"}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NeonSaga — Saga Executor for NeonDB HTTP Driver
|
|
3
|
+
*
|
|
4
|
+
* Provides transaction-like guarantees over NeonDB's stateless HTTP driver
|
|
5
|
+
* by modeling multi-step writes as a saga with compensating actions.
|
|
6
|
+
*
|
|
7
|
+
* Each step's execute() and compensate() must be individually atomic (single
|
|
8
|
+
* DB statement). The saga tracks progress via the jobs table as an outbox,
|
|
9
|
+
* enabling crash recovery and idempotent replay.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* const result = await executeSaga(db, 'provision-license', eventId, [
|
|
14
|
+
* {
|
|
15
|
+
* name: 'insert-license',
|
|
16
|
+
* execute: async (ctx) => {
|
|
17
|
+
* const [row] = await ctx.db.insert(licenses).values({ ... }).returning();
|
|
18
|
+
* return row;
|
|
19
|
+
* },
|
|
20
|
+
* compensate: async (ctx, row) => {
|
|
21
|
+
* await ctx.db.delete(licenses).where(eq(licenses.id, row.id));
|
|
22
|
+
* },
|
|
23
|
+
* },
|
|
24
|
+
* {
|
|
25
|
+
* name: 'activate-subscription',
|
|
26
|
+
* execute: async (ctx) => {
|
|
27
|
+
* await ctx.db.update(subscriptions).set({ status: 'active' }).where(...);
|
|
28
|
+
* return { previousStatus: 'pending' };
|
|
29
|
+
* },
|
|
30
|
+
* compensate: async (ctx, output) => {
|
|
31
|
+
* await ctx.db.update(subscriptions).set({ status: output.previousStatus }).where(...);
|
|
32
|
+
* },
|
|
33
|
+
* },
|
|
34
|
+
* ]);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
import { eq } from 'drizzle-orm';
|
|
38
|
+
import { idempotencyKeys } from '../schema/idempotency.js';
|
|
39
|
+
import { jobs } from '../schema/jobs.js';
|
|
40
|
+
// Default idempotency TTL: 24 hours
|
|
41
|
+
const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
42
|
+
/**
|
|
43
|
+
* Execute a saga — a sequence of individually-atomic steps with compensating
|
|
44
|
+
* actions for rollback.
|
|
45
|
+
*
|
|
46
|
+
* @param db - Database client (works with both NeonDB HTTP and pg Pool)
|
|
47
|
+
* @param sagaName - Saga type identifier (e.g., 'provision-license')
|
|
48
|
+
* @param sagaKey - Natural idempotency key (e.g., Stripe event ID)
|
|
49
|
+
* @param steps - Ordered list of saga steps
|
|
50
|
+
* @param options - Optional retry, circuit breaker, and idempotency config
|
|
51
|
+
* @returns SagaResult with status, outputs, and completed steps
|
|
52
|
+
*/
|
|
53
|
+
export async function executeSaga(db, sagaName, sagaKey, steps, options) {
|
|
54
|
+
const idempotencyKey = options?.idempotencyKey ?? `${sagaName}:${sagaKey}`;
|
|
55
|
+
const sagaId = `saga-${sagaName}-${sagaKey}-${Date.now()}`;
|
|
56
|
+
// -------------------------------------------------------------------------
|
|
57
|
+
// 1. Idempotency check — skip if already processed
|
|
58
|
+
// -------------------------------------------------------------------------
|
|
59
|
+
const alreadyProcessed = await checkIdempotency(db, idempotencyKey);
|
|
60
|
+
if (alreadyProcessed) {
|
|
61
|
+
return {
|
|
62
|
+
sagaId,
|
|
63
|
+
status: 'skipped',
|
|
64
|
+
completedSteps: [],
|
|
65
|
+
alreadyProcessed: true,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
// -------------------------------------------------------------------------
|
|
69
|
+
// 2. Create job record (outbox entry) — records intent before mutations
|
|
70
|
+
// -------------------------------------------------------------------------
|
|
71
|
+
const checkpointData = {
|
|
72
|
+
sagaName,
|
|
73
|
+
sagaKey,
|
|
74
|
+
stepNames: steps.map((s) => s.name),
|
|
75
|
+
completedSteps: [],
|
|
76
|
+
idempotencyKey,
|
|
77
|
+
};
|
|
78
|
+
await db.insert(jobs).values({
|
|
79
|
+
id: sagaId,
|
|
80
|
+
name: `saga:${sagaName}`,
|
|
81
|
+
data: checkpointData,
|
|
82
|
+
state: 'created',
|
|
83
|
+
priority: 0,
|
|
84
|
+
retryCount: 0,
|
|
85
|
+
retryLimit: 3,
|
|
86
|
+
});
|
|
87
|
+
// Mark job active
|
|
88
|
+
await db.update(jobs).set({ state: 'active', startedAt: new Date() }).where(eq(jobs.id, sagaId));
|
|
89
|
+
// -------------------------------------------------------------------------
|
|
90
|
+
// 3. Execute steps sequentially with checkpointing
|
|
91
|
+
// -------------------------------------------------------------------------
|
|
92
|
+
const completedSteps = [];
|
|
93
|
+
let lastOutput;
|
|
94
|
+
const ctx = {
|
|
95
|
+
db,
|
|
96
|
+
sagaId,
|
|
97
|
+
checkpoint: async (stepName, output) => {
|
|
98
|
+
const entry = {
|
|
99
|
+
name: stepName,
|
|
100
|
+
output,
|
|
101
|
+
completedAt: new Date().toISOString(),
|
|
102
|
+
};
|
|
103
|
+
completedSteps.push(entry);
|
|
104
|
+
// Persist checkpoint to job's JSONB data
|
|
105
|
+
await db
|
|
106
|
+
.update(jobs)
|
|
107
|
+
.set({
|
|
108
|
+
data: {
|
|
109
|
+
...checkpointData,
|
|
110
|
+
completedSteps: [...completedSteps],
|
|
111
|
+
},
|
|
112
|
+
})
|
|
113
|
+
.where(eq(jobs.id, sagaId));
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
try {
|
|
117
|
+
for (const step of steps) {
|
|
118
|
+
const output = await step.execute(ctx);
|
|
119
|
+
await ctx.checkpoint(step.name, output);
|
|
120
|
+
lastOutput = output;
|
|
121
|
+
}
|
|
122
|
+
// -----------------------------------------------------------------------
|
|
123
|
+
// 4. All steps succeeded — mark completed and record idempotency key
|
|
124
|
+
// -----------------------------------------------------------------------
|
|
125
|
+
await db
|
|
126
|
+
.update(jobs)
|
|
127
|
+
.set({ state: 'completed', completedAt: new Date() })
|
|
128
|
+
.where(eq(jobs.id, sagaId));
|
|
129
|
+
await recordIdempotency(db, idempotencyKey, 'saga', options?.idempotencyTtlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS);
|
|
130
|
+
return {
|
|
131
|
+
sagaId,
|
|
132
|
+
status: 'completed',
|
|
133
|
+
result: lastOutput,
|
|
134
|
+
completedSteps: completedSteps.map((s) => s.name),
|
|
135
|
+
alreadyProcessed: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
catch (executeError) {
|
|
139
|
+
// -----------------------------------------------------------------------
|
|
140
|
+
// 5. Step failed — compensate completed steps in reverse order
|
|
141
|
+
// -----------------------------------------------------------------------
|
|
142
|
+
const errorMessage = executeError instanceof Error ? executeError.message : String(executeError);
|
|
143
|
+
const compensationErrors = [];
|
|
144
|
+
// Compensate in reverse order
|
|
145
|
+
for (let i = completedSteps.length - 1; i >= 0; i--) {
|
|
146
|
+
const completed = completedSteps[i];
|
|
147
|
+
if (!completed)
|
|
148
|
+
continue;
|
|
149
|
+
const step = steps.find((s) => s.name === completed.name);
|
|
150
|
+
if (!step)
|
|
151
|
+
continue;
|
|
152
|
+
try {
|
|
153
|
+
await step.compensate(ctx, completed.output);
|
|
154
|
+
}
|
|
155
|
+
catch (compensateError) {
|
|
156
|
+
// Log but don't throw — compensations are best-effort
|
|
157
|
+
const msg = compensateError instanceof Error ? compensateError.message : String(compensateError);
|
|
158
|
+
compensationErrors.push(`${completed.name}: ${msg}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
// Determine final status
|
|
162
|
+
const finalStatus = compensationErrors.length > 0 ? 'failed' : 'compensated';
|
|
163
|
+
// Update job record with failure info
|
|
164
|
+
await db
|
|
165
|
+
.update(jobs)
|
|
166
|
+
.set({
|
|
167
|
+
state: 'failed',
|
|
168
|
+
output: {
|
|
169
|
+
error: errorMessage,
|
|
170
|
+
compensationErrors: compensationErrors.length > 0 ? compensationErrors : undefined,
|
|
171
|
+
compensatedSteps: completedSteps.map((s) => s.name),
|
|
172
|
+
},
|
|
173
|
+
})
|
|
174
|
+
.where(eq(jobs.id, sagaId));
|
|
175
|
+
return {
|
|
176
|
+
sagaId,
|
|
177
|
+
status: finalStatus,
|
|
178
|
+
error: errorMessage,
|
|
179
|
+
completedSteps: completedSteps.map((s) => s.name),
|
|
180
|
+
alreadyProcessed: false,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// =============================================================================
|
|
185
|
+
// Idempotency Helpers
|
|
186
|
+
// =============================================================================
|
|
187
|
+
/**
|
|
188
|
+
* Check if an idempotency key has already been recorded.
|
|
189
|
+
* Returns true if the key exists and hasn't expired.
|
|
190
|
+
*/
|
|
191
|
+
async function checkIdempotency(db, key) {
|
|
192
|
+
try {
|
|
193
|
+
const rows = await db
|
|
194
|
+
.select({ key: idempotencyKeys.key, expiresAt: idempotencyKeys.expiresAt })
|
|
195
|
+
.from(idempotencyKeys)
|
|
196
|
+
.where(eq(idempotencyKeys.key, key))
|
|
197
|
+
.limit(1);
|
|
198
|
+
if (rows.length === 0)
|
|
199
|
+
return false;
|
|
200
|
+
const expiresAt = rows[0]?.expiresAt;
|
|
201
|
+
if (expiresAt && expiresAt < new Date()) {
|
|
202
|
+
// Expired — delete and treat as not processed
|
|
203
|
+
await db.delete(idempotencyKeys).where(eq(idempotencyKeys.key, key));
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
// If the idempotency table doesn't exist yet, treat as not processed
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Record an idempotency key after successful saga completion.
|
|
215
|
+
*/
|
|
216
|
+
async function recordIdempotency(db, key, operationType, ttlMs) {
|
|
217
|
+
const expiresAt = new Date(Date.now() + ttlMs);
|
|
218
|
+
try {
|
|
219
|
+
await db
|
|
220
|
+
.insert(idempotencyKeys)
|
|
221
|
+
.values({
|
|
222
|
+
key,
|
|
223
|
+
operationType,
|
|
224
|
+
createdAt: new Date(),
|
|
225
|
+
expiresAt,
|
|
226
|
+
})
|
|
227
|
+
.onConflictDoNothing();
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Best-effort — if this fails, the saga still completed successfully.
|
|
231
|
+
// The next execution will just re-run (which is fine since steps are idempotent).
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=neon-saga.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"neon-saga.js","sourceRoot":"","sources":["../../src/saga/neon-saga.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAmCG;AAEH,OAAO,EAAE,EAAE,EAAE,MAAM,aAAa,CAAC;AACjC,OAAO,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAC3D,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AASzC,oCAAoC;AACpC,MAAM,0BAA0B,GAAG,EAAE,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAEvD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,EAA4C,EAC5C,QAAgB,EAChB,OAAe,EACf,KAAiB,EACjB,OAAqB;IAErB,MAAM,cAAc,GAAG,OAAO,EAAE,cAAc,IAAI,GAAG,QAAQ,IAAI,OAAO,EAAE,CAAC;IAC3E,MAAM,MAAM,GAAG,QAAQ,QAAQ,IAAI,OAAO,IAAI,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;IAE3D,4EAA4E;IAC5E,mDAAmD;IACnD,4EAA4E;IAC5E,MAAM,gBAAgB,GAAG,MAAM,gBAAgB,CAAC,EAAE,EAAE,cAAc,CAAC,CAAC;IACpE,IAAI,gBAAgB,EAAE,CAAC;QACrB,OAAO;YACL,MAAM;YACN,MAAM,EAAE,SAAS;YACjB,cAAc,EAAE,EAAE;YAClB,gBAAgB,EAAE,IAAI;SACvB,CAAC;IACJ,CAAC;IAED,4EAA4E;IAC5E,wEAAwE;IACxE,4EAA4E;IAC5E,MAAM,cAAc,GAAuB;QACzC,QAAQ;QACR,OAAO;QACP,SAAS,EAAE,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;QACnC,cAAc,EAAE,EAAE;QAClB,cAAc;KACf,CAAC;IAEF,MAAM,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC;QAC3B,EAAE,EAAE,MAAM;QACV,IAAI,EAAE,QAAQ,QAAQ,EAAE;QACxB,IAAI,EAAE,cAAoD;QAC1D,KAAK,EAAE,SAAS;QAChB,QAAQ,EAAE,CAAC;QACX,UAAU,EAAE,CAAC;QACb,UAAU,EAAE,CAAC;KACd,CAAC,CAAC;IAEH,kBAAkB;IAClB,MAAM,EAAE,CAAC,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;IAEjG,4EAA4E;IAC5E,mDAAmD;IACnD,4EAA4E;IAC5E,MAAM,cAAc,GAAkE,EAAE,CAAC;IACzF,IAAI,UAAmB,CAAC;IAExB,MAAM,GAAG,GAAgB;QACvB,EAAE;QACF,MAAM;QACN,UAAU,EAAE,KAAK,EAAE,QAAgB,EAAE,MAAe,EAAE,EAAE;YACtD,MAAM,KAAK,GAAG;gBACZ,IAAI,EAAE,QAAQ;gBACd,MAAM;gBACN,WAAW,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;aACtC,CAAC;YACF,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YAE3B,yCAAyC;YACzC,MAAM,EAAE;iBACL,MAAM,CAAC,IAAI,CAAC;iBACZ,GAAG,CAAC;gBACH,IAAI,EAAE;oBACJ,GAAG,cAAc;oBACjB,cAAc,EAAE,CAAC,GAAG,cAAc,CAAC;iBACE;aACxC,CAAC;iBACD,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;QAChC,CAAC;KACF,CAAC;IAEF,IAAI,CAAC;QACH,KAAK,MAAM,IAAI,IAAI,KAAK,EAAE,CAAC;YACzB,MAAM,MAAM,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;YACvC,MAAM,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;YACxC,UAAU,GAAG,MAAM,CAAC;QACtB,CAAC;QAED,0EAA0E;QAC1E,qEAAqE;QACrE,0EAA0E;QAC1E,MAAM,EAAE;aACL,MAAM,CAAC,IAAI,CAAC;aACZ,GAAG,CAAC,EAAE,KAAK,EAAE,WAAW,EAAE,WAAW,EAAE,IAAI,IAAI,EAAE,EAAE,CAAC;aACpD,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;QAE9B,MAAM,iBAAiB,CACrB,EAAE,EACF,cAAc,EACd,MAAM,EACN,OAAO,EAAE,gBAAgB,IAAI,0BAA0B,CACxD,CAAC;QAEF,OAAO;YACL,MAAM;YACN,MAAM,EAAE,WAAW;YACnB,MAAM,EAAE,UAAe;YACvB,cAAc,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;YACjD,gBAAgB,EAAE,KAAK;SACxB,CAAC;IACJ,CAAC;IAAC,OAAO,YAAY,EAAE,CAAC;QACtB,0EAA0E;QAC1E,+DAA+D;QAC/D,0EAA0E;QAC1E,MAAM,YAAY,GAChB,YAAY,YAAY,KAAK,CAAC,CAAC,CAAC,YAAY,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,YAAY,CAAC,CAAC;QAE9E,MAAM,kBAAkB,GAAa,EAAE,CAAC;QAExC,8BAA8B;QAC9B,KAAK,IAAI,CAAC,GAAG,cAAc,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC;YACpD,MAAM,SAAS,GAAG,cAAc,CAAC,CAAC,CAAC,CAAC;YACpC,IAAI,CAAC,SAAS;gBAAE,SAAS;YACzB,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,KAAK,SAAS,CAAC,IAAI,CAAC,CAAC;YAC1D,IAAI,CAAC,IAAI;gBAAE,SAAS;YAEpB,IAAI,CAAC;gBACH,MAAM,IAAI,CAAC,UAAU,CAAC,GAAG,EAAE,SAAS,CAAC,MAAM,CAAC,CAAC;YAC/C,CAAC;YAAC,OAAO,eAAe,EAAE,CAAC;gBACzB,sDAAsD;gBACtD,MAAM,GAAG,GACP,eAAe,YAAY,KAAK,CAAC,CAAC,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC;gBACvF,kBAAkB,CAAC,IAAI,CAAC,GAAG,SAAS,CAAC,IAAI,KAAK,GAAG,EAAE,CAAC,CAAC;YACvD,CAAC;QACH,CAAC;QAED,yBAAyB;QACzB,MAAM,WAAW,GAAG,kBAAkB,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC,CAAC,aAAa,CAAC;QAE7E,sCAAsC;QACtC,MAAM,EAAE;aACL,MAAM,CAAC,IAAI,CAAC;aACZ,GAAG,CAAC;YACH,KAAK,EAAE,QAAQ;YACf,MAAM,EAAE;gBACN,KAAK,EAAE,YAAY;gBACnB,kBAAkB,EAAE,kBAAkB,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS;gBAClF,gBAAgB,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;aACzB;SAC7B,CAAC;aACD,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,CAAC,CAAC;QAE9B,OAAO;YACL,MAAM;YACN,MAAM,EAAE,WAAW;YACnB,KAAK,EAAE,YAAY;YACnB,cAAc,EAAE,cAAc,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC;YACjD,gBAAgB,EAAE,KAAK;SACxB,CAAC;IACJ,CAAC;AACH,CAAC;AAED,gFAAgF;AAChF,sBAAsB;AACtB,gFAAgF;AAEhF;;;GAGG;AACH,KAAK,UAAU,gBAAgB,CAC7B,EAA4C,EAC5C,GAAW;IAEX,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,EAAE;aAClB,MAAM,CAAC,EAAE,GAAG,EAAE,eAAe,CAAC,GAAG,EAAE,SAAS,EAAE,eAAe,CAAC,SAAS,EAAE,CAAC;aAC1E,IAAI,CAAC,eAAe,CAAC;aACrB,KAAK,CAAC,EAAE,CAAC,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;aACnC,KAAK,CAAC,CAAC,CAAC,CAAC;QAEZ,IAAI,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,KAAK,CAAC;QAEpC,MAAM,SAAS,GAAG,IAAI,CAAC,CAAC,CAAC,EAAE,SAAS,CAAC;QACrC,IAAI,SAAS,IAAI,SAAS,GAAG,IAAI,IAAI,EAAE,EAAE,CAAC;YACxC,8CAA8C;YAC9C,MAAM,EAAE,CAAC,MAAM,CAAC,eAAe,CAAC,CAAC,KAAK,CAAC,EAAE,CAAC,eAAe,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC,CAAC;YACrE,OAAO,KAAK,CAAC;QACf,CAAC;QAED,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,MAAM,CAAC;QACP,qEAAqE;QACrE,OAAO,KAAK,CAAC;IACf,CAAC;AACH,CAAC;AAED;;GAEG;AACH,KAAK,UAAU,iBAAiB,CAC9B,EAA4C,EAC5C,GAAW,EACX,aAAqB,EACrB,KAAa;IAEb,MAAM,SAAS,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,KAAK,CAAC,CAAC;IAE/C,IAAI,CAAC;QACH,MAAM,EAAE;aACL,MAAM,CAAC,eAAe,CAAC;aACvB,MAAM,CAAC;YACN,GAAG;YACH,aAAa;YACb,SAAS,EAAE,IAAI,IAAI,EAAE;YACrB,SAAS;SACV,CAAC;aACD,mBAAmB,EAAE,CAAC;IAC3B,CAAC;IAAC,MAAM,CAAC;QACP,sEAAsE;QACtE,kFAAkF;IACpF,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saga Recovery — Sweep and Recover Stuck Sagas
|
|
3
|
+
*
|
|
4
|
+
* Finds saga jobs stuck in 'active' state (indicating the process crashed
|
|
5
|
+
* mid-execution) and marks them as failed. Compensation is not attempted
|
|
6
|
+
* automatically because the step functions are in code, not serialized
|
|
7
|
+
* in the job data — recovery requires re-invoking the saga definition.
|
|
8
|
+
*
|
|
9
|
+
* Designed to run as a periodic cron job or be called manually.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* // In a cron handler:
|
|
14
|
+
* const recovered = await recoverStaleSagas(db);
|
|
15
|
+
* // recovered.length contains the count of stuck sagas found
|
|
16
|
+
*
|
|
17
|
+
* // With custom threshold:
|
|
18
|
+
* const recovered = await recoverStaleSagas(db, 10 * 60 * 1000); // 10 minutes
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import type { Database } from '../client/index.js';
|
|
22
|
+
export interface RecoveredSaga {
|
|
23
|
+
sagaId: string;
|
|
24
|
+
sagaName: string;
|
|
25
|
+
sagaKey: string;
|
|
26
|
+
completedSteps: string[];
|
|
27
|
+
startedAt: Date | null;
|
|
28
|
+
recoveredAt: Date;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Find and mark stuck saga jobs as failed.
|
|
32
|
+
*
|
|
33
|
+
* A saga is considered "stuck" if it has been in 'active' state longer
|
|
34
|
+
* than the threshold. This indicates the executing process crashed or
|
|
35
|
+
* timed out without completing or compensating.
|
|
36
|
+
*
|
|
37
|
+
* @param db - Database client
|
|
38
|
+
* @param thresholdMs - How long a saga must be active before it's considered stuck
|
|
39
|
+
* @returns List of recovered saga records
|
|
40
|
+
*/
|
|
41
|
+
export declare function recoverStaleSagas(db: Database, thresholdMs?: number): Promise<RecoveredSaga[]>;
|
|
42
|
+
/**
|
|
43
|
+
* Clean up expired idempotency keys.
|
|
44
|
+
* Should be called periodically to prevent the table from growing unbounded.
|
|
45
|
+
*
|
|
46
|
+
* @param db - Database client
|
|
47
|
+
* @returns Number of expired keys deleted
|
|
48
|
+
*/
|
|
49
|
+
export declare function cleanupExpiredIdempotencyKeys(db: Database): Promise<number>;
|
|
50
|
+
//# sourceMappingURL=recovery.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery.d.ts","sourceRoot":"","sources":["../../src/saga/recovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAOnD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,CAAC;IAChB,cAAc,EAAE,MAAM,EAAE,CAAC;IACzB,SAAS,EAAE,IAAI,GAAG,IAAI,CAAC;IACvB,WAAW,EAAE,IAAI,CAAC;CACnB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,iBAAiB,CACrC,EAAE,EAAE,QAAQ,EACZ,WAAW,GAAE,MAAmC,GAC/C,OAAO,CAAC,aAAa,EAAE,CAAC,CA+C1B;AAED;;;;;;GAMG;AACH,wBAAsB,6BAA6B,CAAC,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,MAAM,CAAC,CAUjF"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saga Recovery — Sweep and Recover Stuck Sagas
|
|
3
|
+
*
|
|
4
|
+
* Finds saga jobs stuck in 'active' state (indicating the process crashed
|
|
5
|
+
* mid-execution) and marks them as failed. Compensation is not attempted
|
|
6
|
+
* automatically because the step functions are in code, not serialized
|
|
7
|
+
* in the job data — recovery requires re-invoking the saga definition.
|
|
8
|
+
*
|
|
9
|
+
* Designed to run as a periodic cron job or be called manually.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```typescript
|
|
13
|
+
* // In a cron handler:
|
|
14
|
+
* const recovered = await recoverStaleSagas(db);
|
|
15
|
+
* // recovered.length contains the count of stuck sagas found
|
|
16
|
+
*
|
|
17
|
+
* // With custom threshold:
|
|
18
|
+
* const recovered = await recoverStaleSagas(db, 10 * 60 * 1000); // 10 minutes
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
import { and, eq, lt, sql } from 'drizzle-orm';
|
|
22
|
+
import { jobs } from '../schema/jobs.js';
|
|
23
|
+
// Default threshold: 5 minutes (generous for Vercel function timeouts)
|
|
24
|
+
const DEFAULT_STALE_THRESHOLD_MS = 5 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* Find and mark stuck saga jobs as failed.
|
|
27
|
+
*
|
|
28
|
+
* A saga is considered "stuck" if it has been in 'active' state longer
|
|
29
|
+
* than the threshold. This indicates the executing process crashed or
|
|
30
|
+
* timed out without completing or compensating.
|
|
31
|
+
*
|
|
32
|
+
* @param db - Database client
|
|
33
|
+
* @param thresholdMs - How long a saga must be active before it's considered stuck
|
|
34
|
+
* @returns List of recovered saga records
|
|
35
|
+
*/
|
|
36
|
+
export async function recoverStaleSagas(db, thresholdMs = DEFAULT_STALE_THRESHOLD_MS) {
|
|
37
|
+
const cutoff = new Date(Date.now() - thresholdMs);
|
|
38
|
+
// Find stuck saga jobs
|
|
39
|
+
const staleJobs = await db
|
|
40
|
+
.select()
|
|
41
|
+
.from(jobs)
|
|
42
|
+
.where(and(eq(jobs.state, 'active'), sql `${jobs.name} LIKE 'saga:%'`, lt(jobs.startedAt, cutoff)));
|
|
43
|
+
if (staleJobs.length === 0)
|
|
44
|
+
return [];
|
|
45
|
+
const recovered = [];
|
|
46
|
+
const now = new Date();
|
|
47
|
+
for (const job of staleJobs) {
|
|
48
|
+
const data = job.data;
|
|
49
|
+
const sagaName = data?.sagaName ?? job.name.replace('saga:', '');
|
|
50
|
+
const sagaKey = data?.sagaKey ?? 'unknown';
|
|
51
|
+
const completedSteps = data?.completedSteps?.map((s) => s.name) ?? [];
|
|
52
|
+
// Mark as failed with recovery metadata
|
|
53
|
+
await db
|
|
54
|
+
.update(jobs)
|
|
55
|
+
.set({
|
|
56
|
+
state: 'failed',
|
|
57
|
+
output: {
|
|
58
|
+
error: 'Saga recovered from stale active state',
|
|
59
|
+
recoveredAt: now.toISOString(),
|
|
60
|
+
completedStepsAtRecovery: completedSteps,
|
|
61
|
+
originalStartedAt: job.startedAt?.toISOString(),
|
|
62
|
+
},
|
|
63
|
+
})
|
|
64
|
+
.where(eq(jobs.id, job.id));
|
|
65
|
+
recovered.push({
|
|
66
|
+
sagaId: job.id,
|
|
67
|
+
sagaName,
|
|
68
|
+
sagaKey,
|
|
69
|
+
completedSteps,
|
|
70
|
+
startedAt: job.startedAt,
|
|
71
|
+
recoveredAt: now,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
return recovered;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Clean up expired idempotency keys.
|
|
78
|
+
* Should be called periodically to prevent the table from growing unbounded.
|
|
79
|
+
*
|
|
80
|
+
* @param db - Database client
|
|
81
|
+
* @returns Number of expired keys deleted
|
|
82
|
+
*/
|
|
83
|
+
export async function cleanupExpiredIdempotencyKeys(db) {
|
|
84
|
+
// Dynamic import to avoid circular dependency at module load time
|
|
85
|
+
const { idempotencyKeys } = await import('../schema/idempotency.js');
|
|
86
|
+
const result = await db
|
|
87
|
+
.delete(idempotencyKeys)
|
|
88
|
+
.where(lt(idempotencyKeys.expiresAt, new Date()))
|
|
89
|
+
.returning();
|
|
90
|
+
return result.length;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=recovery.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"recovery.js","sourceRoot":"","sources":["../../src/saga/recovery.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AAEH,OAAO,EAAE,GAAG,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,EAAE,MAAM,aAAa,CAAC;AAE/C,OAAO,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAC;AAGzC,uEAAuE;AACvE,MAAM,0BAA0B,GAAG,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC;AAWjD;;;;;;;;;;GAUG;AACH,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,EAAY,EACZ,cAAsB,0BAA0B;IAEhD,MAAM,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,GAAG,WAAW,CAAC,CAAC;IAElD,uBAAuB;IACvB,MAAM,SAAS,GAAG,MAAM,EAAE;SACvB,MAAM,EAAE;SACR,IAAI,CAAC,IAAI,CAAC;SACV,KAAK,CACJ,GAAG,CAAC,EAAE,CAAC,IAAI,CAAC,KAAK,EAAE,QAAQ,CAAC,EAAE,GAAG,CAAA,GAAG,IAAI,CAAC,IAAI,gBAAgB,EAAE,EAAE,CAAC,IAAI,CAAC,SAAS,EAAE,MAAM,CAAC,CAAC,CAC3F,CAAC;IAEJ,IAAI,SAAS,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,CAAC;IAEtC,MAAM,SAAS,GAAoB,EAAE,CAAC;IACtC,MAAM,GAAG,GAAG,IAAI,IAAI,EAAE,CAAC;IAEvB,KAAK,MAAM,GAAG,IAAI,SAAS,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,GAAG,CAAC,IAA4C,CAAC;QAC9D,MAAM,QAAQ,GAAG,IAAI,EAAE,QAAQ,IAAI,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;QACjE,MAAM,OAAO,GAAG,IAAI,EAAE,OAAO,IAAI,SAAS,CAAC;QAC3C,MAAM,cAAc,GAAG,IAAI,EAAE,cAAc,EAAE,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC;QAEtE,wCAAwC;QACxC,MAAM,EAAE;aACL,MAAM,CAAC,IAAI,CAAC;aACZ,GAAG,CAAC;YACH,KAAK,EAAE,QAAQ;YACf,MAAM,EAAE;gBACN,KAAK,EAAE,wCAAwC;gBAC/C,WAAW,EAAE,GAAG,CAAC,WAAW,EAAE;gBAC9B,wBAAwB,EAAE,cAAc;gBACxC,iBAAiB,EAAE,GAAG,CAAC,SAAS,EAAE,WAAW,EAAE;aACrB;SAC7B,CAAC;aACD,KAAK,CAAC,EAAE,CAAC,IAAI,CAAC,EAAE,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;QAE9B,SAAS,CAAC,IAAI,CAAC;YACb,MAAM,EAAE,GAAG,CAAC,EAAE;YACd,QAAQ;YACR,OAAO;YACP,cAAc;YACd,SAAS,EAAE,GAAG,CAAC,SAAS;YACxB,WAAW,EAAE,GAAG;SACjB,CAAC,CAAC;IACL,CAAC;IAED,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,6BAA6B,CAAC,EAAY;IAC9D,kEAAkE;IAClE,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,MAAM,CAAC,0BAA0B,CAAC,CAAC;IAErE,MAAM,MAAM,GAAG,MAAM,EAAE;SACpB,MAAM,CAAC,eAAe,CAAC;SACvB,KAAK,CAAC,EAAE,CAAC,eAAe,CAAC,SAAS,EAAE,IAAI,IAAI,EAAE,CAAC,CAAC;SAChD,SAAS,EAAE,CAAC;IAEf,OAAO,MAAM,CAAC,MAAM,CAAC;AACvB,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resilient Step Wrapper
|
|
3
|
+
*
|
|
4
|
+
* Wraps saga steps with retry (exponential backoff). Transient NeonDB
|
|
5
|
+
* failures (network timeouts, 5xx responses) are retried before triggering
|
|
6
|
+
* compensation.
|
|
7
|
+
*
|
|
8
|
+
* Uses a lightweight built-in retry to avoid a dependency on @revealui/resilience
|
|
9
|
+
* from the db package. For advanced circuit breaker integration, wrap the saga
|
|
10
|
+
* step's execute function with @revealui/resilience directly at the call site.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```typescript
|
|
14
|
+
* const steps = [
|
|
15
|
+
* resilientStep({
|
|
16
|
+
* name: 'insert-license',
|
|
17
|
+
* execute: async (ctx) => { ... },
|
|
18
|
+
* compensate: async (ctx, output) => { ... },
|
|
19
|
+
* }, { maxRetries: 5, baseDelay: 200 }),
|
|
20
|
+
* ];
|
|
21
|
+
*
|
|
22
|
+
* await executeSaga(db, 'provision', key, steps);
|
|
23
|
+
* ```
|
|
24
|
+
*/
|
|
25
|
+
import type { SagaRetryOptions, SagaStep } from './types.js';
|
|
26
|
+
/**
|
|
27
|
+
* Wrap a saga step with retry logic.
|
|
28
|
+
*
|
|
29
|
+
* The execute function is retried on transient failures (network errors,
|
|
30
|
+
* 5xx status codes). The compensate function is NOT retried — compensations
|
|
31
|
+
* should be idempotent and infallible by design.
|
|
32
|
+
*
|
|
33
|
+
* @param step - The saga step to wrap
|
|
34
|
+
* @param options - Retry configuration
|
|
35
|
+
* @returns A new SagaStep with retry-wrapped execute
|
|
36
|
+
*/
|
|
37
|
+
export declare function resilientStep<TOutput = unknown>(step: SagaStep<TOutput>, options?: SagaRetryOptions): SagaStep<TOutput>;
|
|
38
|
+
//# sourceMappingURL=resilient-step.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"resilient-step.d.ts","sourceRoot":"","sources":["../../src/saga/resilient-step.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,KAAK,EAAe,gBAAgB,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAsC1E;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAG,OAAO,EAC7C,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,EACvB,OAAO,CAAC,EAAE,gBAAgB,GACzB,QAAQ,CAAC,OAAO,CAAC,CAenB"}
|