tapimo 0.9.0 → 0.10.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 +4887 -5074
- package/dist/App.d.ts.map +1 -1
- package/dist/Webhooks.d.ts +21 -20
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +66 -23
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/apps/verified-tokens.d.ts +168 -168
- package/dist/apps/Zones.d.ts +34 -22
- package/dist/apps/Zones.d.ts.map +1 -1
- package/dist/apps/Zones.js +9 -8
- package/dist/apps/Zones.js.map +1 -1
- package/dist/apps/data/App.d.ts +5198 -5099
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.d.ts +4 -5
- package/dist/apps/data/FxOracle.d.ts.map +1 -1
- package/dist/apps/data/FxOracle.js +19 -4
- package/dist/apps/data/FxOracle.js.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +270 -270
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +32 -27
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +52 -52
- package/dist/apps/data/routes/balances.d.ts +36 -36
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +135 -81
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +125 -125
- package/dist/apps/data/routes/coingecko.d.ts +54 -54
- package/dist/apps/data/routes/earn.d.ts +512 -58
- package/dist/apps/data/routes/earn.d.ts.map +1 -1
- package/dist/apps/data/routes/earn.js +368 -44
- package/dist/apps/data/routes/earn.js.map +1 -1
- package/dist/apps/data/routes/exchanges.d.ts +120 -120
- package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
- package/dist/apps/data/routes/exchanges.js +114 -118
- package/dist/apps/data/routes/exchanges.js.map +1 -1
- package/dist/apps/data/routes/receipts.d.ts.map +1 -1
- package/dist/apps/data/routes/receipts.js +68 -47
- package/dist/apps/data/routes/receipts.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts +31 -31
- package/dist/apps/data/routes/tokens.d.ts +461 -459
- package/dist/apps/data/routes/tokens.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.d.ts +59 -51
- package/dist/apps/data/routes/transactions.d.ts.map +1 -1
- package/dist/apps/data/routes/transactions.js +25 -23
- package/dist/apps/data/routes/transactions.js.map +1 -1
- package/dist/apps/data/routes/transfers.d.ts +108 -86
- package/dist/apps/data/routes/transfers.d.ts.map +1 -1
- package/dist/apps/data/routes/transfers.js +618 -105
- package/dist/apps/data/routes/transfers.js.map +1 -1
- package/dist/apps/data/routes/valuation.d.ts +21 -21
- package/dist/apps/data/routes/valuation.d.ts.map +1 -1
- package/dist/apps/data/routes/valuation.js +2 -2
- package/dist/apps/data/routes/valuation.js.map +1 -1
- package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
- package/dist/apps/data/routes/webhooks.d.ts +102 -505
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +484 -342
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +12 -12
- package/dist/apps/data/routes/zones.d.ts.map +1 -1
- package/dist/apps/data/routes/zones.js +3 -1
- package/dist/apps/data/routes/zones.js.map +1 -1
- package/dist/apps/funding/App.d.ts +96 -96
- package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
- package/dist/apps/funding/routes/quotes.d.ts +89 -89
- package/dist/apps/funding/routes/transfers.d.ts +4 -4
- package/dist/apps/management/App.d.ts +429 -727
- package/dist/apps/management/App.d.ts.map +1 -1
- package/dist/apps/management/routes/api-keys.d.ts +24 -24
- package/dist/apps/management/routes/billing.d.ts +72 -72
- package/dist/apps/management/routes/faucet.d.ts +6 -6
- package/dist/apps/management/routes/invitations.d.ts +134 -134
- package/dist/apps/management/routes/invite-links.d.ts +38 -38
- package/dist/apps/management/routes/me.d.ts +6 -6
- package/dist/apps/management/routes/members.d.ts +30 -30
- package/dist/apps/management/routes/orgs.d.ts +98 -98
- package/dist/apps/management/routes/projects.d.ts +20 -20
- package/dist/apps/management/routes/usage.d.ts +24 -24
- package/dist/apps/management/routes/verified-token-requests.d.ts +83 -83
- package/dist/apps/management/routes/webhooks.d.ts +158 -573
- package/dist/apps/management/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/mpp/App.d.ts +56 -44
- package/dist/apps/mpp/App.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.d.ts +3 -0
- package/dist/apps/mpp/Mpp.d.ts.map +1 -1
- package/dist/apps/mpp/Mpp.js +3 -0
- package/dist/apps/mpp/Mpp.js.map +1 -1
- package/dist/apps/mpp/internal/Adapter.d.ts.map +1 -1
- package/dist/apps/mpp/internal/Adapter.js +9 -2
- package/dist/apps/mpp/internal/Adapter.js.map +1 -1
- package/dist/db/Db.d.ts +2 -2
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +17 -15
- package/dist/db/Db.js.map +1 -1
- package/dist/db/tables/sponsoredTransactions.d.ts.map +1 -1
- package/dist/db/tables/sponsoredTransactions.js +4 -1
- package/dist/db/tables/sponsoredTransactions.js.map +1 -1
- package/dist/db/tables/webhookSubscriptions.d.ts +56 -3
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +76 -3
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +112 -3
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +116 -3
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/Schema.js +1 -1
- package/dist/internal/Schema.js.map +1 -1
- package/dist/internal/Tidx.d.ts +1 -3
- package/dist/internal/Tidx.d.ts.map +1 -1
- package/dist/internal/Tidx.js +1 -3
- package/dist/internal/Tidx.js.map +1 -1
- package/dist/internal/Viem.d.ts.map +1 -1
- package/dist/internal/Viem.js +2 -1
- package/dist/internal/Viem.js.map +1 -1
- package/dist/internal/WebhookDestination.d.ts +2 -0
- package/dist/internal/WebhookDestination.d.ts.map +1 -1
- package/dist/internal/WebhookDestination.js +11 -0
- package/dist/internal/WebhookDestination.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +55 -0
- package/dist/internal/WebhookTransfer.d.ts.map +1 -0
- package/dist/internal/WebhookTransfer.js +296 -0
- package/dist/internal/WebhookTransfer.js.map +1 -0
- package/dist/internal/Webhooks.d.ts +37 -0
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +87 -13
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/Provider.d.ts +4 -0
- package/dist/internal/funding/Provider.d.ts.map +1 -1
- package/dist/internal/funding/Provider.js +11 -7
- package/dist/internal/funding/Provider.js.map +1 -1
- package/dist/internal/funding/Reconciliation.d.ts.map +1 -1
- package/dist/internal/funding/Reconciliation.js +128 -0
- package/dist/internal/funding/Reconciliation.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +3 -0
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +12 -1
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +3 -3
- package/src/Client.test-d.ts +60 -5
- package/src/Webhooks.bench.ts +714 -0
- package/src/Webhooks.memory.bench.ts +250 -0
- package/src/Webhooks.test.ts +77 -6
- package/src/Webhooks.ts +83 -29
- package/src/apps/Zones.test.ts +5 -4
- package/src/apps/Zones.ts +9 -8
- package/src/apps/data/FxOracle.test.ts +18 -0
- package/src/apps/data/FxOracle.ts +28 -5
- package/src/apps/data/routes/activities.test.ts +19 -0
- package/src/apps/data/routes/activities.ts +34 -30
- package/src/apps/data/routes/balances.test.ts +119 -10
- package/src/apps/data/routes/balances.ts +154 -95
- package/src/apps/data/routes/blocks.test.ts +12 -6
- package/src/apps/data/routes/earn.test.ts +197 -0
- package/src/apps/data/routes/earn.ts +486 -44
- package/src/apps/data/routes/exchanges.test.ts +7 -6
- package/src/apps/data/routes/exchanges.ts +124 -117
- package/src/apps/data/routes/receipts.test.ts +24 -7
- package/src/apps/data/routes/receipts.ts +67 -50
- package/src/apps/data/routes/tokens.test.ts +51 -0
- package/src/apps/data/routes/transactions.test.ts +42 -0
- package/src/apps/data/routes/transactions.ts +44 -39
- package/src/apps/data/routes/transfers.test.ts +402 -24
- package/src/apps/data/routes/transfers.ts +708 -101
- package/src/apps/data/routes/valuation.test.ts +26 -0
- package/src/apps/data/routes/valuation.ts +6 -2
- package/src/apps/data/routes/webhooks.test.ts +414 -130
- package/src/apps/data/routes/webhooks.ts +603 -419
- package/src/apps/data/routes/zones.ts +6 -1
- package/src/apps/funding/routes/quotes.test.ts +1 -1
- package/src/apps/mpp/Mpp.test.ts +13 -1
- package/src/apps/mpp/Mpp.ts +8 -0
- package/src/apps/mpp/internal/Adapter.ts +10 -2
- package/src/db/Db.test.ts +40 -6
- package/src/db/Db.ts +33 -19
- package/src/db/tables/sponsoredTransactions.ts +9 -2
- package/src/db/tables/webhookSubscriptions.ts +116 -4
- package/src/internal/EarnVaults.test.ts +2 -2
- package/src/internal/MetricSink.test.ts +518 -0
- package/src/internal/MetricSink.ts +266 -9
- package/src/internal/Schema.ts +1 -1
- package/src/internal/Tidx.test.ts +4 -4
- package/src/internal/Tidx.ts +1 -3
- package/src/internal/Viem.test.ts +8 -0
- package/src/internal/Viem.ts +2 -1
- package/src/internal/WebhookDestination.test.ts +20 -0
- package/src/internal/WebhookDestination.ts +10 -0
- package/src/internal/WebhookTransfer.ts +426 -0
- package/src/internal/Webhooks.test.ts +137 -0
- package/src/internal/Webhooks.ts +131 -17
- package/src/internal/funding/Provider.test.ts +47 -7
- package/src/internal/funding/Provider.ts +12 -7
- package/src/internal/funding/Reconciliation.local.test.ts +3 -1
- package/src/internal/funding/Reconciliation.test.ts +1240 -0
- package/src/internal/funding/Reconciliation.ts +136 -0
- package/src/internal/funding/SourceObservation.test.ts +1 -0
- package/src/internal/funding/providers/relay.test.ts +1 -4
- package/src/internal/funding/providers/relay.ts +16 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/** @module-tag localnet */
|
|
2
|
+
/**
|
|
3
|
+
* Measures retained database-factory memory and dense transfer-scan allocations.
|
|
4
|
+
* Set `WEBHOOK_MEMORY_SNAPSHOT_DIR` to write V8 heap snapshots for retained objects.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs'
|
|
7
|
+
import * as path from 'node:path'
|
|
8
|
+
import * as v8 from 'node:v8'
|
|
9
|
+
import { nanoid } from 'nanoid'
|
|
10
|
+
import { sendTransactionSync } from 'viem/actions'
|
|
11
|
+
import { Actions, Addresses } from 'viem/tempo'
|
|
12
|
+
import { Webhooks } from 'tapimo'
|
|
13
|
+
import { bench } from 'vite-plus/test'
|
|
14
|
+
|
|
15
|
+
import * as Db from './db/Db.js'
|
|
16
|
+
import * as Cursor from './internal/Cursor.js'
|
|
17
|
+
import * as Runtime from '../test/runtime.js'
|
|
18
|
+
import * as TestApp from '../test/App.js'
|
|
19
|
+
import * as Tempo from '../test/Tempo.js'
|
|
20
|
+
import * as TestViem from '../test/Viem.js'
|
|
21
|
+
|
|
22
|
+
const batchSize = 200
|
|
23
|
+
const endOfBlockIndex = 2_147_483_647
|
|
24
|
+
const factoryTicks = positiveInteger('WEBHOOK_MEMORY_FACTORY_TICKS', 25)
|
|
25
|
+
const mebibyte = 1024 * 1024
|
|
26
|
+
const runtime = Runtime.get()
|
|
27
|
+
const transferCallsPerTransaction = 32
|
|
28
|
+
const transferCount = positiveInteger('WEBHOOK_MEMORY_TRANSFER_COUNT', 3_600)
|
|
29
|
+
|
|
30
|
+
type Memory = {
|
|
31
|
+
arrayBuffers: number
|
|
32
|
+
external: number
|
|
33
|
+
heapUsed: number
|
|
34
|
+
rss: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type Report = {
|
|
38
|
+
after: Memory
|
|
39
|
+
afterOperation: Memory
|
|
40
|
+
afterOperationDelta: Memory
|
|
41
|
+
before: Memory
|
|
42
|
+
delta: Memory
|
|
43
|
+
details?: Record<string, number> | undefined
|
|
44
|
+
label: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const reports: Report[] = []
|
|
48
|
+
|
|
49
|
+
beforeAll(() => {
|
|
50
|
+
if (runtime.mode !== 'localnet')
|
|
51
|
+
throw new Error('Run webhook memory benchmarks with TEMPO_ENV=localnet.')
|
|
52
|
+
if (!globalThis.gc)
|
|
53
|
+
throw new Error('Run webhook memory benchmarks with NODE_OPTIONS=--expose-gc.')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
afterAll(() => {
|
|
57
|
+
process.stdout.write(`WEBHOOK_MEMORY_REPORT ${JSON.stringify(reports)}\n`)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('webhook poller memory', () => {
|
|
61
|
+
bench(
|
|
62
|
+
`factory-created databases / ${factoryTicks} ticks`,
|
|
63
|
+
async () => {
|
|
64
|
+
reports.push(await measureFactoryRetention())
|
|
65
|
+
},
|
|
66
|
+
{ iterations: 1, time: 0, warmupIterations: 0, warmupTime: 0 },
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
bench(
|
|
70
|
+
`transfer firehose / ${transferCount} generated transfers`,
|
|
71
|
+
async () => {
|
|
72
|
+
reports.push(await measureTransferFirehose())
|
|
73
|
+
},
|
|
74
|
+
{ iterations: 1, time: 0, warmupIterations: 0, warmupTime: 0 },
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
async function measureFactoryRetention(): Promise<Report> {
|
|
79
|
+
const databases: Db.Db[] = []
|
|
80
|
+
const schema = `memory_${nanoid()}`
|
|
81
|
+
const database = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
|
|
82
|
+
await database.migrate()
|
|
83
|
+
await Webhooks.createSubscription(database, {
|
|
84
|
+
chainId: runtime.chainId,
|
|
85
|
+
destination: { type: 'url', url: 'https://hooks.example.com/bench/memory/factory' },
|
|
86
|
+
eventType: 'block:created',
|
|
87
|
+
owner: { orgId: 'org_benchmark', type: 'api_key' },
|
|
88
|
+
})
|
|
89
|
+
const headBlockNumber = Number(await TestViem.getClient().getBlockNumber())
|
|
90
|
+
const poller = Webhooks.createPoller({
|
|
91
|
+
db: () => {
|
|
92
|
+
const database = Db.postgres({ connectionString: Runtime.postgresUrl, schema })
|
|
93
|
+
databases.push(database)
|
|
94
|
+
return database
|
|
95
|
+
},
|
|
96
|
+
defaultChainId: runtime.chainId,
|
|
97
|
+
dispatchBatch: async () => ({ ok: true }),
|
|
98
|
+
rpc: TestApp.rpc,
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const report = await measure('factory-retained', async () => {
|
|
103
|
+
for (let index = 0; index < factoryTicks; index++)
|
|
104
|
+
await poller.tick({
|
|
105
|
+
confirmations: 0,
|
|
106
|
+
eventTypes: ['block:created'],
|
|
107
|
+
headBlockNumber,
|
|
108
|
+
maxBlocks: 1,
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
writeHeapSnapshot('factory-retained')
|
|
112
|
+
return { ...report, details: { databases: databases.length, ticks: factoryTicks } }
|
|
113
|
+
} finally {
|
|
114
|
+
await Promise.allSettled(databases.map((database) => database.close()))
|
|
115
|
+
await database.close()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function measureTransferFirehose(): Promise<Report> {
|
|
120
|
+
const database = TestApp.database()
|
|
121
|
+
const client = Tempo.getClient({ account: Tempo.accounts[0] })
|
|
122
|
+
const startBlockNumber = Number(await TestViem.getClient().getBlockNumber())
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
for (let offset = 0; offset < transferCount; offset += transferCallsPerTransaction) {
|
|
126
|
+
const length = Math.min(transferCallsPerTransaction, transferCount - offset)
|
|
127
|
+
await sendTransactionSync(client, {
|
|
128
|
+
account: Tempo.accounts[0],
|
|
129
|
+
calls: Array.from({ length }, () =>
|
|
130
|
+
Actions.token.transfer.call(client, {
|
|
131
|
+
amount: 1n,
|
|
132
|
+
to: Tempo.accounts[1].address,
|
|
133
|
+
token: Addresses.pathUsd,
|
|
134
|
+
}),
|
|
135
|
+
),
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const headBlockNumber = Number(await TestViem.getClient().getBlockNumber())
|
|
140
|
+
const subscription = await Webhooks.createSubscription(database, {
|
|
141
|
+
chainId: runtime.chainId,
|
|
142
|
+
destination: { type: 'url', url: 'https://hooks.example.com/bench/memory/firehose' },
|
|
143
|
+
eventType: 'token:transfer',
|
|
144
|
+
filters: {},
|
|
145
|
+
owner: { orgId: 'org_benchmark', type: 'api_key' },
|
|
146
|
+
})
|
|
147
|
+
await Webhooks.setCursor(
|
|
148
|
+
database,
|
|
149
|
+
subscription.id,
|
|
150
|
+
Cursor.encode([startBlockNumber, endOfBlockIndex]),
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
let dispatched = 0
|
|
154
|
+
let serializedBytes = 0
|
|
155
|
+
const poller = Webhooks.createPoller({
|
|
156
|
+
db: database,
|
|
157
|
+
defaultChainId: runtime.chainId,
|
|
158
|
+
dispatchBatch: async (dispatchables) => {
|
|
159
|
+
dispatched += dispatchables.length
|
|
160
|
+
for (const body of dispatchables)
|
|
161
|
+
serializedBytes += new TextEncoder().encode(JSON.stringify({ body })).byteLength
|
|
162
|
+
return { ok: true }
|
|
163
|
+
},
|
|
164
|
+
rpc: TestApp.rpc,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const report = await measure('transfer-firehose', async () => {
|
|
168
|
+
const result = await poller.tick({
|
|
169
|
+
batchSize,
|
|
170
|
+
confirmations: 0,
|
|
171
|
+
eventTypes: ['token:transfer'],
|
|
172
|
+
headBlockNumber,
|
|
173
|
+
maxBlocks: 500,
|
|
174
|
+
})
|
|
175
|
+
if (result.dispatched === 0 || result.dispatched !== dispatched)
|
|
176
|
+
throw new Error(`Unexpected dispatch count: ${result.dispatched} / ${dispatched}.`)
|
|
177
|
+
if (result.dispatched > batchSize)
|
|
178
|
+
throw new Error(`Dispatch count ${result.dispatched} exceeded batch size ${batchSize}.`)
|
|
179
|
+
if (serializedBytes === 0) throw new Error('Expected serialized webhook queue messages.')
|
|
180
|
+
})
|
|
181
|
+
writeHeapSnapshot('transfer-firehose-retained')
|
|
182
|
+
return {
|
|
183
|
+
...report,
|
|
184
|
+
details: { dispatched, serializedBytes, transferCount },
|
|
185
|
+
}
|
|
186
|
+
} finally {
|
|
187
|
+
await database.close()
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function measure(label: string, fn: () => Promise<void>): Promise<Report> {
|
|
192
|
+
collectGarbage()
|
|
193
|
+
const before = memoryUsage()
|
|
194
|
+
await fn()
|
|
195
|
+
const afterOperation = memoryUsage()
|
|
196
|
+
collectGarbage()
|
|
197
|
+
const after = memoryUsage()
|
|
198
|
+
return {
|
|
199
|
+
after,
|
|
200
|
+
afterOperation,
|
|
201
|
+
afterOperationDelta: subtract(afterOperation, before),
|
|
202
|
+
before,
|
|
203
|
+
delta: subtract(after, before),
|
|
204
|
+
label,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectGarbage(): void {
|
|
209
|
+
globalThis.gc?.()
|
|
210
|
+
globalThis.gc?.()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function memoryUsage(): Memory {
|
|
214
|
+
const usage = process.memoryUsage()
|
|
215
|
+
return {
|
|
216
|
+
arrayBuffers: mebibytes(usage.arrayBuffers),
|
|
217
|
+
external: mebibytes(usage.external),
|
|
218
|
+
heapUsed: mebibytes(usage.heapUsed),
|
|
219
|
+
rss: mebibytes(usage.rss),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function mebibytes(bytes: number): number {
|
|
224
|
+
return Math.round((bytes / mebibyte) * 100) / 100
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function positiveInteger(name: string, fallback: number): number {
|
|
228
|
+
const input = process.env[name]
|
|
229
|
+
if (!input) return fallback
|
|
230
|
+
const value = Number(input)
|
|
231
|
+
if (!Number.isSafeInteger(value) || value < 1)
|
|
232
|
+
throw new Error(`${name} must be a positive integer.`)
|
|
233
|
+
return value
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function subtract(after: Memory, before: Memory): Memory {
|
|
237
|
+
return {
|
|
238
|
+
arrayBuffers: after.arrayBuffers - before.arrayBuffers,
|
|
239
|
+
external: after.external - before.external,
|
|
240
|
+
heapUsed: after.heapUsed - before.heapUsed,
|
|
241
|
+
rss: after.rss - before.rss,
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function writeHeapSnapshot(label: string): void {
|
|
246
|
+
const directory = process.env.WEBHOOK_MEMORY_SNAPSHOT_DIR
|
|
247
|
+
if (!directory) return
|
|
248
|
+
fs.mkdirSync(directory, { recursive: true })
|
|
249
|
+
v8.writeHeapSnapshot(path.join(directory, `${label}.heapsnapshot`))
|
|
250
|
+
}
|
package/src/Webhooks.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Webhooks } from 'tapimo'
|
|
1
|
+
import { Metrics, Webhooks } from 'tapimo'
|
|
2
2
|
import * as TestApp from '../test/App.js'
|
|
3
3
|
|
|
4
4
|
describe('createPoller', () => {
|
|
@@ -25,9 +25,8 @@ describe('createPoller', () => {
|
|
|
25
25
|
|
|
26
26
|
// A brand-new subscription anchors at the confirmed head on its first tick
|
|
27
27
|
// (no backfill), so this exercises the createPoller → poll wiring (db
|
|
28
|
-
// binding, default chain id, injected head, result shape) without
|
|
29
|
-
//
|
|
30
|
-
// src/apps/webhooks.test.ts.
|
|
28
|
+
// binding, default chain id, injected head, and result shape) without an
|
|
29
|
+
// upstream request. Detailed scanner behavior lives in the route test.
|
|
31
30
|
const result = await poller.tick({ headBlockNumber: 200 })
|
|
32
31
|
|
|
33
32
|
expect(result).toMatchInlineSnapshot(`
|
|
@@ -35,19 +34,70 @@ describe('createPoller', () => {
|
|
|
35
34
|
"chainId": 42431,
|
|
36
35
|
"dispatched": 0,
|
|
37
36
|
"failed": 0,
|
|
38
|
-
"safeBlock":
|
|
37
|
+
"safeBlock": 199,
|
|
39
38
|
"scanned": 0,
|
|
40
39
|
"subscriptions": 1,
|
|
41
40
|
}
|
|
42
41
|
`)
|
|
43
42
|
expect(decodeCursor(await Webhooks.getCursor(db, subscription.id))).toMatchInlineSnapshot(`
|
|
44
43
|
[
|
|
45
|
-
|
|
44
|
+
199,
|
|
46
45
|
2147483647,
|
|
47
46
|
]
|
|
48
47
|
`)
|
|
49
48
|
})
|
|
50
49
|
|
|
50
|
+
test('records active subscriptions before scanning', async () => {
|
|
51
|
+
const db = TestApp.database()
|
|
52
|
+
await Webhooks.createSubscription(db, {
|
|
53
|
+
chainId: 42431,
|
|
54
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
55
|
+
eventType: 'block:created',
|
|
56
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
57
|
+
})
|
|
58
|
+
const gauges: { name: string; tags: Metrics.Tags; value: number }[] = []
|
|
59
|
+
const poller = Webhooks.createPoller({
|
|
60
|
+
db,
|
|
61
|
+
defaultChainId: 42431,
|
|
62
|
+
metrics: Metrics.from({
|
|
63
|
+
count() {},
|
|
64
|
+
flush() {},
|
|
65
|
+
gauge(name, value, tags = {}) {
|
|
66
|
+
gauges.push({ name, tags, value })
|
|
67
|
+
},
|
|
68
|
+
histogram() {},
|
|
69
|
+
}),
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await poller.tick({ eventTypes: ['block:created'], headBlockNumber: 200 })
|
|
73
|
+
|
|
74
|
+
expect(gauges).toMatchInlineSnapshot(`
|
|
75
|
+
[
|
|
76
|
+
{
|
|
77
|
+
"name": "webhook_poller_active_subscription_count",
|
|
78
|
+
"tags": {
|
|
79
|
+
"chain_id": "42431",
|
|
80
|
+
},
|
|
81
|
+
"value": 1,
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "webhook_subscription_cursor_lag_blocks",
|
|
85
|
+
"tags": {
|
|
86
|
+
"chain_id": "42431",
|
|
87
|
+
},
|
|
88
|
+
"value": 0,
|
|
89
|
+
},
|
|
90
|
+
{
|
|
91
|
+
"name": "webhook_poller_safe_block",
|
|
92
|
+
"tags": {
|
|
93
|
+
"chain_id": "42431",
|
|
94
|
+
},
|
|
95
|
+
"value": 199,
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
`)
|
|
99
|
+
})
|
|
100
|
+
|
|
51
101
|
test('clamps a creation checkpoint to genesis', async () => {
|
|
52
102
|
const db = TestApp.database()
|
|
53
103
|
const subscription = await Webhooks.createSubscription(
|
|
@@ -72,4 +122,25 @@ describe('createPoller', () => {
|
|
|
72
122
|
|
|
73
123
|
expect(decodeCursor(await Webhooks.getCursor(db, subscription.id))).toEqual([0, 2_147_483_647])
|
|
74
124
|
})
|
|
125
|
+
|
|
126
|
+
test('keeps a factory-created database open after each tick', async () => {
|
|
127
|
+
const db = TestApp.database()
|
|
128
|
+
await Webhooks.createSubscription(db, {
|
|
129
|
+
chainId: 42431,
|
|
130
|
+
destination: { type: 'url', url: 'https://hooks.example.com/x' },
|
|
131
|
+
eventType: 'block:created',
|
|
132
|
+
owner: { orgId: 'org_test', type: 'api_key' },
|
|
133
|
+
})
|
|
134
|
+
const poller = Webhooks.createPoller({
|
|
135
|
+
db: () => db,
|
|
136
|
+
defaultChainId: 42431,
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
await poller.tick({
|
|
140
|
+
eventTypes: ['block:created'],
|
|
141
|
+
headBlockNumber: 1,
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
await expect(Webhooks.maxCursorBlock(db, 42431)).resolves.toBe(0)
|
|
145
|
+
})
|
|
75
146
|
})
|
package/src/Webhooks.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import * as AppWebhooks from './apps/data/routes/webhooks.js'
|
|
2
2
|
import * as Db from './db/Db.js'
|
|
3
|
+
import * as core_Webhooks from './internal/Webhooks.js'
|
|
3
4
|
import * as MetricSink from './internal/MetricSink.js'
|
|
4
5
|
import * as Store from './internal/Store.js'
|
|
5
6
|
import type * as Metrics from './Metrics.js'
|
|
6
|
-
import * as Tidx from './internal/Tidx.js'
|
|
7
7
|
import * as Viem from './internal/Viem.js'
|
|
8
8
|
|
|
9
|
+
/** How often a poller reclaims expired rows, independent of tick cadence. */
|
|
10
|
+
const pruneIntervalMs = 60_000
|
|
11
|
+
|
|
9
12
|
// Re-export the runtime-agnostic primitives (subscriptions, signing, delivery,
|
|
10
13
|
// `deliverAndRecord`, cursors, …) so consumers reach everything through one
|
|
11
14
|
// `Webhooks` namespace. The apps-layer poller stays private behind
|
|
@@ -19,33 +22,29 @@ export * from './internal/Webhooks.js'
|
|
|
19
22
|
export { eventTypes, schema } from './apps/data/routes/webhooks.js'
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
|
-
* Builds a
|
|
25
|
+
* Builds a webhook poller bound to a state store and chain config.
|
|
23
26
|
*
|
|
24
|
-
* This is the entry point a host runtime drives
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* confirmations` and dispatches matching events.
|
|
27
|
+
* This is the entry point a host runtime drives from chain-head notifications
|
|
28
|
+
* or a timer. Each {@link Poller.tick} scans active subscriptions from their
|
|
29
|
+
* cursors up to `head - confirmations` and dispatches matching events.
|
|
28
30
|
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* poller
|
|
32
|
-
*
|
|
33
|
-
* also builds memoized TIDX clients (via {@link Tidx.createGetClient}) when a
|
|
34
|
-
* `tidx` config is supplied. The poller keeps `App.create` a pure Hono app: it
|
|
35
|
-
* constructs its own clients instead of reaching into app internals, and owns no
|
|
36
|
-
* request state.
|
|
31
|
+
* Every onchain event type scans a confirmed window directly over JSON-RPC, so
|
|
32
|
+
* the poller builds memoized viem clients via {@link Viem.createGetClient}. The
|
|
33
|
+
* poller keeps `App.create` a pure Hono app: it constructs its own clients
|
|
34
|
+
* instead of reaching into app internals, and owns no request state.
|
|
37
35
|
*/
|
|
38
36
|
export function createPoller(options: createPoller.Options): createPoller.Poller {
|
|
39
37
|
// Persistent memoize cache for token metadata, shared across ticks (the
|
|
40
38
|
// webhook state store used to double-duty as this cache).
|
|
41
|
-
const cache = Store.memory()
|
|
42
|
-
const getTidx = options.tidx
|
|
43
|
-
? Tidx.createGetClient({ defaultChainId: options.defaultChainId, tidx: options.tidx })
|
|
44
|
-
: undefined
|
|
39
|
+
const cache = options.cache ?? Store.memory()
|
|
45
40
|
const getClient = Viem.createGetClient({
|
|
46
41
|
defaultChainId: options.defaultChainId,
|
|
47
42
|
rpc: options.rpc,
|
|
48
43
|
})
|
|
44
|
+
// Chain-head ticks run about once per block, so the janitor is throttled here
|
|
45
|
+
// rather than issuing deletes per block. Reads filter expiry at query time, so
|
|
46
|
+
// reclaiming late only costs space.
|
|
47
|
+
let prunedAt: number | undefined
|
|
49
48
|
|
|
50
49
|
const metrics = options.metrics ? MetricSink.webhooks(options.metrics) : undefined
|
|
51
50
|
|
|
@@ -58,6 +57,8 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
|
|
|
58
57
|
// returns the underlying result unchanged and is a no-op when no metrics
|
|
59
58
|
// backend is configured.
|
|
60
59
|
const inner = input.dispatch ?? options.dispatch
|
|
60
|
+
const innerBatch =
|
|
61
|
+
input.dispatchBatch ?? (input.dispatch === undefined ? options.dispatchBatch : undefined)
|
|
61
62
|
const dispatch =
|
|
62
63
|
metrics && inner
|
|
63
64
|
? async (dispatchable: AppWebhooks.poll.Dispatchable) => {
|
|
@@ -73,8 +74,35 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
|
|
|
73
74
|
return result
|
|
74
75
|
}
|
|
75
76
|
: inner
|
|
77
|
+
const dispatchBatch =
|
|
78
|
+
metrics && innerBatch
|
|
79
|
+
? async (dispatchables: readonly AppWebhooks.poll.Dispatchable[]) => {
|
|
80
|
+
const start = Date.now()
|
|
81
|
+
const result = await innerBatch(dispatchables)
|
|
82
|
+
const durationMs = Date.now() - start
|
|
83
|
+
for (const dispatchable of dispatchables)
|
|
84
|
+
metrics.record({
|
|
85
|
+
chainId: dispatchable.envelope.chainId,
|
|
86
|
+
durationMs,
|
|
87
|
+
eventType: dispatchable.envelope.type,
|
|
88
|
+
ok: result.ok,
|
|
89
|
+
type: 'webhook:enqueue',
|
|
90
|
+
})
|
|
91
|
+
return result
|
|
92
|
+
}
|
|
93
|
+
: innerBatch
|
|
94
|
+
|
|
95
|
+
// Resolve per tick so Workers factories build a fresh Hyperdrive-pooled
|
|
96
|
+
// connection each run.
|
|
97
|
+
const db = Db.get(options.db)
|
|
98
|
+
const now = input.now ?? options.now
|
|
99
|
+
if (prunedAt === undefined || Date.now() - prunedAt >= pruneIntervalMs) {
|
|
100
|
+
prunedAt = Date.now()
|
|
101
|
+
await core_Webhooks.pruneExpired(db, { now }).catch(() => {})
|
|
102
|
+
}
|
|
76
103
|
|
|
77
104
|
const start = Date.now()
|
|
105
|
+
let cursorLagBlocks: number | undefined
|
|
78
106
|
try {
|
|
79
107
|
const result = await AppWebhooks.poll({
|
|
80
108
|
batchSize: input.batchSize ?? options.batchSize,
|
|
@@ -82,19 +110,37 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
|
|
|
82
110
|
chainId,
|
|
83
111
|
client: getClient(chainId),
|
|
84
112
|
confirmations: input.confirmations ?? options.confirmations,
|
|
85
|
-
|
|
86
|
-
// Hyperdrive-pooled connection each run.
|
|
87
|
-
db: Db.get(options.db),
|
|
113
|
+
db,
|
|
88
114
|
dispatch,
|
|
115
|
+
dispatchBatch,
|
|
89
116
|
eventTypes: input.eventTypes ?? options.eventTypes,
|
|
90
117
|
...(input.headBlockNumber === undefined
|
|
91
118
|
? {}
|
|
92
119
|
: { headBlockNumber: input.headBlockNumber }),
|
|
93
120
|
maxBlocks: input.maxBlocks ?? options.maxBlocks,
|
|
94
|
-
now
|
|
95
|
-
|
|
121
|
+
now,
|
|
122
|
+
signal: input.signal,
|
|
123
|
+
...(metrics
|
|
124
|
+
? {
|
|
125
|
+
onCursorLag(blocks: number) {
|
|
126
|
+
cursorLagBlocks = blocks
|
|
127
|
+
},
|
|
128
|
+
onScanCheckpoint(checkpoint: AppWebhooks.poll.ScanCheckpoint) {
|
|
129
|
+
metrics.record({ chainId, type: 'webhook:poll-scan', ...checkpoint })
|
|
130
|
+
// Ship the start boundary before the RPC allocation can terminate the isolate.
|
|
131
|
+
metrics.flush()
|
|
132
|
+
},
|
|
133
|
+
onStage(stage: AppWebhooks.poll.Stage) {
|
|
134
|
+
metrics.record({ chainId, type: 'webhook:poll', ...stage })
|
|
135
|
+
},
|
|
136
|
+
onSubscriptions(count: number) {
|
|
137
|
+
metrics.record({ chainId, count, type: 'webhook:poll-subscriptions' })
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
: {}),
|
|
96
141
|
})
|
|
97
142
|
metrics?.record({
|
|
143
|
+
cursorLagBlocks,
|
|
98
144
|
meta: { durationMs: Date.now() - start, outcome: 'ok' },
|
|
99
145
|
result,
|
|
100
146
|
type: 'webhook:tick',
|
|
@@ -102,6 +148,7 @@ export function createPoller(options: createPoller.Options): createPoller.Poller
|
|
|
102
148
|
return result
|
|
103
149
|
} catch (error) {
|
|
104
150
|
metrics?.record({
|
|
151
|
+
cursorLagBlocks,
|
|
105
152
|
meta: { chainId, durationMs: Date.now() - start, outcome: 'error' },
|
|
106
153
|
result: undefined,
|
|
107
154
|
type: 'webhook:tick',
|
|
@@ -121,6 +168,9 @@ export declare namespace createPoller {
|
|
|
121
168
|
/** Delivery transport: hands a matched event to a queue or delivers inline. */
|
|
122
169
|
type Dispatch = NonNullable<AppWebhooks.poll.Options['dispatch']>
|
|
123
170
|
|
|
171
|
+
/** Batch delivery transport: confirms all rows together or leaves their cursor unchanged. */
|
|
172
|
+
type DispatchBatch = NonNullable<AppWebhooks.poll.Options['dispatchBatch']>
|
|
173
|
+
|
|
124
174
|
/** Summary counters returned by {@link Poller.tick}. */
|
|
125
175
|
type Result = AppWebhooks.poll.Result
|
|
126
176
|
|
|
@@ -128,13 +178,15 @@ export declare namespace createPoller {
|
|
|
128
178
|
type Defaults = {
|
|
129
179
|
/** Max matching rows scanned per subscription per tick (default 200). */
|
|
130
180
|
batchSize?: number | undefined
|
|
131
|
-
/** Reorg-safety window: only emit blocks this far behind head (default
|
|
181
|
+
/** Reorg-safety window: only emit blocks this far behind head (default 1). */
|
|
132
182
|
confirmations?: number | undefined
|
|
133
183
|
/**
|
|
134
184
|
* Delivery transport. Defaults to inline `deliverAndRecord` (self-host); on
|
|
135
185
|
* Cloudflare pass a Queue producer so retry/backoff/DLQ are native.
|
|
136
186
|
*/
|
|
137
187
|
dispatch?: Dispatch | undefined
|
|
188
|
+
/** Batch delivery transport used when the runtime can enqueue multiple rows atomically. */
|
|
189
|
+
dispatchBatch?: DispatchBatch | undefined
|
|
138
190
|
/** Event types to poll (default all). */
|
|
139
191
|
eventTypes?: AppWebhooks.poll.Options['eventTypes']
|
|
140
192
|
/** Backfill cap: most blocks an RPC scanner inspects per tick (default 1000). */
|
|
@@ -145,16 +197,16 @@ export declare namespace createPoller {
|
|
|
145
197
|
|
|
146
198
|
/** Options for {@link createPoller}. */
|
|
147
199
|
type Options = Defaults & {
|
|
200
|
+
/** Cache for token metadata shared across ticks. */
|
|
201
|
+
cache?: Store.Store | undefined
|
|
148
202
|
/** Database holding subscriptions + cursors, or a factory resolved per tick (Workers/Hyperdrive). */
|
|
149
203
|
db: Db.Source
|
|
150
204
|
/** Tempo chain id polled when a tick omits one. */
|
|
151
205
|
defaultChainId?: Viem.ChainId | undefined
|
|
152
206
|
/** Metrics backend; emits `webhook:tick` per tick and `webhook:enqueue` per dispatch. */
|
|
153
207
|
metrics?: Metrics.Metrics | undefined
|
|
154
|
-
/** RPC options for the viem clients
|
|
208
|
+
/** RPC options for the viem clients that scan chain events. */
|
|
155
209
|
rpc?: Viem.getClient.Rpc | undefined
|
|
156
|
-
/** TIDX query client options for the legacy `token:transfer` type. */
|
|
157
|
-
tidx?: Tidx.getClient.Tidx | undefined
|
|
158
210
|
}
|
|
159
211
|
|
|
160
212
|
/** Per-tick overrides; falls back to the {@link Options} defaults. */
|
|
@@ -163,11 +215,13 @@ export declare namespace createPoller {
|
|
|
163
215
|
chainId?: Viem.ChainId | undefined
|
|
164
216
|
/** Chain head to work back from; resolved from RPC when omitted. */
|
|
165
217
|
headBlockNumber?: bigint | number | undefined
|
|
218
|
+
/** Stops scanning and dispatch when the caller loses its execution lease. */
|
|
219
|
+
signal?: AbortSignal | undefined
|
|
166
220
|
}
|
|
167
221
|
|
|
168
|
-
/** A
|
|
222
|
+
/** A chain-head-driven webhook poller. */
|
|
169
223
|
type Poller = {
|
|
170
|
-
/** Runs one poll tick, resolving the
|
|
224
|
+
/** Runs one poll tick, resolving the RPC head when not supplied. */
|
|
171
225
|
tick(options?: TickOptions): Promise<Result>
|
|
172
226
|
}
|
|
173
227
|
}
|
package/src/apps/Zones.test.ts
CHANGED
|
@@ -5,13 +5,14 @@ describe('chains', () => {
|
|
|
5
5
|
expect(Zones.chains).toMatchObject([
|
|
6
6
|
{
|
|
7
7
|
contracts: {
|
|
8
|
-
messenger: { address: '
|
|
9
|
-
portal: { address: '
|
|
8
|
+
messenger: { address: '0xc9C88278eA934A03CE8c90793F0B903e1C63B735' },
|
|
9
|
+
portal: { address: '0xd8fDA2e3f421F272C56752cBCf5Ae0782c8B9ed2' },
|
|
10
10
|
},
|
|
11
11
|
id: 421_700_001,
|
|
12
12
|
rpcUrls: {
|
|
13
|
-
default: { http: ['https://
|
|
14
|
-
internal: { http: ['https://
|
|
13
|
+
default: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
|
|
14
|
+
internal: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
|
|
15
|
+
tidx: { http: ['https://indexer-zone-internal.testnet.tempo.xyz'] },
|
|
15
16
|
},
|
|
16
17
|
sourceId: 42_431,
|
|
17
18
|
},
|
package/src/apps/Zones.ts
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
import { defineChain } from 'viem'
|
|
2
2
|
import { tempoDevnet, tempoTestnet } from 'viem/tempo/chains'
|
|
3
3
|
|
|
4
|
-
/** Zone
|
|
5
|
-
export const
|
|
4
|
+
/** Moderato Zone on Tempo testnet. */
|
|
5
|
+
export const zoneModerato = defineChain({
|
|
6
6
|
...tempoTestnet,
|
|
7
7
|
contracts: {
|
|
8
8
|
...tempoTestnet.contracts,
|
|
9
|
-
messenger: { address: '
|
|
10
|
-
portal: { address: '
|
|
9
|
+
messenger: { address: '0xc9C88278eA934A03CE8c90793F0B903e1C63B735' },
|
|
10
|
+
portal: { address: '0xd8fDA2e3f421F272C56752cBCf5Ae0782c8B9ed2' },
|
|
11
11
|
},
|
|
12
12
|
id: 421_700_001,
|
|
13
|
-
name: 'zone-
|
|
13
|
+
name: 'zone-moderato',
|
|
14
14
|
rpcUrls: {
|
|
15
|
-
default: { http: ['https://
|
|
16
|
-
internal: { http: ['https://
|
|
15
|
+
default: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
|
|
16
|
+
internal: { http: ['https://zone-internal-rpc.testnet.tempo.xyz'] },
|
|
17
|
+
tidx: { http: ['https://indexer-zone-internal.testnet.tempo.xyz'] },
|
|
17
18
|
},
|
|
18
19
|
sourceId: tempoTestnet.id,
|
|
19
20
|
})
|
|
@@ -37,4 +38,4 @@ export const zoneStable = defineChain({
|
|
|
37
38
|
})
|
|
38
39
|
|
|
39
40
|
/** Hosted Zone chains served by the API and admin. */
|
|
40
|
-
export const chains = [
|
|
41
|
+
export const chains = [zoneModerato, zoneStable] as const
|
|
@@ -41,6 +41,24 @@ describe('from', () => {
|
|
|
41
41
|
})
|
|
42
42
|
|
|
43
43
|
describe('ecb', () => {
|
|
44
|
+
test('caches the daily feed for one day', () => {
|
|
45
|
+
expect(FxOracle.ecb({ url: feedUrl('1.25') }).ttl).toMatchInlineSnapshot(`86400000`)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('rotates the cache key after the daily publication window', () => {
|
|
49
|
+
vi.useFakeTimers()
|
|
50
|
+
try {
|
|
51
|
+
const oracle = FxOracle.ecb({ url: feedUrl('1.25') })
|
|
52
|
+
|
|
53
|
+
vi.setSystemTime('2026-01-02T15:59:00.000Z')
|
|
54
|
+
expect(oracle.cacheKey?.()).toMatchInlineSnapshot(`"ecb:2026-01-01"`)
|
|
55
|
+
vi.setSystemTime('2026-01-02T16:00:00.000Z')
|
|
56
|
+
expect(oracle.cacheKey?.()).toMatchInlineSnapshot(`"ecb:2026-01-02"`)
|
|
57
|
+
} finally {
|
|
58
|
+
vi.useRealTimers()
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
44
62
|
test('fetches and parses the daily reference rates', async () => {
|
|
45
63
|
const rates = await FxOracle.ecb({ url: feedUrl('1.25') }).rates()
|
|
46
64
|
expect(rates).toMatchInlineSnapshot(`
|