tapimo 0.10.1 → 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 (225) hide show
  1. package/dist/App.d.ts +5249 -3572
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +1 -76
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +2 -144
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  10. package/dist/admin/apps/organizations.d.ts +3 -3
  11. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  12. package/dist/analytics/Analytics.d.ts.map +1 -1
  13. package/dist/analytics/Analytics.js +206 -0
  14. package/dist/analytics/Analytics.js.map +1 -1
  15. package/dist/analytics/tables/requestEvents.d.ts +4 -0
  16. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  17. package/dist/analytics/tables/requestEvents.js +2 -0
  18. package/dist/analytics/tables/requestEvents.js.map +1 -1
  19. package/dist/apps/data/App.d.ts +3618 -2954
  20. package/dist/apps/data/App.d.ts.map +1 -1
  21. package/dist/apps/data/App.js +7 -2
  22. package/dist/apps/data/App.js.map +1 -1
  23. package/dist/apps/data/routes/activities.d.ts +1 -1
  24. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  25. package/dist/apps/data/routes/activities.js +55 -27
  26. package/dist/apps/data/routes/activities.js.map +1 -1
  27. package/dist/apps/data/routes/balances.d.ts +11 -11
  28. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  29. package/dist/apps/data/routes/balances.js +2 -6
  30. package/dist/apps/data/routes/balances.js.map +1 -1
  31. package/dist/apps/data/routes/blocks.d.ts +5 -5
  32. package/dist/apps/data/routes/earn.d.ts +4 -4
  33. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  34. package/dist/apps/data/routes/earn.js +63 -34
  35. package/dist/apps/data/routes/earn.js.map +1 -1
  36. package/dist/apps/data/routes/exchanges.d.ts +8 -4
  37. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  38. package/dist/apps/data/routes/exchanges.js +115 -77
  39. package/dist/apps/data/routes/exchanges.js.map +1 -1
  40. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  41. package/dist/apps/data/routes/receipts.d.ts +240 -239
  42. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  43. package/dist/apps/data/routes/receipts.js +15 -14
  44. package/dist/apps/data/routes/receipts.js.map +1 -1
  45. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  46. package/dist/apps/data/routes/tokens.d.ts +4 -4
  47. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  48. package/dist/apps/data/routes/tokens.js +4 -3
  49. package/dist/apps/data/routes/tokens.js.map +1 -1
  50. package/dist/apps/data/routes/transactions.d.ts +282 -282
  51. package/dist/apps/data/routes/transfers.d.ts +13 -13
  52. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  53. package/dist/apps/data/routes/transfers.js +25 -7
  54. package/dist/apps/data/routes/transfers.js.map +1 -1
  55. package/dist/apps/data/routes/valuation.d.ts +21 -21
  56. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  57. package/dist/apps/data/routes/webhooks.d.ts +1519 -643
  58. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  59. package/dist/apps/data/routes/webhooks.js +134 -740
  60. package/dist/apps/data/routes/webhooks.js.map +1 -1
  61. package/dist/apps/data/routes/zones.d.ts +3 -3
  62. package/dist/apps/funding/App.d.ts +233 -0
  63. package/dist/apps/funding/App.d.ts.map +1 -1
  64. package/dist/apps/funding/routes/deposit-addresses.d.ts +331 -1
  65. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  66. package/dist/apps/funding/routes/deposit-addresses.js +207 -11
  67. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  68. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  69. package/dist/apps/management/App.d.ts +793 -16
  70. package/dist/apps/management/App.d.ts.map +1 -1
  71. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  72. package/dist/apps/management/routes/faucet.d.ts +2 -2
  73. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  74. package/dist/apps/management/routes/members.d.ts +2 -2
  75. package/dist/apps/management/routes/projects.d.ts +40 -40
  76. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  77. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  78. package/dist/apps/management/routes/webhooks.js +27 -4
  79. package/dist/apps/management/routes/webhooks.js.map +1 -1
  80. package/dist/db/Db.d.ts.map +1 -1
  81. package/dist/db/Db.js +53 -0
  82. package/dist/db/Db.js.map +1 -1
  83. package/dist/db/Schema.d.ts +7 -1
  84. package/dist/db/Schema.d.ts.map +1 -1
  85. package/dist/db/Schema.js +9 -0
  86. package/dist/db/Schema.js.map +1 -1
  87. package/dist/db/tables/fundingDepositAddresses.d.ts +20 -0
  88. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  89. package/dist/db/tables/fundingDepositAddresses.js +17 -0
  90. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  91. package/dist/db/tables/fundingDeposits.d.ts +41 -0
  92. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  93. package/dist/db/tables/fundingDeposits.js +69 -0
  94. package/dist/db/tables/fundingDeposits.js.map +1 -1
  95. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  96. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  97. package/dist/db/tables/webhookQueueEvents.js +4 -0
  98. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  99. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  100. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  101. package/dist/db/tables/webhookSubscriptions.js +63 -100
  102. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  103. package/dist/internal/Auth.d.ts +0 -26
  104. package/dist/internal/Auth.d.ts.map +1 -1
  105. package/dist/internal/Auth.js +0 -33
  106. package/dist/internal/Auth.js.map +1 -1
  107. package/dist/internal/Credential.d.ts +27 -0
  108. package/dist/internal/Credential.d.ts.map +1 -0
  109. package/dist/internal/Credential.js +34 -0
  110. package/dist/internal/Credential.js.map +1 -0
  111. package/dist/internal/EarnRates.js +11 -7
  112. package/dist/internal/EarnRates.js.map +1 -1
  113. package/dist/internal/Log.d.ts +8 -0
  114. package/dist/internal/Log.d.ts.map +1 -1
  115. package/dist/internal/Log.js +4 -0
  116. package/dist/internal/Log.js.map +1 -1
  117. package/dist/internal/MetricSink.d.ts +50 -116
  118. package/dist/internal/MetricSink.d.ts.map +1 -1
  119. package/dist/internal/MetricSink.js +60 -106
  120. package/dist/internal/MetricSink.js.map +1 -1
  121. package/dist/internal/Schema.d.ts +3 -1
  122. package/dist/internal/Schema.d.ts.map +1 -1
  123. package/dist/internal/Schema.js +4 -2
  124. package/dist/internal/Schema.js.map +1 -1
  125. package/dist/internal/Tidx.d.ts +4 -3
  126. package/dist/internal/Tidx.d.ts.map +1 -1
  127. package/dist/internal/Tidx.js +2 -2
  128. package/dist/internal/Tidx.js.map +1 -1
  129. package/dist/internal/Viem.d.ts +4 -3
  130. package/dist/internal/Viem.d.ts.map +1 -1
  131. package/dist/internal/Viem.js +3 -2
  132. package/dist/internal/Viem.js.map +1 -1
  133. package/dist/internal/WebhookTransfer.d.ts +1 -55
  134. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  135. package/dist/internal/WebhookTransfer.js +1 -239
  136. package/dist/internal/WebhookTransfer.js.map +1 -1
  137. package/dist/internal/Webhooks.d.ts +85 -44
  138. package/dist/internal/Webhooks.d.ts.map +1 -1
  139. package/dist/internal/Webhooks.js +94 -73
  140. package/dist/internal/Webhooks.js.map +1 -1
  141. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  142. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  143. package/dist/internal/funding/DepositWebhook.js +74 -0
  144. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  145. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  146. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  147. package/dist/internal/funding/Reconciliation.js +42 -10
  148. package/dist/internal/funding/Reconciliation.js.map +1 -1
  149. package/dist/internal/funding/index.d.ts +1 -0
  150. package/dist/internal/funding/index.d.ts.map +1 -1
  151. package/dist/internal/funding/index.js +1 -0
  152. package/dist/internal/funding/index.js.map +1 -1
  153. package/dist/internal/index.d.ts +1 -0
  154. package/dist/internal/index.d.ts.map +1 -1
  155. package/dist/internal/index.js +1 -0
  156. package/dist/internal/index.js.map +1 -1
  157. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  158. package/dist/internal/webhookDestinations/slack.js +16 -0
  159. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  160. package/package.json +1 -1
  161. package/src/App.test.ts +30 -0
  162. package/src/App.ts +3 -0
  163. package/src/Client.test-d.ts +55 -2
  164. package/src/Webhooks.ts +2 -221
  165. package/src/analytics/Analytics.test.ts +34 -0
  166. package/src/analytics/Analytics.ts +206 -0
  167. package/src/analytics/tables/requestEvents.ts +6 -0
  168. package/src/apps/composition.test.ts +1 -0
  169. package/src/apps/data/App.ts +10 -2
  170. package/src/apps/data/routes/activities.test.ts +107 -11
  171. package/src/apps/data/routes/activities.ts +64 -32
  172. package/src/apps/data/routes/balances.ts +2 -6
  173. package/src/apps/data/routes/earn.ts +64 -34
  174. package/src/apps/data/routes/exchanges.test.ts +8 -3
  175. package/src/apps/data/routes/exchanges.ts +134 -82
  176. package/src/apps/data/routes/receipts.ts +16 -14
  177. package/src/apps/data/routes/tokens.ts +3 -2
  178. package/src/apps/data/routes/transactions.bench.ts +8 -0
  179. package/src/apps/data/routes/transfers.ts +36 -10
  180. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  181. package/src/apps/data/routes/webhooks.ts +172 -975
  182. package/src/apps/funding/routes/deposit-addresses.test.ts +216 -2
  183. package/src/apps/funding/routes/deposit-addresses.ts +254 -18
  184. package/src/apps/funding/routes/transfers.test.ts +17 -2
  185. package/src/apps/management/routes/webhooks.test.ts +79 -1
  186. package/src/apps/management/routes/webhooks.ts +40 -4
  187. package/src/db/Db.test.ts +1 -5
  188. package/src/db/Db.ts +59 -0
  189. package/src/db/Schema.ts +13 -0
  190. package/src/db/tables/fundingDepositAddresses.test.ts +28 -0
  191. package/src/db/tables/fundingDepositAddresses.ts +39 -0
  192. package/src/db/tables/fundingDeposits.test.ts +82 -0
  193. package/src/db/tables/fundingDeposits.ts +130 -0
  194. package/src/db/tables/organizations.test.ts +5 -2
  195. package/src/db/tables/webhookQueueEvents.ts +7 -1
  196. package/src/db/tables/webhookSubscriptions.ts +89 -138
  197. package/src/internal/Auth.test.ts +0 -48
  198. package/src/internal/Auth.ts +0 -59
  199. package/src/internal/Credential.test.ts +51 -0
  200. package/src/internal/Credential.ts +58 -0
  201. package/src/internal/EarnRates.ts +13 -7
  202. package/src/internal/Log.test.ts +3 -1
  203. package/src/internal/Log.ts +14 -0
  204. package/src/internal/MetricSink.test.ts +75 -358
  205. package/src/internal/MetricSink.ts +117 -246
  206. package/src/internal/Schema.test.ts +18 -0
  207. package/src/internal/Schema.ts +13 -2
  208. package/src/internal/Tidx.ts +5 -4
  209. package/src/internal/Viem.test.ts +4 -0
  210. package/src/internal/Viem.ts +6 -4
  211. package/src/internal/WebhookTransfer.ts +1 -351
  212. package/src/internal/Webhooks.test.ts +111 -139
  213. package/src/internal/Webhooks.ts +181 -105
  214. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  215. package/src/internal/funding/DepositWebhook.ts +101 -0
  216. package/src/internal/funding/Reconciliation.test.ts +14 -0
  217. package/src/internal/funding/Reconciliation.ts +161 -86
  218. package/src/internal/funding/index.test.ts +1 -0
  219. package/src/internal/funding/index.ts +1 -0
  220. package/src/internal/index.test.ts +1 -0
  221. package/src/internal/index.ts +1 -0
  222. package/src/internal/webhookDestinations/slack.ts +15 -0
  223. package/src/Webhooks.bench.ts +0 -714
  224. package/src/Webhooks.memory.bench.ts +0 -250
  225. 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,28 +384,16 @@ 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 decoded = Cursor.decode(cursor, ['int', 'int']) ?? Cursor.decode(cursor, ['int'])
