@revealui/db 0.3.4 → 0.3.6
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 +16 -12
- 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/code-provenance.d.ts +13 -13
- package/dist/queries/code-provenance.d.ts.map +1 -1
- package/dist/queries/code-provenance.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/media.d.ts +6 -6
- package/dist/queries/media.d.ts.map +1 -1
- package/dist/queries/media.js.map +1 -1
- package/dist/queries/oauth-accounts.d.ts +9 -0
- package/dist/queries/oauth-accounts.d.ts.map +1 -0
- package/dist/queries/oauth-accounts.js +15 -0
- package/dist/queries/oauth-accounts.js.map +1 -0
- package/dist/queries/orders.d.ts +5 -5
- package/dist/queries/orders.d.ts.map +1 -1
- package/dist/queries/orders.js.map +1 -1
- package/dist/queries/pages.d.ts +7 -7
- package/dist/queries/pages.d.ts.map +1 -1
- package/dist/queries/pages.js.map +1 -1
- package/dist/queries/passkeys.d.ts +21 -0
- package/dist/queries/passkeys.d.ts.map +1 -0
- package/dist/queries/passkeys.js +19 -0
- package/dist/queries/passkeys.js.map +1 -0
- package/dist/queries/posts.d.ts +8 -8
- package/dist/queries/posts.d.ts.map +1 -1
- package/dist/queries/posts.js.map +1 -1
- package/dist/queries/products.d.ts +7 -7
- package/dist/queries/products.d.ts.map +1 -1
- package/dist/queries/products.js.map +1 -1
- package/dist/queries/sessions.d.ts +30 -0
- package/dist/queries/sessions.d.ts.map +1 -0
- package/dist/queries/sessions.js +37 -0
- package/dist/queries/sessions.js.map +1 -0
- package/dist/queries/sites.d.ts +11 -11
- package/dist/queries/sites.d.ts.map +1 -1
- package/dist/queries/sites.js.map +1 -1
- package/dist/queries/ticket-comments.d.ts +8 -8
- 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/queries/ticket-labels.d.ts +9 -9
- package/dist/queries/ticket-labels.d.ts.map +1 -1
- package/dist/queries/ticket-labels.js.map +1 -1
- package/dist/queries/tickets.d.ts +12 -12
- package/dist/queries/tickets.d.ts.map +1 -1
- package/dist/queries/tickets.js.map +1 -1
- package/dist/queries/user-api-keys.d.ts +28 -0
- package/dist/queries/user-api-keys.d.ts.map +1 -0
- package/dist/queries/user-api-keys.js +49 -0
- package/dist/queries/user-api-keys.js.map +1 -0
- package/dist/queries/users.d.ts +52 -10
- package/dist/queries/users.d.ts.map +1 -1
- package/dist/queries/users.js +20 -1
- package/dist/queries/users.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/accounts.d.ts.map +1 -1
- package/dist/schema/accounts.js +9 -1
- package/dist/schema/accounts.js.map +1 -1
- package/dist/schema/agents.d.ts.map +1 -1
- package/dist/schema/agents.js +22 -8
- 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.d.ts +17 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +40 -1
- package/dist/schema/index.js.map +1 -1
- package/dist/schema/licenses.d.ts.map +1 -1
- package/dist/schema/licenses.js +13 -1
- package/dist/schema/licenses.js.map +1 -1
- package/dist/schema/rest.d.ts +2 -0
- package/dist/schema/rest.d.ts.map +1 -1
- package/dist/schema/rest.js +2 -0
- package/dist/schema/rest.js.map +1 -1
- package/dist/schema/revmarket.d.ts +971 -0
- package/dist/schema/revmarket.d.ts.map +1 -0
- package/dist/schema/revmarket.js +160 -0
- package/dist/schema/revmarket.js.map +1 -0
- package/dist/types/database.d.ts +92 -1
- package/dist/types/database.d.ts.map +1 -1
- package/dist/types/database.js +20 -0
- package/dist/types/database.js.map +1 -1
- package/package.json +29 -4
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CRDT Resolver — Conflict Resolution Bridge
|
|
3
|
+
*
|
|
4
|
+
* Bridges the existing CRDT classes from @revealui/ai/memory/crdt with
|
|
5
|
+
* Drizzle ORM operations for conflict-free concurrent writes over NeonDB.
|
|
6
|
+
*
|
|
7
|
+
* Two patterns:
|
|
8
|
+
* 1. PNCounter for commutative balance updates (agent credits, usage metrics)
|
|
9
|
+
* 2. LWWRegister for last-writer-wins value updates with optimistic concurrency
|
|
10
|
+
*
|
|
11
|
+
* These patterns handle concurrent writes without coordination — the CRDT
|
|
12
|
+
* merge function resolves conflicts deterministically.
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```typescript
|
|
16
|
+
* // Increment agent credit balance (safe under concurrency)
|
|
17
|
+
* await crdtIncrement(db, agentCreditBalance, userId, 'balance', 10, 'node-1');
|
|
18
|
+
*
|
|
19
|
+
* // Set a value with last-writer-wins semantics
|
|
20
|
+
* await crdtSetValue(db, agentContexts, contextId, 'metadata', newMetadata, 'node-1');
|
|
21
|
+
* ```
|
|
22
|
+
*/
|
|
23
|
+
import { sql } from 'drizzle-orm';
|
|
24
|
+
// =============================================================================
|
|
25
|
+
// Types
|
|
26
|
+
// =============================================================================
|
|
27
|
+
/** Maximum number of optimistic concurrency retries before giving up */
|
|
28
|
+
const MAX_RETRIES = 5;
|
|
29
|
+
// =============================================================================
|
|
30
|
+
// PNCounter Pattern — Commutative Increments/Decrements
|
|
31
|
+
// =============================================================================
|
|
32
|
+
/**
|
|
33
|
+
* Atomically increment (or decrement) a numeric column using SQL arithmetic.
|
|
34
|
+
*
|
|
35
|
+
* This is naturally idempotent-safe when combined with an idempotency key.
|
|
36
|
+
* Under the hood it uses `SET column = column + delta` which is atomic in
|
|
37
|
+
* a single SQL statement — no CRDT serialization needed for simple counters.
|
|
38
|
+
*
|
|
39
|
+
* For distributed multi-node scenarios where each node tracks its own
|
|
40
|
+
* counter state, use the full PNCounter class from @revealui/ai/memory/crdt.
|
|
41
|
+
*
|
|
42
|
+
* @param db - Database client
|
|
43
|
+
* @param table - Drizzle table reference
|
|
44
|
+
* @param whereClause - WHERE clause to identify the row
|
|
45
|
+
* @param column - Column name to increment
|
|
46
|
+
* @param delta - Amount to add (negative for decrement)
|
|
47
|
+
* @returns Previous and new values
|
|
48
|
+
*/
|
|
49
|
+
export async function crdtIncrement(db, table, whereClause, column, delta) {
|
|
50
|
+
// Read current value
|
|
51
|
+
const rows = await db.select().from(table).where(whereClause).limit(1);
|
|
52
|
+
if (rows.length === 0) {
|
|
53
|
+
throw new Error('CRDT increment: row not found');
|
|
54
|
+
}
|
|
55
|
+
const row = rows[0];
|
|
56
|
+
const previousValue = typeof row[column] === 'number' ? row[column] : 0;
|
|
57
|
+
const newValue = previousValue + delta;
|
|
58
|
+
// Atomic update using SQL arithmetic via set
|
|
59
|
+
await db
|
|
60
|
+
.update(table)
|
|
61
|
+
.set({ [column]: newValue })
|
|
62
|
+
.where(whereClause);
|
|
63
|
+
return { previousValue, newValue, retries: 0 };
|
|
64
|
+
}
|
|
65
|
+
// =============================================================================
|
|
66
|
+
// LWW Register Pattern — Last-Writer-Wins with Optimistic Concurrency
|
|
67
|
+
// =============================================================================
|
|
68
|
+
/**
|
|
69
|
+
* Detect whether a Database instance supports real transactions (pg Pool).
|
|
70
|
+
* NeonDB HTTP clients are stateless and cannot hold transaction state.
|
|
71
|
+
*/
|
|
72
|
+
function supportsTransactions(db) {
|
|
73
|
+
return ('transaction' in db &&
|
|
74
|
+
typeof db.transaction === 'function');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Set a value using last-writer-wins semantics with optimistic concurrency.
|
|
78
|
+
*
|
|
79
|
+
* When the database client supports transactions (Supabase pg Pool), uses
|
|
80
|
+
* SELECT FOR UPDATE inside a real transaction for true atomic check-and-set.
|
|
81
|
+
*
|
|
82
|
+
* When running on NeonDB HTTP (no transaction support), falls back to a
|
|
83
|
+
* best-effort single-statement UPDATE — still safe for single-row writes
|
|
84
|
+
* but without the isolation guarantee of FOR UPDATE.
|
|
85
|
+
*
|
|
86
|
+
* @param db - Database client (pg Pool for true locking, NeonDB HTTP for best-effort)
|
|
87
|
+
* @param table - Drizzle table reference
|
|
88
|
+
* @param whereClause - WHERE clause to identify the row
|
|
89
|
+
* @param updates - Object of column→value updates to apply
|
|
90
|
+
* @param timestampColumn - Column name used for optimistic concurrency (default: 'updatedAt')
|
|
91
|
+
* @returns Retry count and success status
|
|
92
|
+
*/
|
|
93
|
+
export async function crdtSetWithOptimisticLock(db, table, whereClause, updates, timestampColumn = 'updatedAt') {
|
|
94
|
+
// pg Pool path — true transactional SELECT FOR UPDATE
|
|
95
|
+
if (supportsTransactions(db)) {
|
|
96
|
+
return crdtSetTransactional(db, table, whereClause, updates, timestampColumn);
|
|
97
|
+
}
|
|
98
|
+
// NeonDB HTTP fallback — best-effort single-statement update
|
|
99
|
+
return crdtSetBestEffort(db, table, whereClause, updates, timestampColumn);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* True atomic check-and-set via SELECT FOR UPDATE + conditional UPDATE
|
|
103
|
+
* inside a pg transaction. Retries on serialization failures.
|
|
104
|
+
*/
|
|
105
|
+
async function crdtSetTransactional(db, table, whereClause, updates, timestampColumn) {
|
|
106
|
+
let retries = 0;
|
|
107
|
+
while (retries < MAX_RETRIES) {
|
|
108
|
+
try {
|
|
109
|
+
const success = await db.transaction(async (tx) => {
|
|
110
|
+
// Lock the row — blocks concurrent writers until this tx commits
|
|
111
|
+
const rows = await tx.select().from(table).where(whereClause).for('update').limit(1);
|
|
112
|
+
if (rows.length === 0) {
|
|
113
|
+
throw new Error('CRDT set: row not found');
|
|
114
|
+
}
|
|
115
|
+
const row = rows[0];
|
|
116
|
+
const currentTimestamp = row[timestampColumn];
|
|
117
|
+
// Write with new timestamp
|
|
118
|
+
const newTimestamp = new Date();
|
|
119
|
+
const result = await tx
|
|
120
|
+
.update(table)
|
|
121
|
+
.set({
|
|
122
|
+
...updates,
|
|
123
|
+
[timestampColumn]: newTimestamp,
|
|
124
|
+
})
|
|
125
|
+
.where(sql `${whereClause} AND ${sql.identifier(timestampColumn)} = ${currentTimestamp}`)
|
|
126
|
+
.returning();
|
|
127
|
+
return result.length > 0;
|
|
128
|
+
});
|
|
129
|
+
if (success) {
|
|
130
|
+
return { retries, success: true };
|
|
131
|
+
}
|
|
132
|
+
retries++;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
136
|
+
// Row not found is a hard error, not a retry scenario
|
|
137
|
+
if (message === 'CRDT set: row not found')
|
|
138
|
+
throw error;
|
|
139
|
+
// Serialization/deadlock failures are retryable
|
|
140
|
+
retries++;
|
|
141
|
+
if (retries >= MAX_RETRIES) {
|
|
142
|
+
return { retries, success: false };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { retries, success: false };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Best-effort single-statement UPDATE for NeonDB HTTP.
|
|
150
|
+
* No true isolation — relies on the write being a single atomic statement.
|
|
151
|
+
*/
|
|
152
|
+
async function crdtSetBestEffort(db, table, whereClause, updates, timestampColumn) {
|
|
153
|
+
const rows = await db.select().from(table).where(whereClause).limit(1);
|
|
154
|
+
if (rows.length === 0) {
|
|
155
|
+
throw new Error('CRDT set: row not found');
|
|
156
|
+
}
|
|
157
|
+
const newTimestamp = new Date();
|
|
158
|
+
const result = await db
|
|
159
|
+
.update(table)
|
|
160
|
+
.set({
|
|
161
|
+
...updates,
|
|
162
|
+
[timestampColumn]: newTimestamp,
|
|
163
|
+
})
|
|
164
|
+
.where(whereClause)
|
|
165
|
+
.returning();
|
|
166
|
+
return { retries: 0, success: result.length > 0 };
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=crdt-resolver.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"crdt-resolver.js","sourceRoot":"","sources":["../../src/saga/crdt-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
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 type { Database } from '../client/index.js';
|
|
26
|
+
export interface IdempotentWriteResult<T> {
|
|
27
|
+
/** The operation's return value (undefined if already processed) */
|
|
28
|
+
result?: T;
|
|
29
|
+
/** True if the operation was skipped because the key already existed */
|
|
30
|
+
alreadyProcessed: boolean;
|
|
31
|
+
}
|
|
32
|
+
export interface IdempotentWriteOptions {
|
|
33
|
+
/** TTL for the idempotency key in ms (default: 24 hours) */
|
|
34
|
+
ttlMs?: number;
|
|
35
|
+
/** Cache the result in the idempotency record for retrieval on replay */
|
|
36
|
+
cacheResult?: boolean;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Execute an operation idempotently.
|
|
40
|
+
*
|
|
41
|
+
* 1. Check if the key exists in idempotency_keys (skip if so)
|
|
42
|
+
* 2. Execute the operation
|
|
43
|
+
* 3. Record the key (ON CONFLICT DO NOTHING for race-condition safety)
|
|
44
|
+
*
|
|
45
|
+
* @param db - Database client
|
|
46
|
+
* @param key - Unique idempotency key for this operation
|
|
47
|
+
* @param operationType - Category string (e.g., 'saga', 'email', 'webhook')
|
|
48
|
+
* @param operation - The function to execute idempotently
|
|
49
|
+
* @param options - TTL and caching options
|
|
50
|
+
*/
|
|
51
|
+
export declare function idempotentWrite<T>(db: Database, key: string, operationType: string, operation: () => Promise<T>, options?: IdempotentWriteOptions): Promise<IdempotentWriteResult<T>>;
|
|
52
|
+
//# sourceMappingURL=idempotent-operation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"idempotent-operation.d.ts","sourceRoot":"","sources":["../../src/saga/idempotent-operation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAGH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,oBAAoB,CAAC;AAMnD,MAAM,WAAW,qBAAqB,CAAC,CAAC;IACtC,oEAAoE;IACpE,MAAM,CAAC,EAAE,CAAC,CAAC;IACX,wEAAwE;IACxE,gBAAgB,EAAE,OAAO,CAAC;CAC3B;AAED,MAAM,WAAW,sBAAsB;IACrC,4DAA4D;IAC5D,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,WAAW,CAAC,EAAE,OAAO,CAAC;CACvB;AAED;;;;;;;;;;;;GAYG;AACH,wBAAsB,eAAe,CAAC,CAAC,EACrC,EAAE,EAAE,QAAQ,EACZ,GAAG,EAAE,MAAM,EACX,aAAa,EAAE,MAAM,EACrB,SAAS,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,EAC3B,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,qBAAqB,CAAC,CAAC,CAAC,CAAC,CA0CnC"}
|
|
@@ -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
|