tapimo 0.10.0 → 0.10.1
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/ApiKey.d.ts +2 -1
- package/dist/ApiKey.d.ts.map +1 -1
- package/dist/ApiKeys.d.ts +4 -2
- package/dist/ApiKeys.d.ts.map +1 -1
- package/dist/ApiKeys.js +26 -1
- package/dist/ApiKeys.js.map +1 -1
- package/dist/App.d.ts +3346 -2575
- package/dist/App.d.ts.map +1 -1
- package/dist/App.js +1 -0
- package/dist/App.js.map +1 -1
- package/dist/Webhooks.d.ts.map +1 -1
- package/dist/Webhooks.js +5 -5
- package/dist/Webhooks.js.map +1 -1
- package/dist/admin/App.d.ts +91 -84
- package/dist/admin/App.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.d.ts +26 -17
- package/dist/admin/apps/api-keys.d.ts.map +1 -1
- package/dist/admin/apps/api-keys.js +6 -0
- package/dist/admin/apps/api-keys.js.map +1 -1
- package/dist/admin/apps/organizations.d.ts +4 -2
- package/dist/admin/apps/organizations.d.ts.map +1 -1
- package/dist/admin/ui.generated.d.ts +1 -1
- package/dist/admin/ui.generated.js +69 -69
- package/dist/admin/ui.generated.js.map +1 -1
- package/dist/analytics/Analytics.d.ts.map +1 -1
- package/dist/analytics/Analytics.js +26 -0
- package/dist/analytics/Analytics.js.map +1 -1
- package/dist/apps/Zones.d.ts +4266 -82
- package/dist/apps/Zones.d.ts.map +1 -1
- package/dist/apps/Zones.js +27 -8
- package/dist/apps/Zones.js.map +1 -1
- package/dist/apps/data/App.d.ts +3467 -2696
- package/dist/apps/data/App.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.d.ts +2999 -100
- package/dist/apps/data/routes/activities.d.ts.map +1 -1
- package/dist/apps/data/routes/activities.js +340 -2
- package/dist/apps/data/routes/activities.js.map +1 -1
- package/dist/apps/data/routes/addresses.d.ts +850 -336
- package/dist/apps/data/routes/addresses.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.d.ts +26 -26
- package/dist/apps/data/routes/balances.d.ts.map +1 -1
- package/dist/apps/data/routes/balances.js +11 -3
- package/dist/apps/data/routes/balances.js.map +1 -1
- package/dist/apps/data/routes/blocks.d.ts +4 -4
- package/dist/apps/data/routes/coingecko.d.ts +176 -176
- package/dist/apps/data/routes/earn.d.ts +75 -75
- package/dist/apps/data/routes/earn.js +2 -2
- 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/fee-amm.d.ts +24 -24
- package/dist/apps/data/routes/indexer.d.ts +35 -34
- package/dist/apps/data/routes/indexer.d.ts.map +1 -1
- package/dist/apps/data/routes/indexer.js +3 -2
- package/dist/apps/data/routes/indexer.js.map +1 -1
- package/dist/apps/data/routes/rpc.d.ts.map +1 -1
- package/dist/apps/data/routes/rpc.js +5 -1
- package/dist/apps/data/routes/rpc.js.map +1 -1
- package/dist/apps/data/routes/tokenlist.d.ts +12 -12
- package/dist/apps/data/routes/transactions.d.ts +281 -281
- package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
- package/dist/apps/data/routes/webhooks.d.ts +418 -364
- package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
- package/dist/apps/data/routes/webhooks.js +161 -14
- package/dist/apps/data/routes/webhooks.js.map +1 -1
- package/dist/apps/data/routes/zones.d.ts +31 -31
- package/dist/apps/data/routes/zones.js +1 -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/deposit-addresses.js +1 -1
- package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
- package/dist/apps/funding/routes/providers.d.ts +13 -13
- package/dist/apps/funding/routes/transfers.d.ts +32 -32
- package/dist/apps/funding/routes/transfers.js +1 -1
- package/dist/apps/funding/routes/transfers.js.map +1 -1
- package/dist/apps/management/App.d.ts +418 -418
- package/dist/apps/management/routes/api-keys.d.ts +50 -50
- package/dist/apps/management/routes/billing.d.ts +72 -72
- package/dist/apps/management/routes/billing.js +1 -1
- package/dist/apps/management/routes/billing.js.map +1 -1
- package/dist/apps/management/routes/faucet.d.ts +37 -37
- package/dist/apps/management/routes/faucet.js +2 -2
- package/dist/apps/management/routes/faucet.js.map +1 -1
- package/dist/apps/management/routes/invite-links.d.ts +42 -42
- package/dist/apps/management/routes/me.d.ts +6 -6
- package/dist/apps/management/routes/members.d.ts +75 -75
- package/dist/apps/management/routes/projects.d.ts +40 -40
- package/dist/apps/management/routes/usage.d.ts +76 -76
- package/dist/apps/management/routes/webhooks.d.ts +49 -49
- package/dist/apps/mcp.d.ts.map +1 -1
- package/dist/apps/mcp.js +4 -1
- package/dist/apps/mcp.js.map +1 -1
- package/dist/db/Db.d.ts +2 -0
- package/dist/db/Db.d.ts.map +1 -1
- package/dist/db/Db.js +58 -0
- package/dist/db/Db.js.map +1 -1
- package/dist/db/Schema.d.ts +19 -0
- package/dist/db/Schema.d.ts.map +1 -1
- package/dist/db/Schema.js +24 -0
- package/dist/db/Schema.js.map +1 -1
- package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
- package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
- package/dist/db/tables/webhookQueueEvents.js +160 -0
- package/dist/db/tables/webhookQueueEvents.js.map +1 -0
- package/dist/db/tables/webhookSubscriptions.d.ts +2 -0
- package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
- package/dist/db/tables/webhookSubscriptions.js +10 -0
- package/dist/db/tables/webhookSubscriptions.js.map +1 -1
- package/dist/internal/Auth.d.ts +3 -0
- package/dist/internal/Auth.d.ts.map +1 -1
- package/dist/internal/Auth.js +6 -4
- package/dist/internal/Auth.js.map +1 -1
- package/dist/internal/MetricSink.d.ts +27 -0
- package/dist/internal/MetricSink.d.ts.map +1 -1
- package/dist/internal/MetricSink.js +29 -2
- package/dist/internal/MetricSink.js.map +1 -1
- package/dist/internal/RateLimit.d.ts +4 -6
- package/dist/internal/RateLimit.d.ts.map +1 -1
- package/dist/internal/RateLimit.js +15 -14
- package/dist/internal/RateLimit.js.map +1 -1
- package/dist/internal/Store.d.ts +2 -1
- package/dist/internal/Store.d.ts.map +1 -1
- package/dist/internal/Store.js +57 -18
- package/dist/internal/Store.js.map +1 -1
- package/dist/internal/Value.d.ts +2 -0
- package/dist/internal/Value.d.ts.map +1 -1
- package/dist/internal/Value.js +25 -0
- package/dist/internal/Value.js.map +1 -1
- package/dist/internal/WebhookTransfer.d.ts +20 -0
- package/dist/internal/WebhookTransfer.d.ts.map +1 -1
- package/dist/internal/WebhookTransfer.js +7 -4
- package/dist/internal/WebhookTransfer.js.map +1 -1
- package/dist/internal/Webhooks.d.ts +139 -0
- package/dist/internal/Webhooks.d.ts.map +1 -1
- package/dist/internal/Webhooks.js +129 -7
- package/dist/internal/Webhooks.js.map +1 -1
- package/dist/internal/funding/providers/relay.d.ts +3 -4
- package/dist/internal/funding/providers/relay.d.ts.map +1 -1
- package/dist/internal/funding/providers/relay.js +59 -8
- package/dist/internal/funding/providers/relay.js.map +1 -1
- package/package.json +3 -3
- package/src/ApiKeys.test.ts +20 -0
- package/src/ApiKeys.ts +28 -1
- package/src/App.test.ts +10 -8
- package/src/App.ts +1 -0
- package/src/Client.test-d.ts +1 -0
- package/src/Webhooks.ts +19 -17
- package/src/admin/apps/api-keys.test.ts +9 -0
- package/src/admin/apps/api-keys.ts +15 -0
- package/src/admin/ui/src/lib/query-client.ts +3 -2
- package/src/admin/ui/src/routes/api-keys.tsx +31 -9
- package/src/admin/ui.generated.ts +69 -69
- package/src/analytics/Analytics.test.ts +8 -0
- package/src/analytics/Analytics.ts +26 -0
- package/src/apps/Zones.test.ts +20 -3
- package/src/apps/Zones.ts +28 -8
- package/src/apps/composition.test.ts +1 -0
- package/src/apps/data/App.test.ts +2 -2
- package/src/apps/data/routes/activities.bench.ts +41 -0
- package/src/apps/data/routes/activities.test.ts +103 -2
- package/src/apps/data/routes/activities.ts +401 -2
- package/src/apps/data/routes/balances.bench.ts +21 -0
- package/src/apps/data/routes/balances.ts +11 -3
- package/src/apps/data/routes/blocks.bench.ts +34 -0
- package/src/apps/data/routes/coingecko.bench.ts +53 -0
- package/src/apps/data/routes/earn.bench.ts +71 -0
- package/src/apps/data/routes/earn.ts +2 -2
- package/src/apps/data/routes/exchanges.bench.ts +85 -0
- package/src/apps/data/routes/fee-amm.bench.ts +13 -0
- package/src/apps/data/routes/indexer.bench.ts +8 -0
- package/src/apps/data/routes/indexer.test.ts +2 -1
- package/src/apps/data/routes/indexer.ts +3 -2
- package/src/apps/data/routes/receipts.bench.ts +39 -0
- package/src/apps/data/routes/rpc.bench.ts +14 -0
- package/src/apps/data/routes/rpc.test.ts +27 -0
- package/src/apps/data/routes/rpc.ts +5 -1
- package/src/apps/data/routes/tokenlist.bench.ts +3 -0
- package/src/apps/data/routes/tokens.bench.ts +76 -0
- package/src/apps/data/routes/transactions.bench.ts +40 -0
- package/src/apps/data/routes/transfers.bench.ts +28 -0
- package/src/apps/data/routes/valuation.bench.ts +14 -0
- package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
- package/src/apps/data/routes/webhooks.bench.ts +26 -0
- package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
- package/src/apps/data/routes/webhooks.test.ts +53 -6
- package/src/apps/data/routes/webhooks.ts +221 -14
- package/src/apps/data/routes/zones.bench.ts +32 -0
- package/src/apps/data/routes/zones.test.ts +1 -1
- package/src/apps/data/routes/zones.ts +1 -1
- package/src/apps/funding/routes/chains.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
- package/src/apps/funding/routes/deposit-addresses.ts +1 -1
- package/src/apps/funding/routes/providers.test.ts +1 -1
- package/src/apps/funding/routes/quotes.test.ts +1 -1
- package/src/apps/funding/routes/transfers.test.ts +3 -6
- package/src/apps/funding/routes/transfers.ts +1 -1
- package/src/apps/management/routes/billing.ts +1 -1
- package/src/apps/management/routes/faucet.ts +2 -2
- package/src/apps/management/routes/me.test.ts +31 -0
- package/src/apps/mcp.ts +4 -1
- package/src/db/Db.ts +81 -0
- package/src/db/Schema.ts +32 -0
- package/src/db/tables/webhookQueueEvents.ts +285 -0
- package/src/db/tables/webhookSubscriptions.ts +15 -0
- package/src/internal/Auth.test.ts +61 -50
- package/src/internal/Auth.ts +9 -4
- package/src/internal/EarnVaults.test.ts +2 -2
- package/src/internal/MetricSink.test.ts +58 -1
- package/src/internal/MetricSink.ts +60 -2
- package/src/internal/RateLimit.test.ts +23 -18
- package/src/internal/RateLimit.ts +23 -15
- package/src/internal/Store.test.ts +99 -13
- package/src/internal/Store.ts +52 -17
- package/src/internal/Tidx.test.ts +4 -4
- package/src/internal/Value.test.ts +25 -0
- package/src/internal/Value.ts +29 -0
- package/src/internal/WebhookTransfer.test.ts +89 -0
- package/src/internal/WebhookTransfer.ts +11 -7
- package/src/internal/Webhooks.test.ts +479 -0
- package/src/internal/Webhooks.ts +281 -7
- package/src/internal/funding/Provider.test.ts +1 -1
- package/src/internal/funding/providers/index.test.ts +73 -3
- package/src/internal/funding/providers/relay.test.ts +1 -5
- package/src/internal/funding/providers/relay.ts +56 -12
|
@@ -51,6 +51,21 @@ export type WebhookEvent =
|
|
|
51
51
|
/** Identifies durable coordinator progress. */
|
|
52
52
|
type: 'webhook:head-progress'
|
|
53
53
|
}
|
|
54
|
+
| {
|
|
55
|
+
/** Tempo chain whose listener was checked. */
|
|
56
|
+
chainId: number
|
|
57
|
+
/** Heads refused in a row by the monotonic guard; zero on a delivering listener. */
|
|
58
|
+
skippedHeads: number
|
|
59
|
+
/** Identifies heads received and discarded rather than heads not received. */
|
|
60
|
+
type: 'webhook:head-skipped'
|
|
61
|
+
}
|
|
62
|
+
| {
|
|
63
|
+
chainId: number
|
|
64
|
+
/** Blocks the probed chain head leads the listener's newest observation. */
|
|
65
|
+
lagBlocks: number
|
|
66
|
+
/** Identifies the out-of-band listener head probe. */
|
|
67
|
+
type: 'webhook:listener-head-lag'
|
|
68
|
+
}
|
|
54
69
|
| {
|
|
55
70
|
/** Tempo chain whose recovery poke was dequeued. */
|
|
56
71
|
chainId: number
|
|
@@ -145,6 +160,22 @@ export type WebhookEvent =
|
|
|
145
160
|
type: 'webhook:enqueue'
|
|
146
161
|
}
|
|
147
162
|
| {
|
|
163
|
+
/** Atomic ledger claim outcome. */
|
|
164
|
+
outcome: 'attempting' | 'claimed' | 'missing' | 'scheduled' | 'terminal'
|
|
165
|
+
/** Identifies a delivery-obligation claim. */
|
|
166
|
+
type: 'webhook:delivery-job-claim'
|
|
167
|
+
}
|
|
168
|
+
| {
|
|
169
|
+
/** Obligations represented by this outcome. */
|
|
170
|
+
count: number
|
|
171
|
+
/** Whether staging created or found the obligation. */
|
|
172
|
+
outcome: 'created' | 'existing'
|
|
173
|
+
/** Identifies obligation staging ahead of Queue handoff. */
|
|
174
|
+
type: 'webhook:delivery-job-ensure'
|
|
175
|
+
}
|
|
176
|
+
| {
|
|
177
|
+
/** Delivery attempt ordinal: the ledger claim count on the queue path. */
|
|
178
|
+
attempt: number
|
|
148
179
|
envelope: Webhooks.Envelope
|
|
149
180
|
/** Cloudflare Queue delivery attempt, absent outside the Queue path. */
|
|
150
181
|
queueAttempt?: number | undefined
|
|
@@ -340,6 +371,16 @@ export function webhooks(backend: Metrics): WebhookSink {
|
|
|
340
371
|
chain_id: String(event.chainId),
|
|
341
372
|
})
|
|
342
373
|
return
|
|
374
|
+
case 'webhook:head-skipped':
|
|
375
|
+
backend.gauge('webhook_head_skipped_count', event.skippedHeads, {
|
|
376
|
+
chain_id: String(event.chainId),
|
|
377
|
+
})
|
|
378
|
+
return
|
|
379
|
+
case 'webhook:listener-head-lag':
|
|
380
|
+
backend.gauge('webhook_listener_head_lag_blocks', event.lagBlocks, {
|
|
381
|
+
chain_id: String(event.chainId),
|
|
382
|
+
})
|
|
383
|
+
return
|
|
343
384
|
case 'webhook:poll-failure':
|
|
344
385
|
backend.count('webhook_poll_failure_count', 1, {
|
|
345
386
|
chain_id: String(event.chainId),
|
|
@@ -456,6 +497,16 @@ export function webhooks(backend: Metrics): WebhookSink {
|
|
|
456
497
|
})
|
|
457
498
|
return
|
|
458
499
|
}
|
|
500
|
+
case 'webhook:delivery-job-claim': {
|
|
501
|
+
backend.count('webhook_delivery_job_claim_count', 1, { outcome: event.outcome })
|
|
502
|
+
return
|
|
503
|
+
}
|
|
504
|
+
case 'webhook:delivery-job-ensure': {
|
|
505
|
+
backend.count('webhook_delivery_job_ensure_count', event.count, {
|
|
506
|
+
outcome: event.outcome,
|
|
507
|
+
})
|
|
508
|
+
return
|
|
509
|
+
}
|
|
459
510
|
case 'webhook:delivery': {
|
|
460
511
|
const { envelope, result, subscription, timings } = event
|
|
461
512
|
const queueTags =
|
|
@@ -463,7 +514,11 @@ export function webhooks(backend: Metrics): WebhookSink {
|
|
|
463
514
|
? {}
|
|
464
515
|
: {
|
|
465
516
|
queue_attempt_bucket: attemptBucket(event.queueAttempt),
|
|
466
|
-
|
|
517
|
+
// Retryable results ack the message and schedule a ledger
|
|
518
|
+
// retry; the Queue transport never redelivers them.
|
|
519
|
+
queue_disposition: WebhookDestination.isRetryable(result)
|
|
520
|
+
? 'scheduled_retry'
|
|
521
|
+
: 'acked',
|
|
467
522
|
}
|
|
468
523
|
const tags = {
|
|
469
524
|
chain_id: String(envelope.chainId),
|
|
@@ -473,7 +528,7 @@ export function webhooks(backend: Metrics): WebhookSink {
|
|
|
473
528
|
const outcome = result.ok ? 'succeeded' : 'failed'
|
|
474
529
|
backend.count('webhook_delivery_attempt_count', 1, {
|
|
475
530
|
...tags,
|
|
476
|
-
attempt_bucket: attemptBucket(
|
|
531
|
+
attempt_bucket: attemptBucket(event.attempt),
|
|
477
532
|
outcome,
|
|
478
533
|
...queueTags,
|
|
479
534
|
status_class: statusClass(result),
|
|
@@ -495,6 +550,9 @@ export function webhooks(backend: Metrics): WebhookSink {
|
|
|
495
550
|
if (timings?.endToEndMs !== undefined)
|
|
496
551
|
backend.histogram('webhook_delivery_end_to_end_ms', timings.endToEndMs, {
|
|
497
552
|
...tags,
|
|
553
|
+
// Unlimited-horizon ledger retries emit hour-plus samples;
|
|
554
|
+
// the bucket keeps first-attempt percentiles interpretable.
|
|
555
|
+
attempt_bucket: attemptBucket(event.attempt),
|
|
498
556
|
outcome,
|
|
499
557
|
})
|
|
500
558
|
if (timings?.eventToEnvelopeMs !== undefined)
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { RateLimit, Store } from 'tapimo'
|
|
2
2
|
|
|
3
3
|
describe('memory', () => {
|
|
4
|
-
test('tracks fixed-
|
|
4
|
+
test('tracks fixed-minute quota in a store', async () => {
|
|
5
5
|
const store = RateLimit.memory({
|
|
6
6
|
now: () => new Date('2026-05-25T00:00:30.000Z'),
|
|
7
7
|
})
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
const
|
|
8
|
+
const limit = { limit: 2, period: 'minute' } as const
|
|
9
|
+
const first = await store.consume({ key: 'public:anonymous', limit })
|
|
10
|
+
const second = await store.consume({ key: 'public:anonymous', limit })
|
|
11
|
+
const third = await store.consume({ key: 'public:anonymous', limit })
|
|
11
12
|
|
|
12
13
|
expect(first).toMatchInlineSnapshot(`
|
|
13
14
|
{
|
|
@@ -35,25 +36,26 @@ describe('memory', () => {
|
|
|
35
36
|
`)
|
|
36
37
|
})
|
|
37
38
|
|
|
38
|
-
test('restarts the count when the next
|
|
39
|
-
// The window reset rides on counter expiry
|
|
40
|
-
//
|
|
39
|
+
test('restarts the count when the next second opens', async () => {
|
|
40
|
+
// The window reset rides on counter expiry, so the store clock and the
|
|
41
|
+
// consume clock must agree.
|
|
41
42
|
vi.useFakeTimers()
|
|
42
43
|
try {
|
|
43
|
-
vi.setSystemTime(new Date('2026-05-25T00:00:30.
|
|
44
|
+
vi.setSystemTime(new Date('2026-05-25T00:00:30.250Z'))
|
|
44
45
|
const store = RateLimit.memory()
|
|
45
|
-
|
|
46
|
-
await store.consume({ key: 'public:anonymous', limit
|
|
46
|
+
const limit = { limit: 2, period: 'second' } as const
|
|
47
|
+
await store.consume({ key: 'public:anonymous', limit })
|
|
48
|
+
await store.consume({ key: 'public:anonymous', limit })
|
|
47
49
|
|
|
48
|
-
vi.setSystemTime(new Date('2026-05-25T00:
|
|
49
|
-
const next = await store.consume({ key: 'public:anonymous', limit
|
|
50
|
+
vi.setSystemTime(new Date('2026-05-25T00:00:31.001Z'))
|
|
51
|
+
const next = await store.consume({ key: 'public:anonymous', limit })
|
|
50
52
|
|
|
51
53
|
expect(next).toMatchInlineSnapshot(`
|
|
52
54
|
{
|
|
53
55
|
"allowed": true,
|
|
54
56
|
"limit": 2,
|
|
55
57
|
"remaining": 1,
|
|
56
|
-
"reset":
|
|
58
|
+
"reset": 1779667232,
|
|
57
59
|
}
|
|
58
60
|
`)
|
|
59
61
|
} finally {
|
|
@@ -72,19 +74,22 @@ describe('memory', () => {
|
|
|
72
74
|
},
|
|
73
75
|
})
|
|
74
76
|
const store = RateLimit.memory({
|
|
75
|
-
//
|
|
76
|
-
now: () => new Date('2026-05-25T00:00:30.
|
|
77
|
+
// 250ms into the second, so 750ms of the window remains.
|
|
78
|
+
now: () => new Date('2026-05-25T00:00:30.250Z'),
|
|
77
79
|
store: recording,
|
|
78
80
|
})
|
|
79
81
|
|
|
80
|
-
await store.consume({
|
|
82
|
+
await store.consume({
|
|
83
|
+
key: 'public:anonymous',
|
|
84
|
+
limit: { limit: 2, period: 'second' },
|
|
85
|
+
})
|
|
81
86
|
|
|
82
87
|
expect(increments).toMatchInlineSnapshot(`
|
|
83
88
|
[
|
|
84
89
|
{
|
|
85
|
-
"key": "ratelimit:public:anonymous",
|
|
90
|
+
"key": "ratelimit:second:1779667230:public:anonymous",
|
|
86
91
|
"options": {
|
|
87
|
-
"ttl":
|
|
92
|
+
"ttl": 750,
|
|
88
93
|
},
|
|
89
94
|
},
|
|
90
95
|
]
|
|
@@ -6,9 +6,15 @@ import * as Store from './Store.js'
|
|
|
6
6
|
export namespace schema {
|
|
7
7
|
/** A rate-limit policy; the source of truth for {@link Limit}. */
|
|
8
8
|
export const Limit = z.object({
|
|
9
|
-
|
|
9
|
+
limit: z
|
|
10
10
|
.number()
|
|
11
|
-
.check(z.int(), z.nonnegative(), z.describe('Number of requests allowed per
|
|
11
|
+
.check(z.int(), z.nonnegative(), z.describe('Number of requests allowed per period.')),
|
|
12
|
+
period: z
|
|
13
|
+
.union([z.literal('minute'), z.literal('second')])
|
|
14
|
+
.check(
|
|
15
|
+
z.describe('Fixed window used to enforce the request limit.'),
|
|
16
|
+
z.meta({ examples: ['minute'] }),
|
|
17
|
+
),
|
|
12
18
|
})
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -49,11 +55,8 @@ export namespace RateLimitStore {
|
|
|
49
55
|
* Each consume is a single `Store.increment`, so a backend with a native
|
|
50
56
|
* counter (e.g. a Durable Object) is one atomic round trip; backends without
|
|
51
57
|
* one fall back to get + put, acceptable for dev/in-process stores only.
|
|
52
|
-
* The storage key
|
|
53
|
-
*
|
|
54
|
-
* current minute, so the next window's first hit restarts at 1 and idle keys
|
|
55
|
-
* hold no storage. The sub-minute TTL rules out Cloudflare KV as a backend,
|
|
56
|
-
* which is unsuitable for enforced counters anyway.
|
|
58
|
+
* The storage key includes the fixed-window bucket, so backend latency cannot
|
|
59
|
+
* carry a previous count into a later window.
|
|
57
60
|
*/
|
|
58
61
|
export function memory(options: memory.Options = {}): RateLimitStore {
|
|
59
62
|
const store = options.store ?? Store.memory()
|
|
@@ -62,19 +65,24 @@ export function memory(options: memory.Options = {}): RateLimitStore {
|
|
|
62
65
|
return {
|
|
63
66
|
async consume(options) {
|
|
64
67
|
const time = now().getTime()
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
// window
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
68
|
+
const window = options.limit.period === 'second' ? 1_000 : 60_000
|
|
69
|
+
const bucket = Math.floor(time / window)
|
|
70
|
+
// The bucket isolates window counts if the backing store applies the TTL
|
|
71
|
+
// after the next window starts.
|
|
72
|
+
const count = await Store.increment(
|
|
73
|
+
store,
|
|
74
|
+
`ratelimit:${options.limit.period}:${bucket}:${options.key}`,
|
|
75
|
+
{
|
|
76
|
+
ttl: (bucket + 1) * window - time,
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
const limit = options.limit.limit
|
|
72
80
|
|
|
73
81
|
return {
|
|
74
82
|
allowed: count <= limit,
|
|
75
83
|
limit,
|
|
76
84
|
remaining: Math.max(limit - count, 0),
|
|
77
|
-
reset: Math.ceil(((bucket + 1) *
|
|
85
|
+
reset: Math.ceil(((bucket + 1) * window) / 1_000),
|
|
78
86
|
}
|
|
79
87
|
},
|
|
80
88
|
}
|
|
@@ -389,6 +389,39 @@ describe('durableObject', () => {
|
|
|
389
389
|
`)
|
|
390
390
|
})
|
|
391
391
|
|
|
392
|
+
test('preserves bigint values and collision-shaped strings', async () => {
|
|
393
|
+
const { namespace } = TestStore.durableObjectNamespace()
|
|
394
|
+
const store = Store.durableObject<{
|
|
395
|
+
value: { bigint: bigint; legacyMarker: string; valueMarker: string }
|
|
396
|
+
}>(namespace)
|
|
397
|
+
const value = {
|
|
398
|
+
bigint: 42n,
|
|
399
|
+
legacyMarker: '123#__bigint',
|
|
400
|
+
valueMarker: 'tempo-api:store:value:bigint:123',
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
await store.put('value', value)
|
|
404
|
+
await store.update('value', (current) => ({
|
|
405
|
+
op: 'set',
|
|
406
|
+
result: undefined,
|
|
407
|
+
value: { ...current!, bigint: current!.bigint + 1n },
|
|
408
|
+
}))
|
|
409
|
+
|
|
410
|
+
expect(await store.get('value')).toEqual({ ...value, bigint: 43n })
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
test('reads typed values written with the legacy codec', async () => {
|
|
414
|
+
const { getState, namespace } = TestStore.durableObjectNamespace()
|
|
415
|
+
await getState('channel').put('channel', 'tempo-api:store:json:v1:{"spent":"42#__bigint"}')
|
|
416
|
+
const store = Store.durableObject<TypedItems>(namespace)
|
|
417
|
+
|
|
418
|
+
expect(await store.get('channel')).toMatchInlineSnapshot(`
|
|
419
|
+
{
|
|
420
|
+
"spent": 42n,
|
|
421
|
+
}
|
|
422
|
+
`)
|
|
423
|
+
})
|
|
424
|
+
|
|
392
425
|
test('keeps typed updates linearizable', async () => {
|
|
393
426
|
const { namespace } = TestStore.durableObjectNamespace()
|
|
394
427
|
const storeA = Store.durableObject<TypedItems>(namespace)
|
|
@@ -439,11 +472,11 @@ describe('durableObject', () => {
|
|
|
439
472
|
|
|
440
473
|
test('rejects typed values written with an unknown codec version', async () => {
|
|
441
474
|
const { getState, namespace } = TestStore.durableObjectNamespace()
|
|
442
|
-
await getState('channel').put('channel', 'tempo-api:store:json:
|
|
475
|
+
await getState('channel').put('channel', 'tempo-api:store:json:v3:{}')
|
|
443
476
|
const store = Store.durableObject<TypedItems>(namespace)
|
|
444
477
|
|
|
445
478
|
await expect(store.get('channel')).rejects.toThrowErrorMatchingInlineSnapshot(
|
|
446
|
-
`[Store.DurableObjectCodecError: Unsupported Durable Object store codec version:
|
|
479
|
+
`[Store.DurableObjectCodecError: Unsupported Durable Object store codec version: v3]`,
|
|
447
480
|
)
|
|
448
481
|
})
|
|
449
482
|
|
|
@@ -744,11 +777,9 @@ describe('memoize', () => {
|
|
|
744
777
|
const values = await Promise.all(requests)
|
|
745
778
|
|
|
746
779
|
expect(calls).toMatchInlineSnapshot(`1`)
|
|
747
|
-
expect(
|
|
748
|
-
{
|
|
749
|
-
|
|
750
|
-
}
|
|
751
|
-
`)
|
|
780
|
+
expect(await store.get('memo:test')).toMatchInlineSnapshot(
|
|
781
|
+
`"tempo-api:store:json:v2:{"value":"fresh"}"`,
|
|
782
|
+
)
|
|
752
783
|
expect(values).toMatchInlineSnapshot(`
|
|
753
784
|
[
|
|
754
785
|
{
|
|
@@ -805,8 +836,65 @@ describe('memoize', () => {
|
|
|
805
836
|
`)
|
|
806
837
|
})
|
|
807
838
|
|
|
839
|
+
test('preserves bigint values and collision-shaped strings on cache hits', async () => {
|
|
840
|
+
const store = Store.memory()
|
|
841
|
+
let calls = 0
|
|
842
|
+
const fetch = async () => {
|
|
843
|
+
calls += 1
|
|
844
|
+
return {
|
|
845
|
+
bigint: 42n,
|
|
846
|
+
legacyMarker: '123#__bigint',
|
|
847
|
+
valueMarker: 'tempo-api:store:value:bigint:123',
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
const first = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 })
|
|
852
|
+
const second = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 })
|
|
853
|
+
|
|
854
|
+
expect(first).toEqual(second)
|
|
855
|
+
expect(second).toEqual({
|
|
856
|
+
bigint: 42n,
|
|
857
|
+
legacyMarker: '123#__bigint',
|
|
858
|
+
valueMarker: 'tempo-api:store:value:bigint:123',
|
|
859
|
+
})
|
|
860
|
+
expect(calls).toBe(1)
|
|
861
|
+
})
|
|
862
|
+
|
|
863
|
+
test('does not cache undefined results', async () => {
|
|
864
|
+
const store = Store.memory()
|
|
865
|
+
let calls = 0
|
|
866
|
+
const fetch = async () => {
|
|
867
|
+
calls += 1
|
|
868
|
+
return undefined
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const first = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 })
|
|
872
|
+
const second = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 })
|
|
873
|
+
|
|
874
|
+
expect(first).toBeUndefined()
|
|
875
|
+
expect(second).toBeUndefined()
|
|
876
|
+
expect(await store.get('memo:test')).toBeNull()
|
|
877
|
+
expect(calls).toBe(2)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
test('refreshes legacy untagged cache entries', async () => {
|
|
881
|
+
const store = Store.memory({ entries: [['memo:test', '{"value":"123#__bigint"}']] })
|
|
882
|
+
let calls = 0
|
|
883
|
+
|
|
884
|
+
const value = await Store.memoize(
|
|
885
|
+
async () => {
|
|
886
|
+
calls += 1
|
|
887
|
+
return { value: '123#__bigint' }
|
|
888
|
+
},
|
|
889
|
+
{ key: 'memo:test', store, ttl: 60_000 },
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
expect(value).toEqual({ value: '123#__bigint' })
|
|
893
|
+
expect(calls).toBe(1)
|
|
894
|
+
})
|
|
895
|
+
|
|
808
896
|
test('refreshes corrupt cache entries', async () => {
|
|
809
|
-
const store = Store.memory({ entries: [['memo:test', 'not-json']] })
|
|
897
|
+
const store = Store.memory({ entries: [['memo:test', 'tempo-api:store:json:v2:not-json']] })
|
|
810
898
|
|
|
811
899
|
const value = await Store.memoize(async () => ({ value: 'fresh' }), {
|
|
812
900
|
key: 'memo:test',
|
|
@@ -819,11 +907,9 @@ describe('memoize', () => {
|
|
|
819
907
|
"value": "fresh",
|
|
820
908
|
}
|
|
821
909
|
`)
|
|
822
|
-
expect(
|
|
823
|
-
{
|
|
824
|
-
|
|
825
|
-
}
|
|
826
|
-
`)
|
|
910
|
+
expect(await store.get('memo:test')).toMatchInlineSnapshot(
|
|
911
|
+
`"tempo-api:store:json:v2:{"value":"fresh"}"`,
|
|
912
|
+
)
|
|
827
913
|
})
|
|
828
914
|
})
|
|
829
915
|
|
package/src/internal/Store.ts
CHANGED
|
@@ -578,7 +578,8 @@ export declare namespace durableObject {
|
|
|
578
578
|
/**
|
|
579
579
|
* Wraps an async producer with store-backed caching. Returns the cached value if
|
|
580
580
|
* present and valid; otherwise runs `fetch`, stores the JSON-encoded result
|
|
581
|
-
* under `key` with `ttl` milliseconds, and returns it.
|
|
581
|
+
* under `key` with `ttl` milliseconds, and returns it. Undefined results are
|
|
582
|
+
* returned without being cached.
|
|
582
583
|
*/
|
|
583
584
|
export async function memoize<value>(
|
|
584
585
|
fetch: () => Promise<value>,
|
|
@@ -587,9 +588,11 @@ export async function memoize<value>(
|
|
|
587
588
|
const { key, store, ttl } = options
|
|
588
589
|
|
|
589
590
|
const raw = await store.get(key)
|
|
590
|
-
|
|
591
|
+
// Old cache entries used ambiguous untagged bigint strings. Refresh them
|
|
592
|
+
// instead of guessing whether a matching string was originally a bigint.
|
|
593
|
+
if (raw?.startsWith(jsonCodecPrefix)) {
|
|
591
594
|
try {
|
|
592
|
-
return
|
|
595
|
+
return jsonDecode(raw) as value
|
|
593
596
|
} catch {
|
|
594
597
|
// Corrupt entries are treated as misses so the next call refreshes.
|
|
595
598
|
}
|
|
@@ -604,7 +607,8 @@ export async function memoize<value>(
|
|
|
604
607
|
// fetch is still in flight.
|
|
605
608
|
const flight = (async () => {
|
|
606
609
|
const value = await fetch()
|
|
607
|
-
|
|
610
|
+
if (value === undefined) return value
|
|
611
|
+
await store.put(key, jsonEncode(value), { ttl })
|
|
608
612
|
return value
|
|
609
613
|
})()
|
|
610
614
|
flights.set(key, flight)
|
|
@@ -721,24 +725,55 @@ async function durableObjectRetry<value>(call: () => Promise<value>): Promise<va
|
|
|
721
725
|
}
|
|
722
726
|
}
|
|
723
727
|
|
|
724
|
-
const
|
|
725
|
-
const
|
|
726
|
-
const
|
|
728
|
+
const jsonCodecRoot = 'tempo-api:store:json:'
|
|
729
|
+
const jsonCodecVersion = 'v2'
|
|
730
|
+
const jsonCodecPrefix = `${jsonCodecRoot}${jsonCodecVersion}:`
|
|
731
|
+
const jsonValuePrefix = 'tempo-api:store:value:'
|
|
732
|
+
const legacyDurableObjectCodecPrefix = `${jsonCodecRoot}v1:`
|
|
733
|
+
|
|
734
|
+
function jsonEncode(value: unknown): string {
|
|
735
|
+
const encoded = JSON.stringify(value, (_key, item) => {
|
|
736
|
+
if (typeof item === 'bigint') return `${jsonValuePrefix}bigint:${item}`
|
|
737
|
+
if (typeof item === 'string' && item.startsWith(jsonValuePrefix))
|
|
738
|
+
return `${jsonValuePrefix}string:${item}`
|
|
739
|
+
return item
|
|
740
|
+
})
|
|
741
|
+
if (encoded === undefined) throw new TypeError('Store value is not serializable')
|
|
742
|
+
return `${jsonCodecPrefix}${encoded}`
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function jsonDecode(value: string): unknown {
|
|
746
|
+
return JSON.parse(value.slice(jsonCodecPrefix.length), (_key, item) => {
|
|
747
|
+
if (typeof item !== 'string' || !item.startsWith(jsonValuePrefix)) return item
|
|
748
|
+
if (item.startsWith(`${jsonValuePrefix}string:`))
|
|
749
|
+
return item.slice(`${jsonValuePrefix}string:`.length)
|
|
750
|
+
if (item.startsWith(`${jsonValuePrefix}bigint:`)) {
|
|
751
|
+
const bigint = item.slice(`${jsonValuePrefix}bigint:`.length)
|
|
752
|
+
if (!/^-?\d+$/.test(bigint)) throw new TypeError('Store bigint is malformed')
|
|
753
|
+
return BigInt(bigint)
|
|
754
|
+
}
|
|
755
|
+
throw new TypeError('Store value tag is unsupported')
|
|
756
|
+
})
|
|
757
|
+
}
|
|
727
758
|
|
|
728
759
|
function durableObjectEncode(value: unknown): string {
|
|
729
|
-
if (typeof value === 'string' && !value.startsWith(
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
760
|
+
if (typeof value === 'string' && !value.startsWith(jsonCodecRoot)) return value
|
|
761
|
+
try {
|
|
762
|
+
return jsonEncode(value)
|
|
763
|
+
} catch (cause) {
|
|
764
|
+
throw new TypeError('Durable Object store value is not serializable', {
|
|
765
|
+
cause,
|
|
766
|
+
})
|
|
767
|
+
}
|
|
733
768
|
}
|
|
734
769
|
|
|
735
770
|
function durableObjectDecode(value: null | string): unknown {
|
|
736
|
-
if (value === null || !value.startsWith(
|
|
737
|
-
if (
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
771
|
+
if (value === null || !value.startsWith(jsonCodecRoot)) return value
|
|
772
|
+
if (value.startsWith(jsonCodecPrefix)) return jsonDecode(value)
|
|
773
|
+
if (value.startsWith(legacyDurableObjectCodecPrefix))
|
|
774
|
+
return Json.parse(value.slice(legacyDurableObjectCodecPrefix.length))
|
|
775
|
+
const [version] = value.slice(jsonCodecRoot.length).split(':')
|
|
776
|
+
throw new DurableObjectCodecError(version)
|
|
742
777
|
}
|
|
743
778
|
|
|
744
779
|
function cloudflareKvExpirationTtl(ttl: number | undefined) {
|
|
@@ -330,9 +330,9 @@ describe('resolve', () => {
|
|
|
330
330
|
test('passes Zone metadata to the resolver', () => {
|
|
331
331
|
expect(
|
|
332
332
|
Tidx.resolve(({ zone }) => ({ baseUrl: zone ? 'http://tidx:8080' : undefined }), {
|
|
333
|
-
chainId: Zones.
|
|
333
|
+
chainId: Zones.zoneModeratoInternal.id,
|
|
334
334
|
principal: null,
|
|
335
|
-
zone: Zones.
|
|
335
|
+
zone: Zones.zoneModeratoInternal,
|
|
336
336
|
}),
|
|
337
337
|
).toMatchInlineSnapshot(`
|
|
338
338
|
{
|
|
@@ -469,7 +469,7 @@ describe('getClient', () => {
|
|
|
469
469
|
)
|
|
470
470
|
try {
|
|
471
471
|
const client = Tidx.getClient({
|
|
472
|
-
chainId: Zones.
|
|
472
|
+
chainId: Zones.zoneModeratoInternal.id,
|
|
473
473
|
tidx: {
|
|
474
474
|
baseUrl: 'https://internal.indexer',
|
|
475
475
|
zoneHeaders: {
|
|
@@ -477,7 +477,7 @@ describe('getClient', () => {
|
|
|
477
477
|
'CF-Access-Client-Secret': 'client-secret',
|
|
478
478
|
},
|
|
479
479
|
},
|
|
480
|
-
zone: Zones.
|
|
480
|
+
zone: Zones.zoneModeratoInternal,
|
|
481
481
|
})
|
|
482
482
|
await client.fetch({ query: 'select 1' })
|
|
483
483
|
|
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
import * as Value from './Value.js'
|
|
2
2
|
|
|
3
|
+
describe('blockToIso', () => {
|
|
4
|
+
test('prefers the millisecond timestamp on a raw header', () => {
|
|
5
|
+
expect(
|
|
6
|
+
Value.blockToIso({ timestamp: '0x6a7b5571', timestampMillis: '0x19ff1c5c312' }),
|
|
7
|
+
).toMatchInlineSnapshot(`"2026-08-11T17:01:37.426Z"`)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
test('falls back to whole seconds when the millisecond timestamp is absent', () => {
|
|
11
|
+
expect(Value.blockToIso({ timestamp: '0x6a7b5571' })).toMatchInlineSnapshot(
|
|
12
|
+
`"2026-08-11T17:01:37.000Z"`,
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
test('reads a viem-formatted header', () => {
|
|
17
|
+
expect(
|
|
18
|
+
Value.blockToIso({ timestamp: 1_786_467_697n, timestampMillis: '0x19ff1c5c312' }),
|
|
19
|
+
).toMatchInlineSnapshot(`"2026-08-11T17:01:37.426Z"`)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('returns undefined without a usable timestamp', () => {
|
|
23
|
+
expect(Value.blockToIso(undefined)).toMatchInlineSnapshot(`undefined`)
|
|
24
|
+
expect(Value.blockToIso({ timestamp: null })).toMatchInlineSnapshot(`undefined`)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
3
28
|
describe('tokenAmount', () => {
|
|
4
29
|
test('renders a self-contained token quantity', () => {
|
|
5
30
|
expect(
|
package/src/internal/Value.ts
CHANGED
|
@@ -111,6 +111,35 @@ export function hexSecondsToIso(value: unknown): string | undefined {
|
|
|
111
111
|
return seconds === undefined ? undefined : new Date(seconds * 1000).toISOString()
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Read a block's timestamp as epoch milliseconds, preferring Tempo's
|
|
116
|
+
* `timestampMillis` over the second-granularity `timestamp`. Accepts hex
|
|
117
|
+
* quantities (raw RPC) and bigint/number (viem-formatted).
|
|
118
|
+
*/
|
|
119
|
+
function blockToMillis(block: unknown): number | undefined {
|
|
120
|
+
if (typeof block !== 'object' || block === null) return undefined
|
|
121
|
+
const { timestamp, timestampMillis } = block as {
|
|
122
|
+
timestamp?: unknown
|
|
123
|
+
timestampMillis?: unknown
|
|
124
|
+
}
|
|
125
|
+
const millis = quantity(timestampMillis)
|
|
126
|
+
if (millis !== undefined) return millis
|
|
127
|
+
const seconds = quantity(timestamp)
|
|
128
|
+
return seconds === undefined ? undefined : seconds * 1_000
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Convert a block's timestamp to an ISO 8601 datetime string, preserving milliseconds. */
|
|
132
|
+
export function blockToIso(block: unknown): string | undefined {
|
|
133
|
+
const millis = blockToMillis(block)
|
|
134
|
+
return millis === undefined ? undefined : new Date(millis).toISOString()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Narrows a quantity that may arrive hex-encoded (raw RPC) or decoded (viem). */
|
|
138
|
+
function quantity(value: unknown): number | undefined {
|
|
139
|
+
const number = toNumber(value)
|
|
140
|
+
return number === undefined || !Number.isFinite(number) ? undefined : number
|
|
141
|
+
}
|
|
142
|
+
|
|
114
143
|
/**
|
|
115
144
|
* Coerce a TIDX timestamp column (Date / epoch seconds / epoch ms / ISO string)
|
|
116
145
|
* to an ISO 8601 datetime string. Returns undefined when the value cannot be
|