@tangle-network/agent-gateway 0.7.1 → 0.8.1

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.
Files changed (64) hide show
  1. package/README.md +90 -3
  2. package/dist/chunk-C7Z2BRYV.js +5693 -0
  3. package/dist/chunk-C7Z2BRYV.js.map +1 -0
  4. package/dist/chunk-GITV7CPT.js +84 -0
  5. package/dist/chunk-GITV7CPT.js.map +1 -0
  6. package/dist/chunk-J5SDVHOL.js +104 -0
  7. package/dist/chunk-J5SDVHOL.js.map +1 -0
  8. package/dist/index.d.ts +70 -10
  9. package/dist/index.js +303 -21
  10. package/dist/index.js.map +1 -1
  11. package/dist/middleware.d.ts +7 -2
  12. package/dist/middleware.js +3 -2
  13. package/dist/nonce-store.d.ts +47 -11
  14. package/dist/nonce-store.js +9 -3
  15. package/dist/observer-types-A0RtA8uL.d.ts +95 -0
  16. package/dist/observer.d.ts +79 -0
  17. package/dist/observer.js +11 -0
  18. package/dist/observer.js.map +1 -0
  19. package/dist/{types-DEsMmS-X.d.ts → types-oQ58UakD.d.ts} +447 -172
  20. package/dist/types.d.ts +2 -1
  21. package/package.json +1 -1
  22. package/src/a2a/execution-fence.ts +162 -0
  23. package/src/a2a/handler.ts +506 -560
  24. package/src/a2a/message-send-execution.ts +241 -0
  25. package/src/a2a/message-stream-execution.ts +392 -0
  26. package/src/a2a/payment-recovery.ts +431 -0
  27. package/src/a2a/push-config-methods.ts +158 -0
  28. package/src/a2a/push-notifications.ts +172 -22
  29. package/src/a2a/task-cancellation.ts +50 -0
  30. package/src/a2a/task-finalization.ts +451 -0
  31. package/src/a2a/task-lifecycle.ts +54 -0
  32. package/src/a2a/task-methods.ts +163 -0
  33. package/src/a2a/task-push-delivery.ts +119 -0
  34. package/src/a2a/task-recovery.ts +11 -0
  35. package/src/a2a/task-state.ts +99 -0
  36. package/src/a2a/task-store-sql.ts +222 -24
  37. package/src/a2a/task-store.ts +58 -1
  38. package/src/a2a/task-submission-recovery.ts +178 -0
  39. package/src/a2a/types.ts +1 -0
  40. package/src/dispatch-authorization.ts +468 -0
  41. package/src/dispatch-payment-recovery.ts +248 -0
  42. package/src/dispatch-payment.ts +425 -0
  43. package/src/dispatch-pricing.ts +108 -0
  44. package/src/dispatch-sandbox.ts +424 -0
  45. package/src/dispatch-settlement.ts +139 -0
  46. package/src/dispatch-types.ts +84 -0
  47. package/src/dispatch.ts +35 -483
  48. package/src/index.ts +59 -1
  49. package/src/middleware.ts +339 -35
  50. package/src/mpp-payment.ts +117 -0
  51. package/src/nonce-store.ts +122 -20
  52. package/src/observer-types.ts +63 -0
  53. package/src/observer.ts +3 -63
  54. package/src/payment-operations.ts +485 -0
  55. package/src/payment-recovery-sql.ts +108 -0
  56. package/src/payment-recovery-worker.ts +488 -0
  57. package/src/payment-recovery.ts +331 -0
  58. package/src/payment-types.ts +48 -0
  59. package/src/types.ts +188 -49
  60. package/src/verify.ts +240 -71
  61. package/dist/chunk-M7ZJAK4K.js +0 -53
  62. package/dist/chunk-M7ZJAK4K.js.map +0 -1
  63. package/dist/chunk-Q4YAIEZY.js +0 -1763
  64. package/dist/chunk-Q4YAIEZY.js.map +0 -1
