tapimo 0.10.2 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/App.d.ts +3412 -1968
  2. package/dist/App.d.ts.map +1 -1
  3. package/dist/App.js.map +1 -1
  4. package/dist/Webhooks.d.ts +0 -75
  5. package/dist/Webhooks.d.ts.map +1 -1
  6. package/dist/Webhooks.js +1 -143
  7. package/dist/Webhooks.js.map +1 -1
  8. package/dist/admin/App.d.ts +74 -74
  9. package/dist/admin/apps/api-keys.d.ts +4 -4
  10. package/dist/admin/apps/earn-vaults.d.ts +1 -1
  11. package/dist/admin/apps/organizations.d.ts +3 -3
  12. package/dist/admin/apps/verified-tokens.d.ts +74 -74
  13. package/dist/apps/data/App.d.ts +3618 -2954
  14. package/dist/apps/data/App.d.ts.map +1 -1
  15. package/dist/apps/data/App.js +5 -2
  16. package/dist/apps/data/App.js.map +1 -1
  17. package/dist/apps/data/routes/activities.d.ts +1 -1
  18. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  19. package/dist/apps/data/routes/activities.js +55 -27
  20. package/dist/apps/data/routes/activities.js.map +1 -1
  21. package/dist/apps/data/routes/addresses.d.ts +343 -343
  22. package/dist/apps/data/routes/balances.d.ts +11 -11
  23. package/dist/apps/data/routes/blocks.d.ts +5 -5
  24. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  25. package/dist/apps/data/routes/earn.d.ts +4 -4
  26. package/dist/apps/data/routes/exchanges.d.ts +4 -4
  27. package/dist/apps/data/routes/fee-amm.d.ts +26 -26
  28. package/dist/apps/data/routes/indexer.d.ts +34 -34
  29. package/dist/apps/data/routes/receipts.d.ts +236 -236
  30. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  31. package/dist/apps/data/routes/tokens.d.ts +3 -3
  32. package/dist/apps/data/routes/transactions.d.ts +1 -1
  33. package/dist/apps/data/routes/transfers.d.ts +13 -13
  34. package/dist/apps/data/routes/valuation.d.ts +21 -21
  35. package/dist/apps/data/routes/verified-tokens.d.ts +5 -5
  36. package/dist/apps/data/routes/webhooks.d.ts +1011 -151
  37. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  38. package/dist/apps/data/routes/webhooks.js +124 -737
  39. package/dist/apps/data/routes/webhooks.js.map +1 -1
  40. package/dist/apps/data/routes/zones.d.ts +31 -31
  41. package/dist/apps/funding/routes/deposit-addresses.d.ts +2 -2
  42. package/dist/apps/funding/routes/providers.d.ts +13 -13
  43. package/dist/apps/funding/routes/transfers.d.ts +33 -33
  44. package/dist/apps/management/App.d.ts +793 -16
  45. package/dist/apps/management/App.d.ts.map +1 -1
  46. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  47. package/dist/apps/management/routes/faucet.d.ts +31 -31
  48. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  49. package/dist/apps/management/routes/members.d.ts +79 -79
  50. package/dist/apps/management/routes/projects.d.ts +40 -40
  51. package/dist/apps/management/routes/usage.d.ts +80 -80
  52. package/dist/apps/management/routes/webhooks.d.ts +1031 -59
  53. package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
  54. package/dist/apps/management/routes/webhooks.js +27 -4
  55. package/dist/apps/management/routes/webhooks.js.map +1 -1
  56. package/dist/db/Db.d.ts.map +1 -1
  57. package/dist/db/Db.js +21 -0
  58. package/dist/db/Db.js.map +1 -1
  59. package/dist/db/Schema.d.ts +7 -1
  60. package/dist/db/Schema.d.ts.map +1 -1
  61. package/dist/db/Schema.js +9 -0
  62. package/dist/db/Schema.js.map +1 -1
  63. package/dist/db/tables/webhookQueueEvents.d.ts +3 -1
  64. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -1
  65. package/dist/db/tables/webhookQueueEvents.js +4 -0
  66. package/dist/db/tables/webhookQueueEvents.js.map +1 -1
  67. package/dist/db/tables/webhookSubscriptions.d.ts +33 -69
  68. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  69. package/dist/db/tables/webhookSubscriptions.js +63 -100
  70. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  71. package/dist/internal/MetricSink.d.ts +35 -125
  72. package/dist/internal/MetricSink.d.ts.map +1 -1
  73. package/dist/internal/MetricSink.js +50 -121
  74. package/dist/internal/MetricSink.js.map +1 -1
  75. package/dist/internal/Schema.d.ts +3 -1
  76. package/dist/internal/Schema.d.ts.map +1 -1
  77. package/dist/internal/Schema.js +4 -2
  78. package/dist/internal/Schema.js.map +1 -1
  79. package/dist/internal/WebhookTransfer.d.ts +1 -55
  80. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  81. package/dist/internal/WebhookTransfer.js +1 -239
  82. package/dist/internal/WebhookTransfer.js.map +1 -1
  83. package/dist/internal/Webhooks.d.ts +83 -44
  84. package/dist/internal/Webhooks.d.ts.map +1 -1
  85. package/dist/internal/Webhooks.js +87 -73
  86. package/dist/internal/Webhooks.js.map +1 -1
  87. package/dist/internal/funding/DepositWebhook.d.ts +25 -0
  88. package/dist/internal/funding/DepositWebhook.d.ts.map +1 -0
  89. package/dist/internal/funding/DepositWebhook.js +74 -0
  90. package/dist/internal/funding/DepositWebhook.js.map +1 -0
  91. package/dist/internal/funding/Reconciliation.d.ts +6 -0
  92. package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
  93. package/dist/internal/funding/Reconciliation.js +42 -10
  94. package/dist/internal/funding/Reconciliation.js.map +1 -1
  95. package/dist/internal/funding/index.d.ts +1 -0
  96. package/dist/internal/funding/index.d.ts.map +1 -1
  97. package/dist/internal/funding/index.js +1 -0
  98. package/dist/internal/funding/index.js.map +1 -1
  99. package/dist/internal/webhookDestinations/slack.d.ts.map +1 -1
  100. package/dist/internal/webhookDestinations/slack.js +16 -0
  101. package/dist/internal/webhookDestinations/slack.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/App.ts +3 -0
  104. package/src/Client.test-d.ts +22 -1
  105. package/src/Webhooks.ts +1 -220
  106. package/src/apps/data/App.ts +8 -2
  107. package/src/apps/data/routes/activities.test.ts +107 -11
  108. package/src/apps/data/routes/activities.ts +64 -32
  109. package/src/apps/data/routes/webhooks.test.ts +179 -1497
  110. package/src/apps/data/routes/webhooks.ts +150 -974
  111. package/src/apps/management/routes/webhooks.test.ts +79 -1
  112. package/src/apps/management/routes/webhooks.ts +40 -4
  113. package/src/db/Db.test.ts +1 -5
  114. package/src/db/Db.ts +23 -0
  115. package/src/db/Schema.ts +13 -0
  116. package/src/db/tables/organizations.test.ts +5 -2
  117. package/src/db/tables/webhookQueueEvents.ts +7 -1
  118. package/src/db/tables/webhookSubscriptions.ts +89 -138
  119. package/src/internal/MetricSink.test.ts +41 -373
  120. package/src/internal/MetricSink.ts +92 -273
  121. package/src/internal/Schema.test.ts +18 -0
  122. package/src/internal/Schema.ts +13 -2
  123. package/src/internal/WebhookTransfer.ts +1 -351
  124. package/src/internal/Webhooks.test.ts +102 -139
  125. package/src/internal/Webhooks.ts +175 -106
  126. package/src/internal/funding/DepositWebhook.test.ts +131 -0
  127. package/src/internal/funding/DepositWebhook.ts +101 -0
  128. package/src/internal/funding/Reconciliation.test.ts +14 -0
  129. package/src/internal/funding/Reconciliation.ts +161 -86
  130. package/src/internal/funding/index.test.ts +1 -0
  131. package/src/internal/funding/index.ts +1 -0
  132. package/src/internal/webhookDestinations/slack.ts +15 -0
  133. package/src/Webhooks.bench.ts +0 -714
  134. package/src/Webhooks.memory.bench.ts +0 -250
  135. package/src/Webhooks.test.ts +0 -146
