tapimo 0.10.2 → 0.11.0

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 (135) hide show
  1. package/dist/App.d.ts +3412 -1968
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +0 -75
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +1 -143
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/api-keys.d.ts +4 -4
  10. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  11. package/dist/admin/apps/organizations.d.ts +3 -3
  12. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  13. package/dist/apps/data/App.d.ts +3618 -2954
  14. package/dist/apps/data/App.d.ts.map +1 -1
  15. package/dist/apps/data/App.js +5 -2
  16. package/dist/apps/data/App.js.map +1 -1
  17. package/dist/apps/data/routes/activities.d.ts +1 -1
  18. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  19. package/dist/apps/data/routes/activities.js +55 -27
  20. package/dist/apps/data/routes/activities.js.map +1 -1
  21. package/dist/apps/data/routes/addresses.d.ts +343 -343
  22. package/dist/apps/data/routes/balances.d.ts +11 -11
  23. package/dist/apps/data/routes/blocks.d.ts +5 -5
  24. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  25. package/dist/apps/data/routes/earn.d.ts +4 -4
  26. package/dist/apps/data/routes/exchanges.d.ts +4 -4
  27. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  28. package/dist/apps/data/routes/indexer.d.ts +34 -34
  29. package/dist/apps/data/routes/receipts.d.ts +236 -236
  30. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  31. package/dist/apps/data/routes/tokens.d.ts +3 -3
  32. package/dist/apps/data/routes/transactions.d.ts +1 -1
  33. package/dist/apps/data/routes/transfers.d.ts +13 -13
  34. package/dist/apps/data/routes/valuation.d.ts +21 -21
  35. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  36. package/dist/apps/data/routes/webhooks.d.ts +1011 -151
  37. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  38. package/dist/apps/data/routes/webhooks.js +124 -737
  39. package/dist/apps/data/routes/webhooks.js.map +1 -1
  40. package/dist/apps/data/routes/zones.d.ts +31 -31
  41. package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
  42. package/dist/apps/funding/routes/providers.d.ts +13 -13
  43. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  44. package/dist/apps/management/App.d.ts +793 -16
  45. package/dist/apps/management/App.d.ts.map +1 -1
  46. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  47. package/dist/apps/management/routes/faucet.d.ts +31 -31
  48. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  49. package/dist/apps/management/routes/members.d.ts +79 -79
  50. package/dist/apps/management/routes/projects.d.ts +40 -40
  51. package/dist/apps/management/routes/usage.d.ts +80 -80
  52. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  53. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  54. package/dist/apps/management/routes/webhooks.js +27 -4
  55. package/dist/apps/management/routes/webhooks.js.map +1 -1
  56. package/dist/db/Db.d.ts.map +1 -1
  57. package/dist/db/Db.js +21 -0
  58. package/dist/db/Db.js.map +1 -1
  59. package/dist/db/Schema.d.ts +7 -1
  60. package/dist/db/Schema.d.ts.map +1 -1
  61. package/dist/db/Schema.js +9 -0
  62. package/dist/db/Schema.js.map +1 -1
  63. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  64. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  65. package/dist/db/tables/webhookQueueEvents.js +4 -0
  66. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  67. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  68. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  69. package/dist/db/tables/webhookSubscriptions.js +63 -100
  70. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  71. package/dist/internal/MetricSink.d.ts +35 -125
  72. package/dist/internal/MetricSink.d.ts.map +1 -1
  73. package/dist/internal/MetricSink.js +50 -121
  74. package/dist/internal/MetricSink.js.map +1 -1
  75. package/dist/internal/Schema.d.ts +3 -1
  76. package/dist/internal/Schema.d.ts.map +1 -1
  77. package/dist/internal/Schema.js +4 -2
  78. package/dist/internal/Schema.js.map +1 -1
  79. package/dist/internal/WebhookTransfer.d.ts +1 -55
  80. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  81. package/dist/internal/WebhookTransfer.js +1 -239
  82. package/dist/internal/WebhookTransfer.js.map +1 -1
  83. package/dist/internal/Webhooks.d.ts +83 -44
  84. package/dist/internal/Webhooks.d.ts.map +1 -1
  85. package/dist/internal/Webhooks.js +87 -73
  86. package/dist/internal/Webhooks.js.map +1 -1
  87. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  88. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  89. package/dist/internal/funding/DepositWebhook.js +74 -0
  90. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  91. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  92. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  93. package/dist/internal/funding/Reconciliation.js +42 -10
  94. package/dist/internal/funding/Reconciliation.js.map +1 -1
  95. package/dist/internal/funding/index.d.ts +1 -0
  96. package/dist/internal/funding/index.d.ts.map +1 -1
  97. package/dist/internal/funding/index.js +1 -0
  98. package/dist/internal/funding/index.js.map +1 -1
  99. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  100. package/dist/internal/webhookDestinations/slack.js +16 -0
  101. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/App.ts +3 -0
  104. package/src/Client.test-d.ts +22 -1
  105. package/src/Webhooks.ts +1 -220
  106. package/src/apps/data/App.ts +8 -2
  107. package/src/apps/data/routes/activities.test.ts +107 -11
  108. package/src/apps/data/routes/activities.ts +64 -32
  109. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  110. package/src/apps/data/routes/webhooks.ts +150 -974
  111. package/src/apps/management/routes/webhooks.test.ts +79 -1
  112. package/src/apps/management/routes/webhooks.ts +40 -4
  113. package/src/db/Db.test.ts +1 -5
  114. package/src/db/Db.ts +23 -0
  115. package/src/db/Schema.ts +13 -0
  116. package/src/db/tables/organizations.test.ts +5 -2
  117. package/src/db/tables/webhookQueueEvents.ts +7 -1
  118. package/src/db/tables/webhookSubscriptions.ts +89 -138
  119. package/src/internal/MetricSink.test.ts +41 -373
  120. package/src/internal/MetricSink.ts +92 -273
  121. package/src/internal/Schema.test.ts +18 -0
  122. package/src/internal/Schema.ts +13 -2
  123. package/src/internal/WebhookTransfer.ts +1 -351
  124. package/src/internal/Webhooks.test.ts +102 -139
  125. package/src/internal/Webhooks.ts +175 -106
  126. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  127. package/src/internal/funding/DepositWebhook.ts +101 -0
  128. package/src/internal/funding/Reconciliation.test.ts +14 -0
  129. package/src/internal/funding/Reconciliation.ts +161 -86
  130. package/src/internal/funding/index.test.ts +1 -0
  131. package/src/internal/funding/index.ts +1 -0
  132. package/src/internal/webhookDestinations/slack.ts +15 -0
  133. package/src/Webhooks.bench.ts +0 -714
  134. package/src/Webhooks.memory.bench.ts +0 -250
  135. package/src/Webhooks.test.ts +0 -146