377
- const block = decoded?.[0]
378
- if (typeof block === 'number' && (max === null || block > max)) max = block
379
- }
380
- return max
391
+ /** Decodes the block component of a subscription cursor, if any. */
392
+ export function cursorBlock(cursor: string | null): number | undefined {
393
+ if (cursor === null) return undefined
394
+ const decoded = Cursor.decode(cursor, ['int', 'int']) ?? Cursor.decode(cursor, ['int'])
395
+ const block = decoded?.[0]
396
+ return typeof block === 'number' ? block : undefined
381
397
  }
382
398
 
383
399
  /** Reads a subscription's keyset cursor. Returns null when unset. */
@@ -385,41 +401,10 @@ export async function getCursor(db: Db.Db, subscriptionId: string): Promise<stri
385
401
  return WebhookSubscriptions.getCursor(db, subscriptionId)
386
402
  }
387
403
 
388
- /** Writes a cursor only when its stored value matches the expected cursor. */
389
- export async function compareAndSetCursor(
390
- db: Db.Db,
391
- options: compareAndSetCursor.Options,
392
- ): Promise<boolean> {
393
- return WebhookSubscriptions.compareAndSetCursor(db, options)
394
- }
395
-
396
- export declare namespace compareAndSetCursor {
397
- /** Options for {@link compareAndSetCursor}. */
398
- type Options = WebhookSubscriptions.compareAndSetCursor.Options
399
- }
400
-
401
- /** Writes cursors whose stored values match their expected cursors. */
402
- export async function compareAndSetCursors(
403
- db: Db.Db,
404
- options: compareAndSetCursors.Options,
405
- ): Promise<Set<string>> {
406
- return WebhookSubscriptions.compareAndSetCursors(db, options)
407
- }
408
-
409
- export declare namespace compareAndSetCursors {
410
- /** Options for {@link compareAndSetCursors}. */
411
- type Options = WebhookSubscriptions.compareAndSetCursors.Options
412
- }
413
-
414
- /** Writes a subscription's keyset cursor. */
415
- export async function setCursor(db: Db.Db, subscriptionId: string, cursor: string): Promise<void> {
416
- await WebhookSubscriptions.setCursor(db, subscriptionId, cursor)
417
- }
418
-
419
404
  /**
420
405
  * Deletes expired MPP-owned subscriptions (delivery rows cascade) and
421
- * deliveries past retention. Reads already filter expiry at query time this
422
- * 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.
423
408
  */