@@ -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 = { supportedChainIds: [4217, 42431] } satisfies App.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({ chainId: webhookSchema.Subscription.shape.chainId }),
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 = Number(await c.get('getClient')(chainId).getBlockNumber())
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.listActive(db, 4217, 'token:transfer')
367
- expect(active.map(({ id }) => id).sort()).toEqual(
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 poller cursor committed with the subscription. */
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 poller cursor committed with the subscription. */
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
- const row = await db.kysely
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
- .executeTakeFirst()
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(db: Db.Db, owner: Webhooks.Owner, now: string): Promise<number> {
215
- const row = await db.kysely
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
- .executeTakeFirstOrThrow()
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
- const result = await db.kysely
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
- .executeTakeFirst()
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 — the
291
- * poller's per-stream lookup, served by the partial status index. Each row
292
- * carries its poller cursor so the poller avoids a per-subscription read.
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. The poller
320
- * groups the rows by event type so empty streams cost no extra database reads.
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 poller's chain-level subscription read. */
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 configured for this poller tick. */
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
- * Reads the poller cursors of every live `active` subscription on a chain,
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
- chainId: number,
368
- now: string,
369
- ): Promise<string[]> {
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
- .select('pollerCursor')
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
- .execute()
377
- return rows.flatMap((row) => (row.pollerCursor === null ? [] : [row.pollerCursor]))
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 poller cursor.
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 from the poller tick.
503
+ * best-effort on a schedule.
553
504
  *
554
505
  * @param db - The database.
555
506
  * @param now - ISO timestamp expiry is evaluated against.