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
|
@@ -10,7 +10,10 @@ import * as Orgs from './orgs.js'
|
|
|
10
10
|
import * as Webhooks from './webhooks.js'
|
|
11
11
|
|
|
12
12
|
const origin = 'http://localhost'
|
|
13
|
-
const webhook = {
|
|
13
|
+
const webhook = {
|
|
14
|
+
applicationEventTypes: ['funding:deposit.updated'],
|
|
15
|
+
supportedChainIds: [4217, 42431],
|
|
16
|
+
} satisfies App.Webhook
|
|
14
17
|
|
|
15
18
|
function json(method: 'PATCH' | 'POST', cookie: string, body: unknown) {
|
|
16
19
|
return {
|
|
@@ -47,6 +50,81 @@ async function organization(app: ReturnType<typeof createApp>, cookie: string) {
|
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
describe('webhooks', () => {
|
|
53
|
+
test('creates funding deposit subscriptions without reading the RPC head', async () => {
|
|
54
|
+
const db = TestApp.database()
|
|
55
|
+
const app = createApp({ db })
|
|
56
|
+
const cookie = await session(app)
|
|
57
|
+
const org = await organization(app, cookie)
|
|
58
|
+
const response = await app.request(
|
|
59
|
+
`/v1/orgs/${org.id}/webhooks`,
|
|
60
|
+
json('POST', cookie, {
|
|
61
|
+
chainId: 4217,
|
|
62
|
+
destination: { type: 'url', url: 'https://example.com/funding' },
|
|
63
|
+
environment: 'production',
|
|
64
|
+
eventType: 'funding:deposit.updated',
|
|
65
|
+
filters: { status: 'completed' },
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response)
|
|
69
|
+
const stored = await core_Webhooks.getSubscription(
|
|
70
|
+
db,
|
|
71
|
+
{ orgId: org.id, type: 'api_key' },
|
|
72
|
+
created.id,
|
|
73
|
+
)
|
|
74
|
+
const listedResponse = await app.request(`/v1/orgs/${org.id}/webhooks`, {
|
|
75
|
+
headers: { cookie },
|
|
76
|
+
})
|
|
77
|
+
const listed = await TestApp.json(listedResponse, Webhooks.schema.ListResponse)
|
|
78
|
+
|
|
79
|
+
expect(response.status).toBe(200)
|
|
80
|
+
expect(created.environment).toBe('production')
|
|
81
|
+
expect(listed.data[0]?.environment).toBe('production')
|
|
82
|
+
expect(stored?.environment).toBe('production')
|
|
83
|
+
expect(await core_Webhooks.getCursor(db, created.id)).toBeNull()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
test('preserves a production funding environment on testnet', async () => {
|
|
87
|
+
const db = TestApp.database()
|
|
88
|
+
const app = createApp({ db })
|
|
89
|
+
const cookie = await session(app)
|
|
90
|
+
const org = await organization(app, cookie)
|
|
91
|
+
const response = await app.request(
|
|
92
|
+
`/v1/orgs/${org.id}/webhooks`,
|
|
93
|
+
json('POST', cookie, {
|
|
94
|
+
chainId: 42431,
|
|
95
|
+
destination: { type: 'url', url: 'https://example.com/funding-testnet' },
|
|
96
|
+
environment: 'production',
|
|
97
|
+
eventType: 'funding:deposit.updated',
|
|
98
|
+
}),
|
|
99
|
+
)
|
|
100
|
+
const created = await TestApp.json(response, Webhooks.schema.createWebhook.Response)
|
|
101
|
+
const stored = await core_Webhooks.getSubscription(
|
|
102
|
+
db,
|
|
103
|
+
{ orgId: org.id, type: 'api_key' },
|
|
104
|
+
created.id,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(200)
|
|
108
|
+
expect(created.environment).toBe('production')
|
|
109
|
+
expect(stored?.environment).toBe('production')
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
test('requires an explicit environment for funding deposit subscriptions', async () => {
|
|
113
|
+
const app = createApp()
|
|
114
|
+
const cookie = await session(app)
|
|
115
|
+
const org = await organization(app, cookie)
|
|
116
|
+
const response = await app.request(
|
|
117
|
+
`/v1/orgs/${org.id}/webhooks`,
|
|
118
|
+
json('POST', cookie, {
|
|
119
|
+
chainId: 4217,
|
|
120
|
+
destination: { type: 'url', url: 'https://example.com/funding' },
|
|
121
|
+
eventType: 'funding:deposit.updated',
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
expect(response.status).toBe(400)
|
|
126
|
+
})
|
|
127
|
+
|
|
50
128
|
test('creates, lists, pauses, and deletes an organization webhook', async () => {
|
|
51
129
|
const db = TestApp.database()
|
|
52
130
|
const app = createApp({ db })
|
|
@@ -22,13 +22,29 @@ export namespace schema {
|
|
|
22
22
|
|
|
23
23
|
/** Schemas for creating an organization webhook. */
|
|
24
24
|
export namespace createWebhook {
|
|
25
|
+
const Environment = z
|
|
26
|
+
.enum(['production', 'sandbox'])
|
|
27
|
+
.check(
|
|
28
|
+
z.describe('API-key environment whose private funding deposits are delivered.'),
|
|
29
|
+
z.meta({ examples: ['production'] }),
|
|
30
|
+
)
|
|
31
|
+
|
|
25
32
|
/** Organization webhook creation requires an explicit network. */
|
|
26
33
|
export const Body = z
|
|
27
34
|
.intersection(
|
|
28
35
|
webhookSchema.createWebhook.Body,
|
|
29
|
-
z.object({
|
|
36
|
+
z.object({
|
|
37
|
+
chainId: webhookSchema.Subscription.shape.chainId,
|
|
38
|
+
environment: z.optional(Environment),
|
|
39
|
+
}),
|
|
40
|
+
)
|
|
41
|
+
.check(
|
|
42
|
+
z.refine(
|
|
43
|
+
(body) => body.eventType !== 'funding:deposit.updated' || body.environment !== undefined,
|
|
44
|
+
{ error: '`environment` is required for funding deposit subscriptions.' },
|
|
45
|
+
),
|
|
46
|
+
z.describe('Details for creating an organization webhook subscription.'),
|
|
30
47
|
)
|
|
31
|
-
.check(z.describe('Details for creating an organization webhook subscription.'))
|
|
32
48
|
|
|
33
49
|
const secret = webhookSchema.createWebhook.Response.shape.secret
|
|
34
50
|
const urlDestination = z.object({
|
|
@@ -181,6 +197,7 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
181
197
|
codes: [
|
|
182
198
|
'body_invalid',
|
|
183
199
|
'chain_id_unsupported',
|
|
200
|
+
'event_type_unsupported',
|
|
184
201
|
'filters_invalid',
|
|
185
202
|
'param_invalid',
|
|
186
203
|
'url_invalid',
|
|
@@ -213,6 +230,15 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
213
230
|
if (!options.webhook) return notEnabled(c)
|
|
214
231
|
|
|
215
232
|
const body = c.req.valid('json')
|
|
233
|
+
if (
|
|
234
|
+
body.eventType === 'funding:deposit.updated' &&
|
|
235
|
+
!options.webhook.applicationEventTypes?.includes(body.eventType)
|
|
236
|
+
)
|
|
237
|
+
return Response.error(c, {
|
|
238
|
+
code: 'event_type_unsupported',
|
|
239
|
+
message: 'Webhook event type is not available in this deployment',
|
|
240
|
+
status: 400,
|
|
241
|
+
})
|
|
216
242
|
const chainId = body.chainId
|
|
217
243
|
const supportedChainIds = new Set(options.webhook.supportedChainIds)
|
|
218
244
|
if (!supportedChainIds.has(chainId))
|
|
@@ -225,20 +251,26 @@ export function webhooks(options: webhooks.Options = {}) {
|
|
|
225
251
|
const owner_ = owner(Auth.org(c).id)
|
|
226
252
|
if ((await Webhooks.countSubscriptions(db, owner_)) >= maxPerOwner)
|
|
227
253
|
throw new Webhooks.LimitExceededError(maxPerOwner)
|
|
228
|
-
const startBlockNumber =
|
|
254
|
+
const startBlockNumber =
|
|
255
|
+
body.eventType === 'funding:deposit.updated'
|
|
256
|
+
? undefined
|
|
257
|
+
: Number(await c.get('getClient')(chainId).getBlockNumber())
|
|
229
258
|
const subscription = await Webhooks.createSubscription(
|
|
230
259
|
db,
|
|
231
260
|
{
|
|
232
261
|
chainId,
|
|
233
262
|
...(body.context === undefined ? {} : { context: body.context }),
|
|
234
263
|
destination: body.destination,
|
|
264
|
+
...(body.eventType === 'funding:deposit.updated' && body.environment !== undefined
|
|
265
|
+
? { environment: body.environment }
|
|
266
|
+
: {}),
|
|
235
267
|
eventType: body.eventType,
|
|
236
268
|
filters: body.filters,
|
|
237
269
|
owner: owner_,
|
|
238
270
|
},
|
|
239
271
|
{
|
|
240
272
|
maxPerOwner,
|
|
241
|
-
startBlockNumber,
|
|
273
|
+
...(startBlockNumber === undefined ? {} : { startBlockNumber }),
|
|
242
274
|
},
|
|
243
275
|
)
|
|
244
276
|
return c.json(
|
|
@@ -609,6 +641,10 @@ function publicSubscription(subscription: Webhooks.Subscription) {
|
|
|
609
641
|
...(subscription.context === undefined ? {} : { context: subscription.context }),
|
|
610
642
|
createdAt: subscription.createdAt,
|
|
611
643
|
destination,
|
|
644
|
+
...(subscription.eventType === 'funding:deposit.updated' &&
|
|
645
|
+
subscription.environment !== undefined
|
|
646
|
+
? { environment: subscription.environment }
|
|
647
|
+
: {}),
|
|
612
648
|
eventType: subscription.eventType,
|
|
613
649
|
...(subscription.expiresAt === undefined ? {} : { expiresAt: subscription.expiresAt }),
|
|
614
650
|
failureCount: subscription.failureCount,
|
package/src/db/Db.test.ts
CHANGED
|
@@ -71,11 +71,7 @@ describe('postgres', () => {
|
|
|
71
71
|
await Organizations.create(tx, { id: 'org_qualified', name: 'Qualified' })
|
|
72
72
|
})
|
|
73
73
|
expect((await Organizations.get(db, 'org_qualified'))?.name).toBe('Qualified')
|
|
74
|
-
expect(
|
|
75
|
-
await WebhookSubscriptions.compareAndSetCursors(db, {
|
|
76
|
-
cursors: [{ cursor: 'next', expectedCursor: null, subscriptionId: 'wh_missing' }],
|
|
77
|
-
}),
|
|
78
|
-
).toEqual(new Set())
|
|
74
|
+
expect(await WebhookSubscriptions.getCursor(db, 'wh_missing')).toBeNull()
|
|
79
75
|
await db.close()
|
|
80
76
|
})
|
|
81
77
|
|
package/src/db/Db.ts
CHANGED
|
@@ -1398,6 +1398,29 @@ export const migrations: readonly Migration[] = [
|
|
|
1398
1398
|
.on('fundingDeposits')
|
|
1399
1399
|
.columns(['orgId', 'environment', 'projectId', 'createdAt', 'id']),
|
|
1400
1400
|
},
|
|
1401
|
+
{
|
|
1402
|
+
name: '0127_webhook_subscriptions_environment',
|
|
1403
|
+
up: (db) => raw(db, sql`ALTER TABLE webhook_subscriptions ADD COLUMN IF NOT EXISTS environment text`), // prettier-ignore
|
|
1404
|
+
},
|
|
1405
|
+
{
|
|
1406
|
+
name: '0128_webhook_subscriptions_project',
|
|
1407
|
+
up: (db) => raw(db, sql`ALTER TABLE webhook_subscriptions ADD COLUMN IF NOT EXISTS project_id text`), // prettier-ignore
|
|
1408
|
+
},
|
|
1409
|
+
{
|
|
1410
|
+
name: '0129_webhook_subscriptions_funding_deposit_index',
|
|
1411
|
+
up: (db) =>
|
|
1412
|
+
db.schema
|
|
1413
|
+
.createIndex('webhook_subscriptions_funding_deposit_idx')
|
|
1414
|
+
.ifNotExists()
|
|
1415
|
+
.on('webhookSubscriptions')
|
|
1416
|
+
.columns(['eventType', 'ownerType', 'ownerId', 'environment', 'projectId', 'chainId'])
|
|
1417
|
+
.where(sql.ref('status'), '=', sql.lit('active')),
|
|
1418
|
+
},
|
|
1419
|
+
{
|
|
1420
|
+
name: '0130_webhook_queue_events_observed_at',
|
|
1421
|
+
up: (db) =>
|
|
1422
|
+
raw(db, sql`ALTER TABLE webhook_queue_events ADD COLUMN IF NOT EXISTS observed_at text`),
|
|
1423
|
+
},
|
|
1401
1424
|
]
|
|
1402
1425
|
|
|
1403
1426
|
/**
|
package/src/db/Schema.ts
CHANGED
|
@@ -792,6 +792,9 @@ export const WebhookSubscription = z.object({
|
|
|
792
792
|
destination: z
|
|
793
793
|
.custom<Webhooks.Destination>()
|
|
794
794
|
.check(z.describe('Delivery destination (`jsonb`).')),
|
|
795
|
+
environment: z
|
|
796
|
+
.nullable(z.enum(['production', 'sandbox']))
|
|
797
|
+
.check(z.describe('API-key environment for private resource events, or null.')),
|
|
795
798
|
eventType: z
|
|
796
799
|
.custom<Webhooks.EventType>()
|
|
797
800
|
.check(z.describe('Event type the subscription listens to.')),
|
|
@@ -820,6 +823,9 @@ export const WebhookSubscription = z.object({
|
|
|
820
823
|
pollerCursor: z
|
|
821
824
|
.nullable(z.string())
|
|
822
825
|
.check(z.describe('Poller keyset cursor, or null before the first tick.')),
|
|
826
|
+
projectId: z
|
|
827
|
+
.nullable(z.string())
|
|
828
|
+
.check(z.describe('API-key project attribution for private resource events, or null.')),
|
|
823
829
|
secret: z.string().check(z.describe('HMAC signing secret.')),
|
|
824
830
|
status: z.custom<Webhooks.Status>().check(z.describe('Lifecycle status.')),
|
|
825
831
|
updatedAt: z
|
|
@@ -896,6 +902,13 @@ export const WebhookQueueEvent = z.object({
|
|
|
896
902
|
nextAttemptAt: z
|
|
897
903
|
.nullable(z.string())
|
|
898
904
|
.check(z.describe('ISO retry due time, or null when due immediately.')),
|
|
905
|
+
observedAt: z
|
|
906
|
+
.nullable(z.string())
|
|
907
|
+
.check(
|
|
908
|
+
z.describe(
|
|
909
|
+
'ISO timestamp the coordinator observed the head owing this event, or null on replays.',
|
|
910
|
+
),
|
|
911
|
+
),
|
|
899
912
|
status: z
|
|
900
913
|
.enum(['failed', 'pending', 'skipped', 'succeeded'])
|
|
901
914
|
.check(z.describe('Delivery-obligation lifecycle status.')),
|
|
@@ -363,8 +363,11 @@ describe('deleteOrganization', () => {
|
|
|
363
363
|
expect(await Organizations.deleteOrganization(db, 'org_1')).toBe(true)
|
|
364
364
|
|
|
365
365
|
expect(await Webhooks.listSubscriptions(db, deletedOrg)).toEqual([])
|
|
366
|
-
const active = await Webhooks.
|
|
367
|
-
|
|
366
|
+
const active = await Webhooks.listActiveForChain(db, {
|
|
367
|
+
chainId: 4217,
|
|
368
|
+
eventTypes: ['token:transfer'],
|
|
369
|
+
})
|
|
370
|
+
expect(active.map(({ subscription }) => subscription.id).sort()).toEqual(
|
|
368
371
|
[keptApiKey.id, keptMppSubscription.id].sort(),
|
|
369
372
|
)
|
|
370
373
|
await db.close()
|
|
@@ -7,7 +7,7 @@ import type * as db_Schema from '../Schema.js'
|
|
|
7
7
|
/** Columns of the `webhook_queue_events` table, derived from `Schema.WebhookQueueEvent`. */
|
|
8
8
|
export type Table = Omit<
|
|
9
9
|
db_Schema.WebhookQueueEvent,
|
|
10
|
-
'attemptCount' | 'attemptingAt' | 'nextAttemptAt' | 'status'
|
|
10
|
+
'attemptCount' | 'attemptingAt' | 'nextAttemptAt' | 'observedAt' | 'status'
|
|
11
11
|
> & {
|
|
12
12
|
/** Delivery attempts claimed so far; database default 0. */
|
|
13
13
|
attemptCount: Generated<number>
|
|
@@ -15,6 +15,8 @@ export type Table = Omit<
|
|
|
15
15
|
attemptingAt: Generated<string | null>
|
|
16
16
|
/** ISO retry due time; database default null (due immediately). */
|
|
17
17
|
nextAttemptAt: Generated<string | null>
|
|
18
|
+
/** ISO head-observation stamp; database default null (replays carry none). */
|
|
19
|
+
observedAt: Generated<string | null>
|
|
18
20
|
/** Delivery-obligation lifecycle status; database default `pending`. */
|
|
19
21
|
status: Generated<db_Schema.WebhookQueueEvent['status']>
|
|
20
22
|
}
|
|
@@ -65,6 +67,10 @@ export async function ensure(db: Db.Db, rows: readonly Insertable<Table>[]): Pro
|
|
|
65
67
|
envelope: eb.ref('excluded.envelope'),
|
|
66
68
|
expiresAt: eb.ref('excluded.expiresAt'),
|
|
67
69
|
nextAttemptAt: eb.ref('excluded.nextAttemptAt'),
|
|
70
|
+
// Refreshed with the rest: a replacement keeping the replaced row's
|
|
71
|
+
// stamp would measure observation-to-attempt from an observation
|
|
72
|
+
// that belongs to a delivery made days earlier.
|
|
73
|
+
observedAt: eb.ref('excluded.observedAt'),
|
|
68
74
|
status: 'pending',
|
|
69
75
|
}))
|
|
70
76
|
.where((eb) =>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ExpressionBuilder, Selectable } from 'kysely'
|
|
2
2
|
import { sql } from 'kysely'
|
|
3
3
|
|
|
4
|
+
import * as Scope from '../../Scope.js'
|
|
4
5
|
import type * as Webhooks from '../../internal/Webhooks.js'
|
|
5
6
|
import type * as Db from '../Db.js'
|
|
6
7
|
import type * as db_Schema from '../Schema.js'
|
|
@@ -40,6 +41,7 @@ export function toSubscription(row: Record): Webhooks.Subscription {
|
|
|
40
41
|
...(row.context === null ? {} : { context: row.context }),
|
|
41
42
|
createdAt: row.createdAt,
|
|
42
43
|
destination: row.destination,
|
|
44
|
+
...(row.environment === null ? {} : { environment: row.environment }),
|
|
43
45
|
eventType: row.eventType,
|
|
44
46
|
...(row.expiresAt === null ? {} : { expiresAt: row.expiresAt }),
|
|
45
47
|
failureCount: row.failureCount,
|
|
@@ -50,6 +52,7 @@ export function toSubscription(row: Record): Webhooks.Subscription {
|
|
|
50
52
|
row.ownerType === 'api_key'
|
|
51
53
|
? { orgId: row.ownerId, type: 'api_key' }
|
|
52
54
|
: { payer: row.ownerId, type: 'mpp' },
|
|
55
|
+
...(row.projectId === null ? {} : { projectId: row.projectId }),
|
|
53
56
|
secret: row.secret,
|
|
54
57
|
status: row.status,
|
|
55
58
|
updatedAt: row.updatedAt,
|
|
@@ -75,6 +78,7 @@ export async function insert(
|
|
|
75
78
|
context: subscription.context ?? null,
|
|
76
79
|
createdAt: subscription.createdAt,
|
|
77
80
|
destination: subscription.destination,
|
|
81
|
+
environment: subscription.environment ?? null,
|
|
78
82
|
eventType: subscription.eventType,
|
|
79
83
|
expiresAt: subscription.expiresAt ?? null,
|
|
80
84
|
failureCount: subscription.failureCount,
|
|
@@ -84,6 +88,7 @@ export async function insert(
|
|
|
84
88
|
ownerId: ownerId(subscription.owner),
|
|
85
89
|
ownerType: subscription.owner.type,
|
|
86
90
|
pollerCursor: options.pollerCursor ?? null,
|
|
91
|
+
projectId: subscription.projectId ?? null,
|
|
87
92
|
secret: subscription.secret,
|
|
88
93
|
status: subscription.status,
|
|
89
94
|
updatedAt: subscription.updatedAt,
|
|
@@ -94,7 +99,7 @@ export async function insert(
|
|
|
94
99
|
export declare namespace insert {
|
|
95
100
|
/** Options for {@link insert}. */
|
|
96
101
|
type Options = {
|
|
97
|
-
/** Initial
|
|
102
|
+
/** Initial cursor committed with the subscription. */
|
|
98
103
|
pollerCursor?: string | undefined
|
|
99
104
|
}
|
|
100
105
|
}
|
|
@@ -129,7 +134,7 @@ export declare namespace insertWithinLimit {
|
|
|
129
134
|
maxPerOwner: number
|
|
130
135
|
/** ISO timestamp used to exclude expired rows from the count. */
|
|
131
136
|
now: string
|
|
132
|
-
/** Initial
|
|
137
|
+
/** Initial cursor committed with the subscription. */
|
|
133
138
|
pollerCursor?: string | undefined
|
|
134
139
|
}
|
|
135
140
|
}
|
|
@@ -149,15 +154,17 @@ export async function get(
|
|
|
149
154
|
owner: Webhooks.Owner,
|
|
150
155
|
id: string,
|
|
151
156
|
now: string,
|
|
157
|
+
access?: Webhooks.Access,
|
|
152
158
|
): Promise<Webhooks.Subscription | null> {
|
|
153
|
-
|
|
159
|
+
let query = db.kysely
|
|
154
160
|
.selectFrom('webhook_subscriptions')
|
|
155
161
|
.selectAll()
|
|
156
162
|
.where('id', '=', id)
|
|
157
163
|
.where('ownerType', '=', owner.type)
|
|
158
164
|
.where('ownerId', '=', ownerId(owner))
|
|
159
165
|
.where(live(now))
|
|
160
|
-
|
|
166
|
+
if (access) query = query.where(privateResourceAccess(access))
|
|
167
|
+
const row = await query.executeTakeFirst()
|
|
161
168
|
return row ? toSubscription(row) : null
|
|
162
169
|
}
|
|
163
170
|
|
|
@@ -183,6 +190,7 @@ export async function list(
|
|
|
183
190
|
.where('ownerId', '=', ownerId(owner))
|
|
184
191
|
.where(live(options.now))
|
|
185
192
|
.orderBy('id', 'desc')
|
|
193
|
+
if (options.access) query = query.where(privateResourceAccess(options.access))
|
|
186
194
|
if (options.cursor) query = query.where('id', '<', options.cursor)
|
|
187
195
|
if (options.offset) query = query.offset(options.offset)
|
|
188
196
|
if (options.limit !== undefined) query = query.limit(options.limit)
|
|
@@ -192,6 +200,8 @@ export async function list(
|
|
|
192
200
|
export declare namespace list {
|
|
193
201
|
/** Options for {@link list}. */
|
|
194
202
|
type Options = {
|
|
203
|
+
/** Private-resource visibility scope. */
|
|
204
|
+
access?: Webhooks.Access | undefined
|
|
195
205
|
/** Return subscriptions older than this id (keyset paging, newest first). */
|
|
196
206
|
cursor?: string | undefined
|
|
197
207
|
/** Maximum subscriptions to return. */
|
|
@@ -211,14 +221,20 @@ export declare namespace list {
|
|
|
211
221
|
* @param now - ISO timestamp expiry is evaluated against.
|
|
212
222
|
* @returns The count.
|
|
213
223
|
*/
|
|
214
|
-
export async function count(
|
|
215
|
-
|
|
224
|
+
export async function count(
|
|
225
|
+
db: Db.Db,
|
|
226
|
+
owner: Webhooks.Owner,
|
|
227
|
+
now: string,
|
|
228
|
+
access?: Webhooks.Access,
|
|
229
|
+
): Promise<number> {
|
|
230
|
+
let query = db.kysely
|
|
216
231
|
.selectFrom('webhook_subscriptions')
|
|
217
232
|
.select((eb) => eb.fn.countAll().as('count'))
|
|
218
233
|
.where('ownerType', '=', owner.type)
|
|
219
234
|
.where('ownerId', '=', ownerId(owner))
|
|
220
235
|
.where(live(now))
|
|
221
|
-
|
|
236
|
+
if (access) query = query.where(privateResourceAccess(access))
|
|
237
|
+
const row = await query.executeTakeFirstOrThrow()
|
|
222
238
|
return Number(row.count)
|
|
223
239
|
}
|
|
224
240
|
|
|
@@ -275,21 +291,42 @@ export async function remove(
|
|
|
275
291
|
owner: Webhooks.Owner,
|
|
276
292
|
id: string,
|
|
277
293
|
now: string,
|
|
294
|
+
access?: Webhooks.Access,
|
|
278
295
|
): Promise<boolean> {
|
|
279
|
-
|
|
296
|
+
let query = db.kysely
|
|
280
297
|
.deleteFrom('webhook_subscriptions')
|
|
281
298
|
.where('id', '=', id)
|
|
282
299
|
.where('ownerType', '=', owner.type)
|
|
283
300
|
.where('ownerId', '=', ownerId(owner))
|
|
284
301
|
.where(live(now))
|
|
285
|
-
|
|
302
|
+
if (access) query = query.where(privateResourceAccess(access))
|
|
303
|
+
const result = await query.executeTakeFirst()
|
|
286
304
|
return result.numDeletedRows > 0n
|
|
287
305
|
}
|
|
288
306
|
|
|
307
|
+
function privateResourceAccess(access: Webhooks.Access) {
|
|
308
|
+
const fundingRead = access.scopes.some(
|
|
309
|
+
(scope) => scope === Scope.wildcard || scope === 'funding:read',
|
|
310
|
+
)
|
|
311
|
+
return (eb: ExpressionBuilder<Db.Database, 'webhook_subscriptions'>) =>
|
|
312
|
+
eb.or([
|
|
313
|
+
eb('eventType', '!=', 'funding:deposit.updated'),
|
|
314
|
+
...(fundingRead
|
|
315
|
+
? [
|
|
316
|
+
eb.and([
|
|
317
|
+
eb('eventType', '=', 'funding:deposit.updated'),
|
|
318
|
+
eb('environment', '=', access.environment),
|
|
319
|
+
...(access.projectId === undefined ? [] : [eb('projectId', '=', access.projectId)]),
|
|
320
|
+
]),
|
|
321
|
+
]
|
|
322
|
+
: []),
|
|
323
|
+
])
|
|
324
|
+
}
|
|
325
|
+
|
|
289
326
|
/**
|
|
290
|
-
* Lists live `active` subscriptions for a `(chainId, eventType)` stream
|
|
291
|
-
*
|
|
292
|
-
*
|
|
327
|
+
* Lists live `active` subscriptions for a `(chainId, eventType)` stream,
|
|
328
|
+
* served by the partial status index. Each row carries its cursor so callers
|
|
329
|
+
* avoid a per-subscription read.
|
|
293
330
|
*
|
|
294
331
|
* @param db - The database.
|
|
295
332
|
* @param chainId - Chain the stream belongs to.
|
|
@@ -316,8 +353,8 @@ export async function listActive(
|
|
|
316
353
|
}
|
|
317
354
|
|
|
318
355
|
/**
|
|
319
|
-
* Lists every live `active` subscription on a chain in one read.
|
|
320
|
-
*
|
|
356
|
+
* Lists every live `active` subscription on a chain in one read. Callers
|
|
357
|
+
* group the rows by event type so empty streams cost no extra database reads.
|
|
321
358
|
*
|
|
322
359
|
* @param db - The database.
|
|
323
360
|
* @param options - Chain, configured event types, and expiry time.
|
|
@@ -342,43 +379,58 @@ export async function listActiveForChain(
|
|
|
342
379
|
}
|
|
343
380
|
|
|
344
381
|
export declare namespace listActiveForChain {
|
|
345
|
-
/** Filters for the
|
|
382
|
+
/** Filters for the chain-level subscription read. */
|
|
346
383
|
type Options = {
|
|
347
384
|
/** Chain whose subscriptions to read. */
|
|
348
385
|
chainId: number
|
|
349
|
-
/** Event types
|
|
386
|
+
/** Event types eligible for the read. */
|
|
350
387
|
eventTypes: readonly Webhooks.EventType[]
|
|
351
388
|
/** ISO timestamp expiry is evaluated against. */
|
|
352
389
|
now: string
|
|
353
390
|
}
|
|
354
391
|
}
|
|
355
392
|
|
|
356
|
-
/**
|
|
357
|
-
|
|
358
|
-
* across event types. Feeds the cron's cursor-freshness fallback gate.
|
|
359
|
-
*
|
|
360
|
-
* @param db - The database.
|
|
361
|
-
* @param chainId - Chain whose cursors to read.
|
|
362
|
-
* @param now - ISO timestamp expiry is evaluated against.
|
|
363
|
-
* @returns The non-null cursors, unordered.
|
|
364
|
-
*/
|
|
365
|
-
export async function listActiveCursors(
|
|
393
|
+
/** Lists live funding deposit subscriptions visible to one deposit owner. */
|
|
394
|
+
export async function listActiveForFundingDeposit(
|
|
366
395
|
db: Db.Db,
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
const rows = await db.kysely
|
|
396
|
+
options: listActiveForFundingDeposit.Options,
|
|
397
|
+
): Promise<Webhooks.Subscription[]> {
|
|
398
|
+
let query = db.kysely
|
|
371
399
|
.selectFrom('webhook_subscriptions')
|
|
372
|
-
.
|
|
373
|
-
.where('chainId', '=', chainId)
|
|
400
|
+
.selectAll()
|
|
401
|
+
.where('chainId', '=', options.chainId)
|
|
402
|
+
.where('environment', '=', options.environment)
|
|
403
|
+
.where('eventType', '=', 'funding:deposit.updated')
|
|
404
|
+
.where('ownerId', '=', options.orgId)
|
|
405
|
+
.where('ownerType', '=', 'api_key')
|
|
374
406
|
.where('status', '=', 'active')
|
|
375
|
-
.where(live(now))
|
|
376
|
-
|
|
377
|
-
|
|
407
|
+
.where(live(options.now))
|
|
408
|
+
const projectId = options.projectId
|
|
409
|
+
query =
|
|
410
|
+
projectId === undefined
|
|
411
|
+
? query.where('projectId', 'is', null)
|
|
412
|
+
: query.where((eb) => eb.or([eb('projectId', 'is', null), eb('projectId', '=', projectId)]))
|
|
413
|
+
return (await query.orderBy('id').execute()).map(toSubscription)
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
export declare namespace listActiveForFundingDeposit {
|
|
417
|
+
/** Ownership scope and chain for a funding deposit event. */
|
|
418
|
+
type Options = {
|
|
419
|
+
/** Destination Tempo chain id. */
|
|
420
|
+
chainId: number
|
|
421
|
+
/** Deposit API-key environment. */
|
|
422
|
+
environment: 'production' | 'sandbox'
|
|
423
|
+
/** ISO timestamp used to exclude expired subscriptions. */
|
|
424
|
+
now: string
|
|
425
|
+
/** Owning organization id. */
|
|
426
|
+
orgId: string
|
|
427
|
+
/** Attributed deposit project, when present. */
|
|
428
|
+
projectId?: string | undefined
|
|
429
|
+
}
|
|
378
430
|
}
|
|
379
431
|
|
|
380
432
|
/**
|
|
381
|
-
* Reads a subscription's
|
|
433
|
+
* Reads a subscription's keyset cursor.
|
|
382
434
|
*
|
|
383
435
|
* @param db - The database.
|
|
384
436
|
* @param id - The subscription id (`wh_…`).
|
|
@@ -393,107 +445,6 @@ export async function getCursor(db: Db.Db, id: string): Promise<string | null> {
|
|
|
393
445
|
return row?.pollerCursor ?? null
|
|
394
446
|
}
|
|
395
447
|
|
|
396
|
-
/**
|
|
397
|
-
* Writes a subscription's poller cursor. Deliberately does not touch
|
|
398
|
-
* `updatedAt` — the cursor advances every tick and is not a user mutation.
|
|
399
|
-
*
|
|
400
|
-
* @param db - The database.
|
|
401
|
-
* @param id - The subscription id (`wh_…`).
|
|
402
|
-
* @param cursor - The keyset cursor to store.
|
|
403
|
-
*/
|
|
404
|
-
export async function setCursor(db: Db.Db, id: string, cursor: string): Promise<void> {
|
|
405
|
-
await db.kysely
|
|
406
|
-
.updateTable('webhook_subscriptions')
|
|
407
|
-
.set({ pollerCursor: cursor })
|
|
408
|
-
.where('id', '=', id)
|
|
409
|
-
.execute()
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
/**
|
|
413
|
-
* Writes a poller cursor only when the stored cursor still matches the caller's
|
|
414
|
-
* observed value.
|
|
415
|
-
*
|
|
416
|
-
* @param db - The database.
|
|
417
|
-
* @param options - Subscription, expected cursor, and next cursor.
|
|
418
|
-
* @returns Whether the cursor was updated.
|
|
419
|
-
*/
|
|
420
|
-
export async function compareAndSetCursor(
|
|
421
|
-
db: Db.Db,
|
|
422
|
-
options: compareAndSetCursor.Options,
|
|
423
|
-
): Promise<boolean> {
|
|
424
|
-
let query = db.kysely
|
|
425
|
-
.updateTable('webhook_subscriptions')
|
|
426
|
-
.set({ pollerCursor: options.cursor })
|
|
427
|
-
.where('id', '=', options.subscriptionId)
|
|
428
|
-
query =
|
|
429
|
-
options.expectedCursor === null
|
|
430
|
-
? query.where('pollerCursor', 'is', null)
|
|
431
|
-
: query.where('pollerCursor', '=', options.expectedCursor)
|
|
432
|
-
const result = await query.executeTakeFirst()
|
|
433
|
-
return result.numUpdatedRows > 0n
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
export declare namespace compareAndSetCursor {
|
|
437
|
-
/** Options for {@link compareAndSetCursor}. */
|
|
438
|
-
type Options = {
|
|
439
|
-
/** Next keyset cursor. */
|
|
440
|
-
cursor: string
|
|
441
|
-
/** Cursor observed before scanning, or null for an uninitialized subscription. */
|
|
442
|
-
expectedCursor: string | null
|
|
443
|
-
/** Subscription whose cursor advances. */
|
|
444
|
-
subscriptionId: string
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
/**
|
|
449
|
-
* Writes multiple poller cursors when each stored value still matches the
|
|
450
|
-
* caller's observed value.
|
|
451
|
-
*
|
|
452
|
-
* @param db - The database.
|
|
453
|
-
* @param options - Candidate cursor writes.
|
|
454
|
-
* @returns Subscription ids whose cursors were updated.
|
|
455
|
-
*/
|
|
456
|
-
export async function compareAndSetCursors(
|
|
457
|
-
db: Db.Db,
|
|
458
|
-
options: compareAndSetCursors.Options,
|
|
459
|
-
): Promise<Set<string>> {
|
|
460
|
-
if (options.cursors.length === 0) return new Set()
|
|
461
|
-
const values = JSON.stringify(options.cursors)
|
|
462
|
-
type Candidate = {
|
|
463
|
-
cursor: string
|
|
464
|
-
expectedCursor: string | null
|
|
465
|
-
subscriptionId: string
|
|
466
|
-
}
|
|
467
|
-
const result = await db.kysely
|
|
468
|
-
.with(
|
|
469
|
-
'candidates',
|
|
470
|
-
() => sql<Candidate>`(
|
|
471
|
-
SELECT
|
|
472
|
-
candidate."subscriptionId" AS subscription_id,
|
|
473
|
-
candidate."expectedCursor" AS expected_cursor,
|
|
474
|
-
candidate.cursor
|
|
475
|
-
FROM jsonb_to_recordset(${values}::jsonb)
|
|
476
|
-
AS candidate("subscriptionId" text, "expectedCursor" text, cursor text)
|
|
477
|
-
)`,
|
|
478
|
-
)
|
|
479
|
-
.updateTable('webhook_subscriptions as subscription')
|
|
480
|
-
.from('candidates as candidate')
|
|
481
|
-
.set((eb) => ({ pollerCursor: eb.ref('candidate.cursor') }))
|
|
482
|
-
.whereRef('subscription.id', '=', 'candidate.subscriptionId')
|
|
483
|
-
.whereRef('subscription.pollerCursor', 'is not distinct from', 'candidate.expectedCursor')
|
|
484
|
-
.returning('subscription.id as subscriptionId')
|
|
485
|
-
.execute()
|
|
486
|
-
return new Set(result.map((row) => row.subscriptionId))
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
export declare namespace compareAndSetCursors {
|
|
490
|
-
/** Options for {@link compareAndSetCursors}. */
|
|
491
|
-
type Options = {
|
|
492
|
-
/** Candidate cursor writes. */
|
|
493
|
-
cursors: readonly compareAndSetCursor.Options[]
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
448
|
/**
|
|
498
449
|
* Records a successful delivery: resets the consecutive-failure counter and
|
|
499
450
|
* stamps `lastDeliveryAt`.
|
|
@@ -549,7 +500,7 @@ export async function recordFailure(
|
|
|
549
500
|
|
|
550
501
|
/**
|
|
551
502
|
* Deletes expired subscriptions (their delivery rows cascade). Called
|
|
552
|
-
* best-effort
|
|
503
|
+
* best-effort on a schedule.
|
|
553
504
|
*
|
|
554
505
|
* @param db - The database.
|
|
555
506
|
* @param now - ISO timestamp expiry is evaluated against.
|