424
409
  export async function pruneExpired(db: Db.Db, options: pruneExpired.Options = {}): Promise<void> {
425
410
  const iso = (options.now ?? (() => new Date()))().toISOString()
@@ -483,6 +468,10 @@ export async function ensureQueueEvents(
483
468
  ): Promise<ensureQueueEvents.Result> {
484
469
  const now = (options.now ?? (() => new Date()))()
485
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()
486
475
  const events = dispatchables.map(({ envelope, subscription }) => ({
487
476
  createdAt: now.toISOString(),
488
477
  envelope,
@@ -492,6 +481,7 @@ export async function ensureQueueEvents(
492
481
  expiresAt,
493
482
  // Due immediately; the value orders the sweep so it is never null here.
494
483
  nextAttemptAt: now.toISOString(),
484
+ observedAt,
495
485
  subscriptionId: subscription.id,
496
486
  }))
497
487
  return {
@@ -513,6 +503,8 @@ export declare namespace ensureQueueEvents {
513
503
  type Options = {
514
504
  /** Clock used for staging timestamps. */
515
505
  now?: (() => Date) | undefined
506
+ /** Epoch ms the head owing these events was observed; absent on replays. */
507
+ observedAt?: number | undefined
516
508
  /** Staged-envelope TTL in ms; defaults to {@link deliveryRetentionMs}. */
517
509
  ttl?: number | undefined
518
510
  }
@@ -645,10 +637,13 @@ export async function deliverClaimedAndRecord(
645
637
  new Date().toISOString(),
646
638
  )
647
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
648
642
  return {
649
643
  result: await deliverAndRecord(db, subscription, claimed.envelope, {
650
644
  ...options,
651
645
  dequeuedAt,
646
+ ...(Number.isNaN(observedAt) ? {} : { observedAt }),
652
647
  subscriptionReadMs: Date.now() - subscriptionReadStartedAt,
653
648
  }),
654
649
  status: 'delivered',
@@ -660,6 +655,8 @@ export declare namespace deliverClaimedAndRecord {
660
655
  type Claimed = {
661
656
  /** Immutable event envelope to deliver. */
662
657
  envelope: Envelope
658
+ /** ISO head-observation stamp carried by the staged row; null on replays. */
659
+ observedAt?: string | null | undefined
663
660
  /** Subscription re-resolved for current status and secret. */
664
661
  subscriptionId: string
665
662
  }
@@ -707,7 +704,7 @@ export async function recordDelivery(
707
704
  ): Promise<void> {
708
705
  const ttl = options.ttl ?? deliveryRetentionMs
709
706
  // The retention deadline replaces the old store TTL: reads filter on it at
710
- // 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
711
708
  // row's `createdAt`, which tests pin) — exactly like the old `put` TTL.
712
709
  const expiresAt = new Date(Date.now() + ttl).toISOString()
713
710
  await WebhookDeliveries.insert(db, delivery, expiresAt)
@@ -765,7 +762,7 @@ export async function getDelivery(
765
762
 
766
763
  /**
767
764
  * Default number of consecutive delivery failures after which a subscription
768
- * auto-disables (and is dropped from the poller's active index).
765
+ * auto-disables (and is dropped from the partial active index).
769
766
  */
770
767
  export const maxFailures = 10
771
768
 
@@ -798,7 +795,7 @@ export declare namespace recordSuccess {
798
795
  /**
799
796
  * Records a failed delivery: atomically increments the consecutive-failure
800
797
  * counter and, once it reaches `maxFailures`, sets `status: 'disabled'` so the
801
- * 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).
802
799
  * Returns the updated subscription.
803
800
  */
804
801
  export async function recordFailure(
@@ -861,23 +858,32 @@ export async function deliverAndRecord(
861
858
  return { error: 'delivery failed before request', ok: false }
862
859
  }
863
860
  })()
864
- const stateWriteStartedAt = Date.now()
865
- if (result.ok) await recordSuccess(db, subscription, options)
866
- else await recordFailure(db, subscription, options)
867
- const stateWriteMs = Date.now() - stateWriteStartedAt
868
-
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()
869
865
  // Append a delivery-log row. Best-effort: a logging failure must never change
870
866
  // the delivery outcome the caller (Queue consumer / inline path) acts on.
871
- const deliveryLogStartedAt = Date.now()
872
- try {
873
- await recordDelivery(
874
- db,
875
- buildDelivery({ attempt, envelope, result, subscription }, options),
876
- options.deliveryRetentionMs === undefined ? {} : { ttl: options.deliveryRetentionMs },
877
- )
878
- } catch {
879
- // delivery logging is best-effort
880
- }
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])
881
887
  const completedAt = Date.now()
882
888
 
883
889
  // Emit after all delivery work so the timings cover every stage. The
@@ -894,19 +900,34 @@ export async function deliverAndRecord(
894
900
  subscription,
895
901
  attempt,
896
902
  timings: {
897
- deliveryLogMs: completedAt - deliveryLogStartedAt,
903
+ deliveryLogMs,
898
904
  envelopeToQueueMs:
899
905
  options.queuedAt === undefined || Number.isNaN(envelopeAt)
900
906
  ? undefined
901
907
  : Math.max(0, options.queuedAt - envelopeAt),
902
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),
903
918
  eventToEnvelopeMs:
904
919
  eventAt === undefined || Number.isNaN(envelopeAt)
905
920
  ? undefined
906
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),
907
927
  processingMs: Math.max(0, completedAt - dequeuedAt),
908
928
  queueWaitMs:
909
929
  options.queuedAt === undefined ? undefined : Math.max(0, dequeuedAt - options.queuedAt),
930
+ settleMs: Math.max(0, completedAt - writeStartedAt),
910
931
  stateWriteMs,
911
932
  subscriptionReadMs: options.subscriptionReadMs,
912
933
  },
@@ -953,6 +974,8 @@ export declare namespace deliverAndRecord {
953
974
  metrics?: Metrics.Metrics | undefined
954
975
  /** Clock used for timestamps; injectable for tests. */
955
976
  now?: (() => Date) | undefined
977
+ /** Epoch ms the head owing this event was observed; absent on replays. */
978
+ observedAt?: number | undefined
956
979
  /** Cloudflare Queue delivery attempt, starting at one; absent for inline delivery paths. */
957
980
  queueAttempt?: number | undefined
958
981
  /** Queue enqueue time as epoch milliseconds, used to isolate queue wait. */
@@ -1037,8 +1060,11 @@ function buildDelivery(
1037
1060
  * re-deliveries are dedupable by the receiver.
1038
1061
  */
1039
1062
  export function eventId(options: eventId.Options): string {
1040
- const input = `${options.chainId}:${options.eventType}:${options.blockNumber}:${options.logIndex}`
1041
- 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
+ })
1042
1068
  }
1043
1069
 
1044
1070
  export declare namespace eventId {
@@ -1055,6 +1081,24 @@ export declare namespace eventId {
1055
1081
  }
1056
1082
  }
1057
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
+
1058
1102
  /** The JSON envelope delivered to subscribers. */
1059
1103
  export type Envelope = {
1060
1104
  /** Chain the event occurred on. */
@@ -1108,6 +1152,38 @@ export declare namespace buildEnvelope {
1108
1152
  }
1109
1153
  }
1110
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
+
1111
1187
  /**
1112
1188
  * Builds a synthetic `ping` envelope so an owner can test a subscription's
1113
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
+ }