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.
- package/dist/App.d.ts +3412 -1968
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js.map +1 -1
- package/dist/Webhooks.d.ts +0 -75
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +1 -143
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +74 -74
- package/dist/admin/apps/api-keys.d.ts +4 -4
- package/dist/admin/apps/earn-vaults.d.ts +1 -1
- package/dist/admin/apps/organizations.d.ts +3 -3
- package/dist/admin/apps/verified-tokens.d.ts +74 -74
- package/dist/apps/data/App.d.ts +3618 -2954
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/App.js +5 -2
- package/dist/apps/data/App.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +1 -1
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +55 -27
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +343 -343
- package/dist/apps/data/routes/balances.d.ts +11 -11
- package/dist/apps/data/routes/blocks.d.ts +5 -5
- package/dist/apps/data/routes/coingecko.d.ts +176 -176
- package/dist/apps/data/routes/earn.d.ts +4 -4
- package/dist/apps/data/routes/exchanges.d.ts +4 -4
- package/dist/apps/data/routes/fee-amm.d.ts +26 -26
- package/dist/apps/data/routes/indexer.d.ts +34 -34
- package/dist/apps/data/routes/receipts.d.ts +236 -236
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/tokens.d.ts +3 -3
- package/dist/apps/data/routes/transactions.d.ts +1 -1
- package/dist/apps/data/routes/transfers.d.ts +13 -13
- package/dist/apps/data/routes/valuation.d.ts +21 -21
- package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
- package/dist/apps/data/routes/webhooks.d.ts +1011 -151
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +124 -737
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
- package/dist/apps/funding/routes/providers.d.ts +13 -13
- package/dist/apps/funding/routes/transfers.d.ts +33 -33
- package/dist/apps/management/App.d.ts +793 -16
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +50 -50
- package/dist/apps/management/routes/faucet.d.ts +31 -31
- package/dist/apps/management/routes/invite-links.d.ts +42 -42
- package/dist/apps/management/routes/members.d.ts +79 -79
- package/dist/apps/management/routes/projects.d.ts +40 -40
- package/dist/apps/management/routes/usage.d.ts +80 -80
- package/dist/apps/management/routes/webhooks.d.ts +1031 -59
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/management/routes/webhooks.js +27 -4
- package/dist/apps/management/routes/webhooks.js.map +1 -1
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +21 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +7 -1
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +9 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
- package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
- package/dist/db/tables/webhookQueueEvents.js +4 -0
- package/dist/db/tables/webhookQueueEvents.js.map +1 -1
- package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +63 -100
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +35 -125
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +50 -121
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/Schema.d.ts +3 -1
- package/dist/internal/Schema.d.ts.map +1 -1
- package/dist/internal/Schema.js +4 -2
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +1 -55
- package/dist/internal/WebhookTransfer.d.ts.map +1 -1
- package/dist/internal/WebhookTransfer.js +1 -239
- package/dist/internal/WebhookTransfer.js.map +1 -1
- package/dist/internal/Webhooks.d.ts +83 -44
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +87 -73
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/DepositWebhook.d.ts +25 -0
- package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
- package/dist/internal/funding/DepositWebhook.js +74 -0
- package/dist/internal/funding/DepositWebhook.js.map +1 -0
- package/dist/internal/funding/Reconciliation.d.ts +6 -0
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +42 -10
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/index.d.ts +1 -0
- package/dist/internal/funding/index.d.ts.map +1 -1
- package/dist/internal/funding/index.js +1 -0
- package/dist/internal/funding/index.js.map +1 -1
- package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
- package/dist/internal/webhookDestinations/slack.js +16 -0
- package/dist/internal/webhookDestinations/slack.js.map +1 -1
- package/package.json +1 -1
- package/src/App.ts +3 -0
- package/src/Client.test-d.ts +22 -1
- package/src/Webhooks.ts +1 -220
- package/src/apps/data/App.ts +8 -2
- package/src/apps/data/routes/activities.test.ts +107 -11
- package/src/apps/data/routes/activities.ts +64 -32
- package/src/apps/data/routes/webhooks.test.ts +179 -1497
- package/src/apps/data/routes/webhooks.ts +150 -974
- package/src/apps/management/routes/webhooks.test.ts +79 -1
- package/src/apps/management/routes/webhooks.ts +40 -4
- package/src/db/Db.test.ts +1 -5
- package/src/db/Db.ts +23 -0
- package/src/db/Schema.ts +13 -0
- package/src/db/tables/organizations.test.ts +5 -2
- package/src/db/tables/webhookQueueEvents.ts +7 -1
- package/src/db/tables/webhookSubscriptions.ts +89 -138
- package/src/internal/MetricSink.test.ts +41 -373
- package/src/internal/MetricSink.ts +92 -273
- package/src/internal/Schema.test.ts +18 -0
- package/src/internal/Schema.ts +13 -2
- package/src/internal/WebhookTransfer.ts +1 -351
- package/src/internal/Webhooks.test.ts +102 -139
- package/src/internal/Webhooks.ts +175 -106
- package/src/internal/funding/DepositWebhook.test.ts +131 -0
- package/src/internal/funding/DepositWebhook.ts +101 -0
- package/src/internal/funding/Reconciliation.test.ts +14 -0
- package/src/internal/funding/Reconciliation.ts +161 -86
- package/src/internal/funding/index.test.ts +1 -0
- package/src/internal/funding/index.ts +1 -0
- package/src/internal/webhookDestinations/slack.ts +15 -0
- package/src/Webhooks.bench.ts +0 -714
- package/src/Webhooks.memory.bench.ts +0 -250
- package/src/Webhooks.test.ts +0 -146
package/src/internal/Webhooks.ts
CHANGED
|
@@ -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
|
|
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(
|
|
273
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
return
|
|
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
|
-
|
|
336
|
-
|
|
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
|
|
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
|
|
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
|
|
429
|
-
* is the janitor reclaiming space,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
const
|
|
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
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
+
}
|