@@ -49,6 +49,16 @@ export type Owner =
49
49
  type: 'mpp'
50
50
  }
51
51
 
52
+ /** API-key scope applied when managing private-resource subscriptions. */
53
+ export type Access = {
54
+ /** API-key environment. */
55
+ environment: 'production' | 'sandbox'
56
+ /** Project attribution, when the key is project-scoped. */
57
+ projectId?: string | undefined
58
+ /** API-key scopes used to authorize private resource reads. */
59
+ scopes: readonly string[]
60
+ }
61
+
52
62
  /**
53
63
  * Optional human context describing what a subscription is for. Carried onto
54
64
  * every delivered {@link Envelope} and surfaced by destinations (Slack header +
@@ -73,6 +83,8 @@ export type Subscription = {
73
83
  createdAt: string
74
84
  /** Where matched events are delivered (URL or Slack channel). */
75
85
  destination: WebhookDestination.Destination
86
+ /** API-key environment captured when the subscription was created. */
87
+ environment?: 'production' | 'sandbox' | undefined
76
88
  /** Event type the subscription listens to. */
77
89
  eventType: EventType
78
90
  /** ISO expiry timestamp; set for MPP-owned subscriptions (TTL-bounded). */
@@ -87,6 +99,8 @@ export type Subscription = {
87
99
  lastDeliveryAt?: string | undefined
88
100
  /** Authenticated owner. */
89
101
  owner: Owner
102
+ /** Project attribution captured from the creating API key. */
103
+ projectId?: string | undefined
90
104
  /** HMAC signing secret. Returned once at creation; never exposed by reads. */
91
105
  secret: string
92
106
  /** Lifecycle status. */
@@ -103,12 +117,16 @@ export type CreateInput = {
103
117
  context?: Context | undefined
104
118
  /** Where matched events are delivered. `url` destinations are SSRF-validated. */
105
119
  destination: WebhookDestination.Destination
120
+ /** API-key environment used to isolate private resource events. */
121
+ environment?: 'production' | 'sandbox' | undefined
106
122
  /** Event type to listen to. */
107
123
  eventType: EventType
108
124
  /** Event-type-specific filter predicates. */
109
125
  filters?: Record<string, unknown> | undefined
110
126
  /** Owner derived from the request principal. */
111
127
  owner: Owner
128
+ /** Project attribution used to isolate private resource events. */
129
+ projectId?: string | undefined
112
130
  /** Time-to-live in milliseconds; set the subscription to expire (MPP TTL). */
113
131
  ttl?: number | undefined
114
132
  }
@@ -181,7 +199,7 @@ export async function createSubscription(
181
199
  const now = (options.now ?? (() => new Date()))()
182
200
  const iso = now.toISOString()
183
201
  // A one-field checkpoint records the live creation head without claiming it
184
- // was scanned. The poller expands it using its confirmation depth.
202
+ // was scanned; it anchors the fanout boundary for the new subscription.
185
203
  const pollerCursor =
186
204
  options.startBlockNumber === undefined ? undefined : Cursor.encode([options.startBlockNumber])
187
205
 
@@ -190,11 +208,13 @@ export async function createSubscription(
190
208
  ...(input.context === undefined ? {} : { context: input.context }),
191
209
  createdAt: iso,
192
210
  destination: input.destination,
211
+ ...(input.environment === undefined ? {} : { environment: input.environment }),
193
212
  eventType: input.eventType,
194
213
  failureCount: 0,
195
214
  filters: input.filters ?? {},
196
215
  id: generateId(now),
197
216
  owner: input.owner,
217
+ ...(input.projectId === undefined ? {} : { projectId: input.projectId }),
198
218
  secret: generateSecret(),
199
219
  status: 'active',
200
220
  updatedAt: iso,
@@ -236,8 +256,14 @@ export async function getSubscription(
236
256
  db: Db.Db,
237
257
  owner: Owner,
238
258
  id: string,
259
+ options: getSubscription.Options = {},
239
260
  ): Promise<Subscription | null> {
240
- return WebhookSubscriptions.get(db, owner, id, new Date().toISOString())
261
+ return WebhookSubscriptions.get(db, owner, id, new Date().toISOString(), options.access)
262
+ }
263
+
264
+ export declare namespace getSubscription {
265
+ /** Private-resource visibility options. */
266
+ type Options = { access?: Access | undefined }
241
267
  }
242
268
 
243
269
  /**
@@ -255,6 +281,8 @@ export async function listSubscriptions(
255
281
  export declare namespace listSubscriptions {
256
282
  /** Options for {@link listSubscriptions}. */
257
283
  type Options = {
284
+ /** Private-resource visibility scope. */
285
+ access?: Access | undefined
258
286
  /** Return subscriptions older than this id (keyset paging, newest first). */
259
287
  cursor?: string | undefined
260
288
  /** Maximum subscriptions to return. */
@@ -269,13 +297,22 @@ export declare namespace listSubscriptions {
269
297
  * since the per-owner set is bounded by `maxPerOwner`. Feeds the opt-in
270
298
  * `meta.totalCount` on `GET /webhooks`.
271
299
  */
272
- export async function countSubscriptions(db: Db.Db, owner: Owner): Promise<number> {
273
- return WebhookSubscriptions.count(db, owner, new Date().toISOString())
300
+ export async function countSubscriptions(
301
+ db: Db.Db,
302
+ owner: Owner,
303
+ options: countSubscriptions.Options = {},
304
+ ): Promise<number> {
305
+ return WebhookSubscriptions.count(db, owner, new Date().toISOString(), options.access)
306
+ }
307
+
308
+ export declare namespace countSubscriptions {
309
+ /** Private-resource visibility options. */
310
+ type Options = { access?: Access | undefined }
274
311
  }
275
312
 
276
313
  /**
277
314
  * Patches an owner's subscription, re-validating the URL when changed.
278
- * Returns null when absent. The status column drives the poller's partial
315
+ * Returns null when absent. The status column drives the partial active
279
316
  * index, so no secondary-index maintenance is needed.
280
317
  */
281
318
  export async function updateSubscription(
@@ -285,7 +322,12 @@ export async function updateSubscription(
285
322
  patch: PatchInput,
286
323
  options: updateSubscription.Options = {},
287
324
  ): Promise<Subscription | null> {
288
- const current = await getSubscription(db, owner, id)
325
+ const current = await getSubscription(
326
+ db,
327
+ owner,
328
+ id,
329
+ options.access === undefined ? {} : { access: options.access },
330
+ )
289
331
  if (!current) return null
290
332
 
291
333
  if (current.destination.type !== 'url' && patch.destination?.type === 'url')
@@ -307,43 +349,29 @@ export async function updateSubscription(
307
349
  export declare namespace updateSubscription {
308
350
  /** Options for {@link updateSubscription}. */
309
351
  type Options = {
352
+ /** Private-resource visibility scope. */
353
+ access?: Access | undefined
310
354
  /** Clock used for timestamps (injectable for tests). */
311
355
  now?: (() => Date) | undefined
312
356
  }
313
357
  }
314
358
 
315
359
  /** Deletes an owner's subscription; its delivery rows cascade via the FK. */
316
- export async function deleteSubscription(db: Db.Db, owner: Owner, id: string): Promise<boolean> {
317
- return WebhookSubscriptions.remove(db, owner, id, new Date().toISOString())
318
- }
319
-
320
- /**
321
- * Lists active subscriptions for a `(chainId, eventType)` stream. Used by the
322
- * poller; one indexed query (the partial status index replaces the old
323
- * secondary key space).
324
- */
325
- export async function listActive(
360
+ export async function deleteSubscription(
326
361
  db: Db.Db,
327
- chainId: number,
328
- eventType: EventType,
329
- ): Promise<Subscription[]> {
330
- const pairs = await listActiveWithCursors(db, chainId, eventType)
331
- return pairs.map((pair) => pair.subscription)
362
+ owner: Owner,
363
+ id: string,
364
+ options: deleteSubscription.Options = {},
365
+ ): Promise<boolean> {
366
+ return WebhookSubscriptions.remove(db, owner, id, new Date().toISOString(), options.access)
332
367
  }
333
368
 
334
- /**
335
- * Lists a stream's live active subscriptions paired with their poller cursors,
336
- * from one query. The poller's per-tick lookup.
337
- */
338
- export async function listActiveWithCursors(
339
- db: Db.Db,
340
- chainId: number,
341
- eventType: EventType,
342
- ): Promise<{ cursor: string | null; subscription: Subscription }[]> {
343
- return WebhookSubscriptions.listActive(db, chainId, eventType, new Date().toISOString())
369
+ export declare namespace deleteSubscription {
370
+ /** Private-resource visibility options. */
371
+ type Options = { access?: Access | undefined }
344
372
  }
345
373
 
346
- /** Reads every live active subscription on a chain in one poller query. */
374
+ /** Reads every live active subscription on a chain in one query. */
347
375
  export async function listActiveForChain(
348
376
  db: Db.Db,
349
377
  options: listActiveForChain.Options,
@@ -356,29 +384,10 @@ export async function listActiveForChain(
356
384
  }
357
385
 
358
386
  export declare namespace listActiveForChain {
359
- /** Filters for the poller's chain-level subscription read. */
387
+ /** Filters for the chain-level subscription read. */
360
388
  type Options = Pick<WebhookSubscriptions.listActiveForChain.Options, 'chainId' | 'eventTypes'>
361
389
  }
362
390
 
363
- /**
364
- * Reads the freshest cursor block across a chain's active subscriptions, or
365
- * null when none carry a cursor. Creation checkpoints count at their recorded
366
- * head, so a brand-new subscription reads as fresh.
367
- */
368
- export async function maxCursorBlock(db: Db.Db, chainId: number): Promise<number | null> {
369
- const cursors = await WebhookSubscriptions.listActiveCursors(
370
- db,
371
- chainId,
372
- new Date().toISOString(),
373
- )
374
- let max: number | null = null
375
- for (const cursor of cursors) {
376
- const block = cursorBlock(cursor)
377
- if (block !== undefined && (max === null || block > max)) max = block
378
- }
379
- return max
380
- }
381
-
382
391
  /** Decodes the block component of a subscription cursor, if any. */
383
392
  export function cursorBlock(cursor: string | null): number | undefined {
384
393
  if (cursor === null) return undefined
@@ -392,41 +401,10 @@ export async function getCursor(db: Db.Db, subscriptionId: string): Promise<stri
392
401
  return WebhookSubscriptions.getCursor(db, subscriptionId)
393
402
  }
394
403
 
395
- /** Writes a cursor only when its stored value matches the expected cursor. */
396
- export async function compareAndSetCursor(
397
- db: Db.Db,
398
- options: compareAndSetCursor.Options,
399
- ): Promise<boolean> {
400
- return WebhookSubscriptions.compareAndSetCursor(db, options)
401
- }
402
-
403
- export declare namespace compareAndSetCursor {
404
- /** Options for {@link compareAndSetCursor}. */
405
- type Options = WebhookSubscriptions.compareAndSetCursor.Options
406
- }
407
-
408
- /** Writes cursors whose stored values match their expected cursors. */
409
- export async function compareAndSetCursors(
410
- db: Db.Db,
411
- options: compareAndSetCursors.Options,
412
- ): Promise<Set<string>> {
413
- return WebhookSubscriptions.compareAndSetCursors(db, options)
414
- }
415
-
416
- export declare namespace compareAndSetCursors {
417
- /** Options for {@link compareAndSetCursors}. */
418
- type Options = WebhookSubscriptions.compareAndSetCursors.Options
419
- }
420
-
421
- /** Writes a subscription's keyset cursor. */
422
- export async function setCursor(db: Db.Db, subscriptionId: string, cursor: string): Promise<void> {
423
- await WebhookSubscriptions.setCursor(db, subscriptionId, cursor)
424
- }
425
-
426
404
  /**
427
405
  * Deletes expired MPP-owned subscriptions (delivery rows cascade) and
428
- * deliveries past retention. Reads already filter expiry at query time this
429
- * is the janitor reclaiming space, called best-effort per poller tick.
406
+ * deliveries past retention. Reads already filter expiry at query time; this
407
+ * is the janitor reclaiming space, run best-effort on a schedule.
430
408
  */
431
409
  export async function pruneExpired(db: Db.Db, options: pruneExpired.Options = {}): Promise<void> {
432
410
  const iso = (options.now ?? (() => new Date()))().toISOString()
@@ -490,6 +468,10 @@ export async function ensureQueueEvents(
490
468
  ): Promise<ensureQueueEvents.Result> {
491
469
  const now = (options.now ?? (() => new Date()))()
492
470
  const expiresAt = new Date(now.getTime() + (options.ttl ?? deliveryRetentionMs)).toISOString()
471
+ // Carried so the delivery worker can measure observation-to-attempt without
472
+ // re-reading the block; replays have no observation and stay null.
473
+ const observedAt =
474
+ options.observedAt === undefined ? null : new Date(options.observedAt).toISOString()
493
475
  const events = dispatchables.map(({ envelope, subscription }) => ({
494
476
  createdAt: now.toISOString(),
495
477
  envelope,
@@ -499,6 +481,7 @@ export async function ensureQueueEvents(
499
481
  expiresAt,
500
482
  // Due immediately; the value orders the sweep so it is never null here.
501
483
  nextAttemptAt: now.toISOString(),
484
+ observedAt,
502
485
  subscriptionId: subscription.id,
503
486
  }))
504
487
  return {
@@ -520,6 +503,8 @@ export declare namespace ensureQueueEvents {
520
503
  type Options = {
521
504
  /** Clock used for staging timestamps. */
522
505
  now?: (() => Date) | undefined
506
+ /** Epoch ms the head owing these events was observed; absent on replays. */
507
+ observedAt?: number | undefined
523
508
  /** Staged-envelope TTL in ms; defaults to {@link deliveryRetentionMs}. */
524
509
  ttl?: number | undefined
525
510
  }
@@ -652,10 +637,13 @@ export async function deliverClaimedAndRecord(
652
637
  new Date().toISOString(),
653
638
  )
654
639
  if (!subscription || subscription.status !== 'active') return { status: 'skipped' }
640
+ // A staged row predating the observation column, or a replay, reads null.
641
+ const observedAt = claimed.observedAt ? Date.parse(claimed.observedAt) : Number.NaN
655
642
  return {
656
643
  result: await deliverAndRecord(db, subscription, claimed.envelope, {
657
644
  ...options,
658
645
  dequeuedAt,
646
+ ...(Number.isNaN(observedAt) ? {} : { observedAt }),
659
647
  subscriptionReadMs: Date.now() - subscriptionReadStartedAt,
660
648
  }),
661
649
  status: 'delivered',
@@ -667,6 +655,8 @@ export declare namespace deliverClaimedAndRecord {
667
655
  type Claimed = {
668
656
  /** Immutable event envelope to deliver. */
669
657
  envelope: Envelope
658
+ /** ISO head-observation stamp carried by the staged row; null on replays. */
659
+ observedAt?: string | null | undefined
670
660
  /** Subscription re-resolved for current status and secret. */
671
661
  subscriptionId: string
672
662
  }
@@ -714,7 +704,7 @@ export async function recordDelivery(
714
704
  ): Promise<void> {
715
705
  const ttl = options.ttl ?? deliveryRetentionMs
716
706
  // The retention deadline replaces the old store TTL: reads filter on it at
717
- // query time and the poller prunes past it. Anchored to write time (not the
707
+ // query time and pruning reclaims past it. Anchored to write time (not the
718
708
  // row's `createdAt`, which tests pin) — exactly like the old `put` TTL.
719
709
  const expiresAt = new Date(Date.now() + ttl).toISOString()
720
710
  await WebhookDeliveries.insert(db, delivery, expiresAt)
@@ -772,7 +762,7 @@ export async function getDelivery(
772
762
 
773
763
  /**
774
764
  * Default number of consecutive delivery failures after which a subscription
775
- * auto-disables (and is dropped from the poller's active index).
765
+ * auto-disables (and is dropped from the partial active index).
776
766
  */
777
767
  export const maxFailures = 10
778
768
 
@@ -805,7 +795,7 @@ export declare namespace recordSuccess {
805
795
  /**
806
796
  * Records a failed delivery: atomically increments the consecutive-failure
807
797
  * counter and, once it reaches `maxFailures`, sets `status: 'disabled'` so the
808
- * subscription drops out of the poller's partial index (surfaced via the API).
798
+ * subscription drops out of the partial active index (surfaced via the API).
809
799
  * Returns the updated subscription.
810
800
  */
811
801
  export async function recordFailure(
@@ -868,23 +858,32 @@ export async function deliverAndRecord(
868
858
  return { error: 'delivery failed before request', ok: false }
869
859
  }
870
860
  })()
871
- const stateWriteStartedAt = Date.now()
872
- if (result.ok) await recordSuccess(db, subscription, options)
873
- else await recordFailure(db, subscription, options)
874
- const stateWriteMs = Date.now() - stateWriteStartedAt
875
-
861
+ // Overlapped: the two writes touch different rows and the log carries the
862
+ // immutable pre-attempt subscription, so it never reads the state write's
863
+ // result. Sequentially they cost two Postgres round trips per delivery.
864
+ const writeStartedAt = Date.now()
876
865
  // Append a delivery-log row. Best-effort: a logging failure must never change
877
866
  // the delivery outcome the caller (Queue consumer / inline path) acts on.
878
- const deliveryLogStartedAt = Date.now()
879
- try {
880
- await recordDelivery(
881
- db,
882
- buildDelivery({ attempt, envelope, result, subscription }, options),
883
- options.deliveryRetentionMs === undefined ? {} : { ttl: options.deliveryRetentionMs },
884
- )
885
- } catch {
886
- // delivery logging is best-effort
887
- }
867
+ const deliveryLog = (async () => {
868
+ try {
869
+ await recordDelivery(
870
+ db,
871
+ buildDelivery({ attempt, envelope, result, subscription }, options),
872
+ options.deliveryRetentionMs === undefined ? {} : { ttl: options.deliveryRetentionMs },
873
+ )
874
+ } catch {
875
+ // delivery logging is best-effort
876
+ }
877
+ return Date.now() - writeStartedAt
878
+ })()
879
+ const stateWrite = (async () => {
880
+ if (result.ok) await recordSuccess(db, subscription, options)
881
+ else await recordFailure(db, subscription, options)
882
+ return Date.now() - writeStartedAt
883
+ })()
884
+ // A state-write rejection still propagates to the caller's retry path; the
885
+ // log promise never rejects, so it cannot mask it or go unhandled.
886
+ const [stateWriteMs, deliveryLogMs] = await Promise.all([stateWrite, deliveryLog])
888
887
  const completedAt = Date.now()
889
888
 
890
889
  // Emit after all delivery work so the timings cover every stage. The
@@ -901,19 +900,34 @@ export async function deliverAndRecord(
901
900
  subscription,
902
901
  attempt,
903
902
  timings: {
904
- deliveryLogMs: completedAt - deliveryLogStartedAt,
903
+ deliveryLogMs,
905
904
  envelopeToQueueMs:
906
905
  options.queuedAt === undefined || Number.isNaN(envelopeAt)
907
906
  ? undefined
908
907
  : Math.max(0, options.queuedAt - envelopeAt),
909
908
  endToEndMs: eventAt === undefined ? undefined : Math.max(0, completedAt - eventAt),
909
+ // The attempt leaving, not bookkeeping finishing: everything after
910
+ // `startedAt` is either the subscriber's time or our own recording,
911
+ // neither of which delays the event reaching them. Gated on a request
912
+ // actually being made, so a failure before send (formatting, invalid
913
+ // destination) does not report an attempt that never left.
914
+ eventToAttemptMs:
915
+ eventAt === undefined || result.durationMs === undefined
916
+ ? undefined
917
+ : Math.max(0, startedAt - eventAt),
910
918
  eventToEnvelopeMs:
911
919
  eventAt === undefined || Number.isNaN(envelopeAt)
912
920
  ? undefined
913
921
  : Math.max(0, envelopeAt - eventAt),
922
+ observedToAttemptMs:
923
+ options.observedAt === undefined || result.durationMs === undefined
924
+ ? undefined
925
+ : Math.max(0, startedAt - options.observedAt),
926
+ preflightMs: Math.max(0, startedAt - dequeuedAt),
914
927
  processingMs: Math.max(0, completedAt - dequeuedAt),
915
928
  queueWaitMs:
916
929
  options.queuedAt === undefined ? undefined : Math.max(0, dequeuedAt - options.queuedAt),
930
+ settleMs: Math.max(0, completedAt - writeStartedAt),
917
931
  stateWriteMs,
918
932
  subscriptionReadMs: options.subscriptionReadMs,
919
933
  },
@@ -960,6 +974,8 @@ export declare namespace deliverAndRecord {
960
974
  metrics?: Metrics.Metrics | undefined
961
975
  /** Clock used for timestamps; injectable for tests. */
962
976
  now?: (() => Date) | undefined
977
+ /** Epoch ms the head owing this event was observed; absent on replays. */
978
+ observedAt?: number | undefined
963
979
  /** Cloudflare Queue delivery attempt, starting at one; absent for inline delivery paths. */
964
980
  queueAttempt?: number | undefined
965
981
  /** Queue enqueue time as epoch milliseconds, used to isolate queue wait. */
@@ -1044,8 +1060,11 @@ function buildDelivery(
1044
1060
  * re-deliveries are dedupable by the receiver.
1045
1061
  */
1046
1062
  export function eventId(options: eventId.Options): string {
1047
- const input = `${options.chainId}:${options.eventType}:${options.blockNumber}:${options.logIndex}`
1048
- return `evt_${Hash.sha256(Hex.fromString(input)).slice(2)}`
1063
+ return eventIdFromKey({
1064
+ chainId: options.chainId,
1065
+ eventType: options.eventType,
1066
+ key: `${options.blockNumber}:${options.logIndex}`,
1067
+ })
1049
1068
  }
1050
1069
 
1051
1070
  export declare namespace eventId {
@@ -1062,6 +1081,24 @@ export declare namespace eventId {
1062
1081
  }
1063
1082
  }
1064
1083
 
1084
+ /** Computes a stable event id from an event-type-specific identity key. */
1085
+ export function eventIdFromKey(options: eventIdFromKey.Options): string {
1086
+ const input = `${options.chainId}:${options.eventType}:${options.key}`
1087
+ return `evt_${Hash.sha256(Hex.fromString(input)).slice(2)}`
1088
+ }
1089
+
1090
+ export declare namespace eventIdFromKey {
1091
+ /** Stable identity inputs for a webhook event. */
1092
+ type Options = {
1093
+ /** Chain the event belongs to. */
1094
+ chainId: number
1095
+ /** Event type. */
1096
+ eventType: EventType
1097
+ /** Stable event-type-specific identity. */
1098
+ key: string
1099
+ }
1100
+ }
1101
+
1065
1102
  /** The JSON envelope delivered to subscribers. */
1066
1103
  export type Envelope = {
1067
1104
  /** Chain the event occurred on. */
@@ -1115,6 +1152,38 @@ export declare namespace buildEnvelope {
1115
1152
  }
1116
1153
  }
1117
1154
 
1155
+ /** Builds a delivery envelope from a stable application-event identity. */
1156
+ export function buildKeyedEnvelope(options: buildKeyedEnvelope.Options): Envelope {
1157
+ const { subscription } = options
1158
+ return {
1159
+ chainId: subscription.chainId,
1160
+ ...(subscription.context === undefined ? {} : { context: subscription.context }),
1161
+ createdAt: (options.createdAt ?? new Date()).toISOString(),
1162
+ data: options.data,
1163
+ id: eventIdFromKey({
1164
+ chainId: subscription.chainId,
1165
+ eventType: subscription.eventType,
1166
+ key: options.key,
1167
+ }),
1168
+ subscriptionId: subscription.id,
1169
+ type: subscription.eventType,
1170
+ }
1171
+ }
1172
+
1173
+ export declare namespace buildKeyedEnvelope {
1174
+ /** Application-event envelope fields. */
1175
+ type Options = {
1176
+ /** Timestamp to stamp on the envelope; defaults to now. */
1177
+ createdAt?: Date | undefined
1178
+ /** Public event payload. */
1179
+ data: unknown
1180
+ /** Stable event-type-specific identity. */
1181
+ key: string
1182
+ /** Subscription the event matched. */
1183
+ subscription: Subscription
1184
+ }
1185
+ }
1186
+
1118
1187
  /**
1119
1188
  * Builds a synthetic `ping` envelope so an owner can test a subscription's
1120
1189
  * endpoint (connectivity, TLS, signature verification) without waiting for a real
@@ -0,0 +1,131 @@
1
+ import * as TestApp from '../../../test/App.js'
2
+ import * as TestFunding from '../../../test/Funding.js'
3
+ import * as WebhookQueueEvents from '../../db/tables/webhookQueueEvents.js'
4
+ import * as Webhooks from '../Webhooks.js'
5
+ import * as Deposit from './Deposit.js'
6
+ import * as FundingDepositAddress from './DepositAddress.js'
7
+ import * as DepositWebhook from './DepositWebhook.js'
8
+
9
+ const db = TestApp.database()
10
+
11
+ describe('stage', () => {
12
+ test('matches owner, environment, project, and filters with stable event ids', async () => {
13
+ const orgId = 'org_funding_deposit_webhook'
14
+ const projectId = 'prj_funding_deposit_webhook'
15
+ const address = await FundingDepositAddress.create(db, {
16
+ apiKeyId: 'key_funding_deposit_webhook',
17
+ deliveryStrategy: 'provider',
18
+ environment: 'production',
19
+ now: new Date('2026-08-12T00:00:00.000Z'),
20
+ orgId,
21
+ projectId,
22
+ providerOutputToken: TestFunding.providerOutputToken(),
23
+ snapshot: TestFunding.depositAddressSnapshot({
24
+ address: 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE',
25
+ recipient: `0x${'ab'.repeat(20)}`,
26
+ }),
27
+ })
28
+ const deposit = await Deposit.create(db, {
29
+ now: new Date('2026-08-12T00:01:00.000Z'),
30
+ providerRequestId: 'relay_deposit_webhook',
31
+ snapshot: TestFunding.depositSnapshot(address.id),
32
+ sourceTransactionHash: `0x${'aa'.repeat(32)}`,
33
+ sourceTransferIndex: 0,
34
+ })
35
+ const completed = await Deposit.transition(db, {
36
+ expectedVersion: deposit.version,
37
+ id: deposit.id,
38
+ status: 'completed',
39
+ })
40
+ if (completed.type !== 'applied') throw new Error('Expected deposit transition to apply.')
41
+
42
+ const filters = {
43
+ depositAddressId: address.id,
44
+ recipient: address.recipient.toLowerCase(),
45
+ status: 'completed',
46
+ }
47
+ const org = await subscription({ filters, orgId })
48
+ const project = await subscription({ filters, orgId, projectId })
49
+ await subscription({ orgId, projectId: 'prj_funding_deposit_sibling' })
50
+ await subscription({ environment: 'sandbox', orgId })
51
+ await subscription({ filters: { status: 'refunded' }, orgId })
52
+
53
+ const first = await DepositWebhook.stage(db, { records: [completed.record] })
54
+ const second = await DepositWebhook.stage(db, { records: [completed.record] })
55
+ expect({ created: first.created, repeated: second.created }).toEqual({
56
+ created: 2,
57
+ repeated: 0,
58
+ })
59
+ expect(first.references.map((reference) => reference.subscriptionId)).toEqual([
60
+ org.id,
61
+ project.id,
62
+ ])
63
+ const event = await WebhookQueueEvents.get(db, first.references[0]!)
64
+ expect(event?.envelope).toMatchObject({
65
+ chainId: 4217,
66
+ data: { id: completed.record.id, status: 'completed' },
67
+ id: first.references[0]!.eventId,
68
+ subscriptionId: org.id,
69
+ type: 'funding:deposit.updated',
70
+ })
71
+ })
72
+ })
73
+
74
+ describe('transition', () => {
75
+ test('commits the deposit version with its webhook obligation', async () => {
76
+ const orgId = 'org_funding_deposit_transition'
77
+ const address = await FundingDepositAddress.create(db, {
78
+ apiKeyId: 'key_funding_deposit_transition',
79
+ deliveryStrategy: 'provider',
80
+ environment: 'production',
81
+ now: new Date('2026-08-12T01:00:00.000Z'),
82
+ orgId,
83
+ providerOutputToken: TestFunding.providerOutputToken(),
84
+ snapshot: TestFunding.depositAddressSnapshot(),
85
+ })
86
+ const deposit = await Deposit.create(db, {
87
+ now: new Date('2026-08-12T01:01:00.000Z'),
88
+ providerRequestId: 'relay_deposit_transition',
89
+ snapshot: TestFunding.depositSnapshot(address.id),
90
+ sourceTransactionHash: `0x${'bb'.repeat(32)}`,
91
+ sourceTransferIndex: 0,
92
+ })
93
+ await subscription({ filters: {}, orgId })
94
+
95
+ const transitioned = await DepositWebhook.transition(db, {
96
+ expectedVersion: deposit.version,
97
+ id: deposit.id,
98
+ status: 'bridging',
99
+ })
100
+ if (transitioned.result.type !== 'applied') throw new Error('Expected applied transition.')
101
+
102
+ expect(transitioned.references).toHaveLength(1)
103
+ expect(await WebhookQueueEvents.get(db, transitioned.references[0]!)).toMatchObject({
104
+ envelope: {
105
+ data: { id: deposit.id, status: 'bridging' },
106
+ id: transitioned.references[0]!.eventId,
107
+ },
108
+ })
109
+ })
110
+ })
111
+
112
+ function subscription(options: subscription.Options) {
113
+ return Webhooks.createSubscription(db, {
114
+ chainId: 4217,
115
+ destination: { type: 'url', url: 'https://example.com/webhooks/tempo' },
116
+ environment: options.environment ?? 'production',
117
+ eventType: 'funding:deposit.updated',
118
+ filters: options.filters ?? { recipient: `0x${'ab'.repeat(20)}`, status: 'completed' },
119
+ owner: { orgId: options.orgId, type: 'api_key' },
120
+ ...(options.projectId === undefined ? {} : { projectId: options.projectId }),
121
+ })
122
+ }
123
+
124
+ declare namespace subscription {
125
+ type Options = {
126
+ environment?: 'production' | 'sandbox' | undefined
127
+ filters?: Record<string, unknown> | undefined
128
+ orgId: string
129
+ projectId?: string | undefined
130
+ }
131
+ }