@@ -0,0 +1,485 @@
1
+ import type {
2
+ PaymentSettlementBasis,
3
+ SandboxUsageReceipt,
4
+ } from './payment-types'
5
+
6
+ /** Version negotiated by gateways that use durable payment operations. */
7
+ export const PAYMENT_PROTOCOL_VERSION = 2 as const
8
+
9
+ export type PaymentOperationState =
10
+ | 'claiming'
11
+ | 'claimed'
12
+ | 'executing'
13
+ | 'retained'
14
+ | 'settling'
15
+ | 'settled'
16
+ | 'releasing'
17
+ | 'released'
18
+ | 'reclaimable'
19
+ | 'reclaimed'
20
+
21
+ /** Fenced result for a recovery lookup that found no provider operation. */
22
+ export interface PaymentOperationNotFound {
23
+ protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
24
+ operationId: string
25
+ state: 'not-found'
26
+ }
27
+
28
+ export type PaymentOperationRecoveryResult = PaymentOperation | PaymentOperationNotFound
29
+
30
+ /** Durable ownership of one signed payment authorization. */
31
+ export interface PaymentOperation {
32
+ protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
33
+ operationId: string
34
+ /** Request that atomically created this operation. Idempotent reads retain the original value. */
35
+ acquiredByRequestId: string
36
+ executionStartedAt?: number
37
+ retentionReason?: string
38
+ nonceKey: string
39
+ authorizationId: string
40
+ reservedAmount: bigint
41
+ settledAmount: bigint
42
+ refundAmount: bigint
43
+ expiresAt: number
44
+ state: PaymentOperationState
45
+ }
46
+
47
+ export interface PaymentAuthorizationContext {
48
+ requestId: string
49
+ agentId: string
50
+ requiredAmount: bigint
51
+ maxOutputTokens: number
52
+ executionBudget: {
53
+ maxInputTokens: number
54
+ maxOutputTokens: number
55
+ maxReasoningTokens: number
56
+ maxToolTokens: number
57
+ maxToolCalls: number
58
+ maxProviderCostUsd: number
59
+ }
60
+ }
61
+
62
+ export interface PaymentSettlementInput {
63
+ amount: bigint
64
+ totalCostUsd: number
65
+ usage: SandboxUsageReceipt
66
+ /** Distinguishes a provider receipt from the bounded missing-receipt fallback. */
67
+ basis: PaymentSettlementBasis
68
+ }
69
+
70
+ /**
71
+ * One payment lifecycle shared by every payment-backed gateway surface.
72
+ * Implementations must persist the operation before external side effects.
73
+ */
74
+ export interface PaymentOperations {
75
+ readonly protocolVersion: typeof PAYMENT_PROTOCOL_VERSION
76
+ claimPayment(
77
+ payload: Record<string, unknown>,
78
+ context: PaymentAuthorizationContext,
79
+ ): Promise<PaymentOperation>
80
+ /** Prevent expiry reclaim while the sandbox can consume provider resources. */
81
+ beginPaymentExecution(operation: PaymentOperation): Promise<PaymentOperation>
82
+ /** Preserve funds after work when the final usage receipt is still missing. */
83
+ retainPayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation>
84
+ settlePayment(
85
+ operation: PaymentOperation,
86
+ input: PaymentSettlementInput,
87
+ ): Promise<PaymentOperation>
88
+ /**
89
+ * Read the authoritative durable operation without changing its state.
90
+ * Recovery uses this to avoid repeating a provider settlement after the
91
+ * provider committed but the task finalization write lost its acknowledgement.
92
+ */
93
+ getPaymentOperation(operationId: string): Promise<PaymentOperationRecoveryResult>
94
+ /**
95
+ * Release an unused authorization.
96
+ * Repeated calls must recover an ambiguous acknowledgement by operationId.
97
+ */
98
+ releasePayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation>
99
+ reclaimPayment(operationId: string): Promise<PaymentOperationRecoveryResult>
100
+ }
101
+
102
+ export interface MemoryPaymentOperationsOptions {
103
+ now?: () => number
104
+ onClaim?: (operation: PaymentOperation) => Promise<void>
105
+ onSettle?: (operation: PaymentOperation, input: PaymentSettlementInput) => Promise<void>
106
+ onRelease?: (operation: PaymentOperation, reason: string) => Promise<void>
107
+ onReclaim?: (operation: PaymentOperation) => Promise<void>
108
+ }
109
+
110
+ /** Small atomic implementation used by single-process deployments and tests. */
111
+ export class MemoryPaymentOperations implements PaymentOperations {
112
+ readonly protocolVersion = PAYMENT_PROTOCOL_VERSION
113
+ private readonly operations = new Map<string, PaymentOperation>()
114
+ private readonly claimFlights = new Map<string, Promise<PaymentOperation>>()
115
+ private readonly claimTokens = new Map<string, string>()
116
+ private readonly settleFlights = new Map<string, Promise<PaymentOperation>>()
117
+ private readonly releaseFlights = new Map<string, Promise<PaymentOperation>>()
118
+ private readonly reclaimFlights = new Map<string, Promise<PaymentOperation>>()
119
+ private readonly now: () => number
120
+
121
+ constructor(private readonly options: MemoryPaymentOperationsOptions = {}) {
122
+ if ((options.onClaim || options.onSettle || options.onRelease) && !options.onReclaim) {
123
+ throw new Error('onReclaim is required when payment callbacks can lose acknowledgement')
124
+ }
125
+ this.now = options.now ?? (() => Math.floor(Date.now() / 1000))
126
+ }
127
+
128
+ async claimPayment(
129
+ payload: Record<string, unknown>,
130
+ context: PaymentAuthorizationContext,
131
+ ): Promise<PaymentOperation> {
132
+ const nonceKey = paymentNonceKey(payload)
133
+ const operationId = `x402:${nonceKey}`
134
+ const existing = this.operations.get(operationId)
135
+ if (existing) {
136
+ if (existing.state === 'claiming') throw new Error('payment operation is being recovered')
137
+ throw new Error('payment operation was already claimed')
138
+ }
139
+
140
+ const reservedAmount = unsignedAmount(payload.amount)
141
+ if (reservedAmount < context.requiredAmount) {
142
+ throw new Error('payment authorization is below the request ceiling')
143
+ }
144
+ const expiresAt = unsignedAmount(payload.expiry, 'expiry')
145
+ if (expiresAt > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('payment expiry is too large')
146
+ if (Number(expiresAt) <= this.now()) throw new Error('payment authorization has expired')
147
+
148
+ const operation: PaymentOperation = {
149
+ protocolVersion: PAYMENT_PROTOCOL_VERSION,
150
+ operationId,
151
+ acquiredByRequestId: context.requestId,
152
+ nonceKey,
153
+ authorizationId: typeof payload.authHash === 'string' ? payload.authHash : operationId,
154
+ reservedAmount,
155
+ settledAmount: 0n,
156
+ refundAmount: reservedAmount,
157
+ expiresAt: Number(expiresAt),
158
+ state: 'claiming',
159
+ }
160
+ // The map write is synchronous. No second caller can observe a free nonce
161
+ // between the uniqueness check and ownership write.
162
+ this.operations.set(operationId, operation)
163
+ const claimToken = globalThis.crypto.randomUUID()
164
+ this.claimTokens.set(operationId, claimToken)
165
+ const claim = (async () => {
166
+ try {
167
+ await this.options.onClaim?.(operation)
168
+ if (this.claimTokens.get(operationId) !== claimToken) {
169
+ throw new Error('payment claim ownership was reclaimed')
170
+ }
171
+ const current = this.operations.get(operationId)
172
+ if (!current || current.state !== 'claiming') {
173
+ throw new Error('payment claim ownership was lost')
174
+ }
175
+ const claimed = { ...current, state: 'claimed' as const }
176
+ this.operations.set(operationId, claimed)
177
+ return claimed
178
+ } catch (error) {
179
+ // Keep the durable `claiming` row. The external authorization may have
180
+ // committed before its acknowledgement was lost, so expiry recovery
181
+ // must own the decision to reclaim it.
182
+ throw error
183
+ } finally {
184
+ if (this.claimTokens.get(operationId) === claimToken) {
185
+ this.claimTokens.delete(operationId)
186
+ }
187
+ }
188
+ })()
189
+ this.claimFlights.set(operationId, claim)
190
+ try {
191
+ return await claim
192
+ } finally {
193
+ if (this.claimFlights.get(operationId) === claim) {
194
+ this.claimFlights.delete(operationId)
195
+ }
196
+ }
197
+ }
198
+
199
+ async settlePayment(
200
+ operation: PaymentOperation,
201
+ input: PaymentSettlementInput,
202
+ ): Promise<PaymentOperation> {
203
+ const current = this.requireCurrent(operation)
204
+ if (current.state === 'settled') {
205
+ if (current.settledAmount !== input.amount) throw new Error('payment operation was settled twice')
206
+ return current
207
+ }
208
+ if (current.state === 'settling') {
209
+ if (current.settledAmount !== input.amount) throw new Error('payment operation has a different pending settlement')
210
+ const flight = this.settleFlights.get(current.operationId)
211
+ if (flight) return flight
212
+ const reclaimFlight = this.reclaimFlights.get(current.operationId)
213
+ if (reclaimFlight) return reclaimFlight
214
+ const recovery = this.recoverSettlement(current, input)
215
+ this.settleFlights.set(current.operationId, recovery)
216
+ try {
217
+ return await recovery
218
+ } finally {
219
+ if (this.settleFlights.get(current.operationId) === recovery) {
220
+ this.settleFlights.delete(current.operationId)
221
+ }
222
+ }
223
+ } else if (current.state !== 'claimed' && current.state !== 'executing' && current.state !== 'retained') {
224
+ throw new Error(`cannot settle payment in state ${current.state}`)
225
+ }
226
+ validateSettlement(current, input)
227
+ const settling = {
228
+ ...current,
229
+ state: 'settling' as const,
230
+ settledAmount: input.amount,
231
+ refundAmount: current.reservedAmount - input.amount,
232
+ }
233
+ this.operations.set(current.operationId, settling)
234
+ const flight = this.runSettlement(settling, input)
235
+ this.settleFlights.set(current.operationId, flight)
236
+ try {
237
+ return await flight
238
+ } finally {
239
+ if (this.settleFlights.get(current.operationId) === flight) {
240
+ this.settleFlights.delete(current.operationId)
241
+ }
242
+ }
243
+ }
244
+
245
+ async beginPaymentExecution(operation: PaymentOperation): Promise<PaymentOperation> {
246
+ const current = this.requireCurrent(operation)
247
+ if (current.state === 'executing') return current
248
+ if (current.state !== 'claimed') {
249
+ throw new Error(`cannot begin payment execution in state ${current.state}`)
250
+ }
251
+ const executing = {
252
+ ...current,
253
+ state: 'executing' as const,
254
+ executionStartedAt: this.now(),
255
+ }
256
+ this.operations.set(current.operationId, executing)
257
+ return executing
258
+ }
259
+
260
+ async retainPayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation> {
261
+ const current = this.requireCurrent(operation)
262
+ if (current.state === 'retained' || current.state === 'settling' || current.state === 'settled') {
263
+ return current
264
+ }
265
+ if (current.state !== 'claimed' && current.state !== 'executing') {
266
+ throw new Error(`cannot retain payment in state ${current.state}`)
267
+ }
268
+ const retained = {
269
+ ...current,
270
+ state: 'retained' as const,
271
+ retentionReason: reason,
272
+ }
273
+ this.operations.set(current.operationId, retained)
274
+ return retained
275
+ }
276
+
277
+ async releasePayment(operation: PaymentOperation, reason: string): Promise<PaymentOperation> {
278
+ const current = this.requireCurrent(operation)
279
+ if (current.state === 'released') return current
280
+ if (current.state === 'releasing') {
281
+ const flight = this.releaseFlights.get(current.operationId)
282
+ if (flight) return flight
283
+ const recovered = await this.reclaimPayment(current.operationId)
284
+ if (recovered.state === 'not-found') {
285
+ throw new Error('payment operation disappeared during release recovery')
286
+ }
287
+ return recovered
288
+ }
289
+ if (current.state !== 'claimed' && current.state !== 'executing') {
290
+ throw new Error(`cannot release payment in state ${current.state}`)
291
+ }
292
+ const releasing = { ...current, state: 'releasing' as const }
293
+ this.operations.set(current.operationId, releasing)
294
+ return this.runRelease(releasing, reason)
295
+ }
296
+
297
+ async reclaimPayment(operationId: string): Promise<PaymentOperationRecoveryResult> {
298
+ const current = this.operations.get(operationId)
299
+ if (!current) {
300
+ return {
301
+ protocolVersion: PAYMENT_PROTOCOL_VERSION,
302
+ operationId,
303
+ state: 'not-found',
304
+ }
305
+ }
306
+ if (current.state === 'reclaimed') return current
307
+ if (current.state === 'executing' || current.state === 'retained') {
308
+ throw new Error(`cannot reclaim payment in state ${current.state} without a usage receipt`)
309
+ }
310
+ if (current.state === 'settling') {
311
+ const flight = this.settleFlights.get(operationId)
312
+ if (flight) return flight
313
+ const recoveryFlight = this.reclaimFlights.get(operationId)
314
+ if (recoveryFlight) return recoveryFlight
315
+ const recovery = this.recoverSettlement(current, {
316
+ amount: current.settledAmount,
317
+ totalCostUsd: 0,
318
+ basis: 'usage-receipt',
319
+ usage: {
320
+ inputTokens: 0,
321
+ outputTokens: 0,
322
+ reasoningTokens: 0,
323
+ toolTokens: 0,
324
+ toolCallCount: 0,
325
+ providerCostUsd: 0,
326
+ budgetEnforced: true,
327
+ },
328
+ })
329
+ this.reclaimFlights.set(operationId, recovery)
330
+ try {
331
+ return await recovery
332
+ } finally {
333
+ if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
334
+ }
335
+ }
336
+ if (current.state === 'releasing') {
337
+ if (!this.options.onReclaim) throw new Error('payment release recovery is not configured')
338
+ const releaseFlight = this.releaseFlights.get(operationId)
339
+ if (releaseFlight) return releaseFlight
340
+ const flight = this.reclaimFlights.get(operationId)
341
+ if (flight) return flight
342
+ const recovery = this.runReclaim(current, 'released')
343
+ this.reclaimFlights.set(operationId, recovery)
344
+ try {
345
+ return await recovery
346
+ } finally {
347
+ if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
348
+ }
349
+ }
350
+ if (current.state !== 'claiming' && current.state !== 'claimed' && current.state !== 'reclaimable') {
351
+ throw new Error(`cannot reclaim payment in state ${current.state}`)
352
+ }
353
+ const flight = this.reclaimFlights.get(operationId)
354
+ if (flight) return flight
355
+ if (current.expiresAt > this.now()) throw new Error('payment operation has not expired')
356
+ if (current.state === 'claiming') {
357
+ // Invalidate the live claim before starting recovery. Its callback may
358
+ // still finish, but it can no longer promote the operation to claimed.
359
+ this.claimTokens.delete(operationId)
360
+ }
361
+ const reclaimable = { ...current, state: 'reclaimable' as const }
362
+ this.operations.set(operationId, reclaimable)
363
+ const recovery = this.runReclaim(reclaimable, 'reclaimed')
364
+ this.reclaimFlights.set(operationId, recovery)
365
+ try {
366
+ return await recovery
367
+ } finally {
368
+ if (this.reclaimFlights.get(operationId) === recovery) this.reclaimFlights.delete(operationId)
369
+ }
370
+ }
371
+
372
+ private async runSettlement(
373
+ settling: PaymentOperation,
374
+ input: PaymentSettlementInput,
375
+ ): Promise<PaymentOperation> {
376
+ await this.options.onSettle?.(settling, input)
377
+ const settled = {
378
+ ...settling,
379
+ state: 'settled' as const,
380
+ settledAmount: input.amount,
381
+ refundAmount: settling.reservedAmount - input.amount,
382
+ }
383
+ this.operations.set(settling.operationId, settled)
384
+ return settled
385
+ }
386
+
387
+ private async recoverSettlement(
388
+ settling: PaymentOperation,
389
+ input: PaymentSettlementInput,
390
+ ): Promise<PaymentOperation> {
391
+ if (!this.options.onReclaim) throw new Error('payment settlement recovery is not configured')
392
+ await this.options.onReclaim(settling)
393
+ const settled = {
394
+ ...settling,
395
+ state: 'settled' as const,
396
+ settledAmount: input.amount,
397
+ refundAmount: settling.reservedAmount - input.amount,
398
+ }
399
+ this.operations.set(settling.operationId, settled)
400
+ return settled
401
+ }
402
+
403
+ private async runReclaim(
404
+ operation: PaymentOperation,
405
+ finalState: 'released' | 'reclaimed',
406
+ ): Promise<PaymentOperation> {
407
+ await this.options.onReclaim?.(operation)
408
+ const recovered = { ...operation, state: finalState as PaymentOperationState }
409
+ this.operations.set(operation.operationId, recovered)
410
+ return recovered
411
+ }
412
+
413
+ private async runRelease(
414
+ releasing: PaymentOperation,
415
+ reason: string,
416
+ ): Promise<PaymentOperation> {
417
+ const flight = (async () => {
418
+ await this.options.onRelease?.(releasing, reason)
419
+ const released = { ...releasing, state: 'released' as const }
420
+ this.operations.set(releasing.operationId, released)
421
+ return released
422
+ })()
423
+ this.releaseFlights.set(releasing.operationId, flight)
424
+ try {
425
+ return await flight
426
+ } finally {
427
+ if (this.releaseFlights.get(releasing.operationId) === flight) {
428
+ this.releaseFlights.delete(releasing.operationId)
429
+ }
430
+ }
431
+ }
432
+
433
+ get(operationId: string): PaymentOperation | undefined {
434
+ const operation = this.operations.get(operationId)
435
+ return operation ? { ...operation } : undefined
436
+ }
437
+
438
+ async getPaymentOperation(operationId: string): Promise<PaymentOperationRecoveryResult> {
439
+ const operation = this.get(operationId)
440
+ return operation ?? {
441
+ protocolVersion: PAYMENT_PROTOCOL_VERSION,
442
+ operationId,
443
+ state: 'not-found',
444
+ }
445
+ }
446
+
447
+ private requireCurrent(operation: PaymentOperation): PaymentOperation {
448
+ const current = this.operations.get(operation.operationId)
449
+ if (!current) throw new Error('payment operation was not found')
450
+ if (current.protocolVersion !== operation.protocolVersion) {
451
+ throw new Error('payment operation protocol version mismatch')
452
+ }
453
+ return current
454
+ }
455
+ }
456
+
457
+ export function paymentNonceKey(payload: Record<string, unknown>): string {
458
+ const commitment = String(payload.commitment ?? '').toLowerCase()
459
+ const nonce = unsignedAmount(payload.nonce, 'nonce').toString()
460
+ if (!commitment) throw new Error('payment commitment is required')
461
+ return `${commitment}:${nonce}`
462
+ }
463
+
464
+ function unsignedAmount(value: unknown, name = 'amount'): bigint {
465
+ const raw = typeof value === 'string'
466
+ ? value
467
+ : typeof value === 'number' && Number.isSafeInteger(value)
468
+ ? String(value)
469
+ : ''
470
+ if (!/^\d+$/.test(raw)) throw new Error(`${name} is not an unsigned integer`)
471
+ return BigInt(raw)
472
+ }
473
+
474
+ function validateSettlement(operation: PaymentOperation, input: PaymentSettlementInput): void {
475
+ if (input.amount < 0n || input.amount > operation.reservedAmount) {
476
+ throw new Error('settled amount must be between zero and the reserved amount')
477
+ }
478
+ if (!Number.isFinite(input.totalCostUsd) || input.totalCostUsd < 0) {
479
+ throw new Error('settlement cost must be finite and non-negative')
480
+ }
481
+ if (!input.usage.budgetEnforced) throw new Error('sandbox usage receipt is not budget-enforced')
482
+ if (input.basis !== 'usage-receipt' && input.basis !== 'quoted-ceiling') {
483
+ throw new Error('payment settlement basis is invalid')
484
+ }
485
+ }
@@ -0,0 +1,108 @@
1
+ import type { SqlAdapter } from './a2a/task-store-sql'
2
+ import type {
3
+ PaymentRecoveryRecord,
4
+ PaymentRecoveryStore,
5
+ } from './payment-recovery'
6
+
7
+ const TABLE_DDL = (table: string) => `
8
+ CREATE TABLE IF NOT EXISTS ${table} (
9
+ id TEXT PRIMARY KEY,
10
+ state TEXT NOT NULL,
11
+ next_attempt_at INTEGER NOT NULL,
12
+ revision INTEGER NOT NULL,
13
+ payload TEXT NOT NULL,
14
+ updated_at INTEGER NOT NULL
15
+ )
16
+ `
17
+
18
+ const DUE_INDEX_DDL = (table: string) => `
19
+ CREATE INDEX IF NOT EXISTS idx_${table}_due
20
+ ON ${table} (state, next_attempt_at)
21
+ `
22
+
23
+ /** Durable recovery outbox for D1, sqlite, libSQL, or an adapted SQL driver. */
24
+ export class SqlPaymentRecoveryStore implements PaymentRecoveryStore {
25
+ private readonly table: string
26
+
27
+ constructor(
28
+ private readonly db: SqlAdapter,
29
+ options: { table?: string } = {},
30
+ ) {
31
+ this.table = options.table ?? 'payment_recovery'
32
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(this.table)) {
33
+ throw new Error('payment recovery table name is invalid')
34
+ }
35
+ }
36
+
37
+ /** Idempotent. Run this before the gateway starts accepting traffic. */
38
+ async migrate(): Promise<void> {
39
+ await this.db.exec(TABLE_DDL(this.table))
40
+ await this.db.exec(DUE_INDEX_DDL(this.table))
41
+ }
42
+
43
+ async createIfAbsent(record: PaymentRecoveryRecord): Promise<boolean> {
44
+ try {
45
+ const result = await this.db.exec(
46
+ `INSERT INTO ${this.table} (id, state, next_attempt_at, revision, payload, updated_at) VALUES (?, ?, ?, ?, ?, ?)`,
47
+ [
48
+ record.id,
49
+ record.state,
50
+ record.nextAttemptAt,
51
+ record.revision,
52
+ JSON.stringify(record),
53
+ record.updatedAt,
54
+ ],
55
+ )
56
+ return result.rowsAffected === 1
57
+ } catch (error) {
58
+ if (await this.get(record.id)) return false
59
+ throw error
60
+ }
61
+ }
62
+
63
+ async get(id: string): Promise<PaymentRecoveryRecord | undefined> {
64
+ const rows = await this.db.query<{ payload: string }>(
65
+ `SELECT payload FROM ${this.table} WHERE id = ?`,
66
+ [id],
67
+ )
68
+ return rows[0] ? parseRecord(rows[0].payload) : undefined
69
+ }
70
+
71
+ async compareAndSet(
72
+ expected: PaymentRecoveryRecord,
73
+ next: PaymentRecoveryRecord,
74
+ ): Promise<boolean> {
75
+ const result = await this.db.exec(
76
+ `UPDATE ${this.table} SET state = ?, next_attempt_at = ?, revision = ?, payload = ?, updated_at = ? WHERE id = ? AND revision = ?`,
77
+ [
78
+ next.state,
79
+ next.nextAttemptAt,
80
+ next.revision,
81
+ JSON.stringify(next),
82
+ next.updatedAt,
83
+ expected.id,
84
+ expected.revision,
85
+ ],
86
+ )
87
+ return result.rowsAffected === 1
88
+ }
89
+
90
+ async listDue(now: number, limit: number): Promise<PaymentRecoveryRecord[]> {
91
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
92
+ throw new Error('payment recovery scan limit must be a positive safe integer')
93
+ }
94
+ const rows = await this.db.query<{ payload: string }>(
95
+ `SELECT payload FROM ${this.table} WHERE state <> ? AND next_attempt_at <= ? ORDER BY next_attempt_at ASC LIMIT ?`,
96
+ ['reconciled', now, limit],
97
+ )
98
+ return rows.map((row) => parseRecord(row.payload))
99
+ }
100
+ }
101
+
102
+ function parseRecord(payload: string): PaymentRecoveryRecord {
103
+ const value = JSON.parse(payload) as PaymentRecoveryRecord
104
+ if (value.version !== 1 || !value.id || !Number.isSafeInteger(value.revision)) {
105
+ throw new Error('stored payment recovery record is invalid')
106
+ }
107
+ return value
108
+ }