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.
Files changed (224) hide show
  1. package/dist/ApiKey.d.ts +2 -1
  2. package/dist/ApiKey.d.ts.map +1 -1
  3. package/dist/ApiKeys.d.ts +4 -2
  4. package/dist/ApiKeys.d.ts.map +1 -1
  5. package/dist/ApiKeys.js +26 -1
  6. package/dist/ApiKeys.js.map +1 -1
  7. package/dist/App.d.ts +3346 -2575
  8. package/dist/App.d.ts.map +1 -1
  9. package/dist/App.js +1 -0
  10. package/dist/App.js.map +1 -1
  11. package/dist/Webhooks.d.ts.map +1 -1
  12. package/dist/Webhooks.js +5 -5
  13. package/dist/Webhooks.js.map +1 -1
  14. package/dist/admin/App.d.ts +91 -84
  15. package/dist/admin/App.d.ts.map +1 -1
  16. package/dist/admin/apps/api-keys.d.ts +26 -17
  17. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  18. package/dist/admin/apps/api-keys.js +6 -0
  19. package/dist/admin/apps/api-keys.js.map +1 -1
  20. package/dist/admin/apps/organizations.d.ts +4 -2
  21. package/dist/admin/apps/organizations.d.ts.map +1 -1
  22. package/dist/admin/ui.generated.d.ts +1 -1
  23. package/dist/admin/ui.generated.js +69 -69
  24. package/dist/admin/ui.generated.js.map +1 -1
  25. package/dist/analytics/Analytics.d.ts.map +1 -1
  26. package/dist/analytics/Analytics.js +26 -0
  27. package/dist/analytics/Analytics.js.map +1 -1
  28. package/dist/apps/Zones.d.ts +4266 -82
  29. package/dist/apps/Zones.d.ts.map +1 -1
  30. package/dist/apps/Zones.js +27 -8
  31. package/dist/apps/Zones.js.map +1 -1
  32. package/dist/apps/data/App.d.ts +3467 -2696
  33. package/dist/apps/data/App.d.ts.map +1 -1
  34. package/dist/apps/data/routes/activities.d.ts +2999 -100
  35. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  36. package/dist/apps/data/routes/activities.js +340 -2
  37. package/dist/apps/data/routes/activities.js.map +1 -1
  38. package/dist/apps/data/routes/addresses.d.ts +850 -336
  39. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  40. package/dist/apps/data/routes/balances.d.ts +26 -26
  41. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  42. package/dist/apps/data/routes/balances.js +11 -3
  43. package/dist/apps/data/routes/balances.js.map +1 -1
  44. package/dist/apps/data/routes/blocks.d.ts +4 -4
  45. package/dist/apps/data/routes/coingecko.d.ts +176 -176
  46. package/dist/apps/data/routes/earn.d.ts +75 -75
  47. package/dist/apps/data/routes/earn.js +2 -2
  48. package/dist/apps/data/routes/earn.js.map +1 -1
  49. package/dist/apps/data/routes/exchanges.d.ts +120 -120
  50. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  51. package/dist/apps/data/routes/indexer.d.ts +35 -34
  52. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  53. package/dist/apps/data/routes/indexer.js +3 -2
  54. package/dist/apps/data/routes/indexer.js.map +1 -1
  55. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  56. package/dist/apps/data/routes/rpc.js +5 -1
  57. package/dist/apps/data/routes/rpc.js.map +1 -1
  58. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  59. package/dist/apps/data/routes/transactions.d.ts +281 -281
  60. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  61. package/dist/apps/data/routes/webhooks.d.ts +418 -364
  62. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  63. package/dist/apps/data/routes/webhooks.js +161 -14
  64. package/dist/apps/data/routes/webhooks.js.map +1 -1
  65. package/dist/apps/data/routes/zones.d.ts +31 -31
  66. package/dist/apps/data/routes/zones.js +1 -1
  67. package/dist/apps/data/routes/zones.js.map +1 -1
  68. package/dist/apps/funding/App.d.ts +96 -96
  69. package/dist/apps/funding/routes/deposit-addresses.d.ts +52 -52
  70. package/dist/apps/funding/routes/deposit-addresses.js +1 -1
  71. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  72. package/dist/apps/funding/routes/providers.d.ts +13 -13
  73. package/dist/apps/funding/routes/transfers.d.ts +32 -32
  74. package/dist/apps/funding/routes/transfers.js +1 -1
  75. package/dist/apps/funding/routes/transfers.js.map +1 -1
  76. package/dist/apps/management/App.d.ts +418 -418
  77. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  78. package/dist/apps/management/routes/billing.d.ts +72 -72
  79. package/dist/apps/management/routes/billing.js +1 -1
  80. package/dist/apps/management/routes/billing.js.map +1 -1
  81. package/dist/apps/management/routes/faucet.d.ts +37 -37
  82. package/dist/apps/management/routes/faucet.js +2 -2
  83. package/dist/apps/management/routes/faucet.js.map +1 -1
  84. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  85. package/dist/apps/management/routes/me.d.ts +6 -6
  86. package/dist/apps/management/routes/members.d.ts +75 -75
  87. package/dist/apps/management/routes/projects.d.ts +40 -40
  88. package/dist/apps/management/routes/usage.d.ts +76 -76
  89. package/dist/apps/management/routes/webhooks.d.ts +49 -49
  90. package/dist/apps/mcp.d.ts.map +1 -1
  91. package/dist/apps/mcp.js +4 -1
  92. package/dist/apps/mcp.js.map +1 -1
  93. package/dist/db/Db.d.ts +2 -0
  94. package/dist/db/Db.d.ts.map +1 -1
  95. package/dist/db/Db.js +58 -0
  96. package/dist/db/Db.js.map +1 -1
  97. package/dist/db/Schema.d.ts +19 -0
  98. package/dist/db/Schema.d.ts.map +1 -1
  99. package/dist/db/Schema.js +24 -0
  100. package/dist/db/Schema.js.map +1 -1
  101. package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
  102. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
  103. package/dist/db/tables/webhookQueueEvents.js +160 -0
  104. package/dist/db/tables/webhookQueueEvents.js.map +1 -0
  105. package/dist/db/tables/webhookSubscriptions.d.ts +2 -0
  106. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  107. package/dist/db/tables/webhookSubscriptions.js +10 -0
  108. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  109. package/dist/internal/Auth.d.ts +3 -0
  110. package/dist/internal/Auth.d.ts.map +1 -1
  111. package/dist/internal/Auth.js +6 -4
  112. package/dist/internal/Auth.js.map +1 -1
  113. package/dist/internal/MetricSink.d.ts +27 -0
  114. package/dist/internal/MetricSink.d.ts.map +1 -1
  115. package/dist/internal/MetricSink.js +29 -2
  116. package/dist/internal/MetricSink.js.map +1 -1
  117. package/dist/internal/RateLimit.d.ts +4 -6
  118. package/dist/internal/RateLimit.d.ts.map +1 -1
  119. package/dist/internal/RateLimit.js +15 -14
  120. package/dist/internal/RateLimit.js.map +1 -1
  121. package/dist/internal/Store.d.ts +2 -1
  122. package/dist/internal/Store.d.ts.map +1 -1
  123. package/dist/internal/Store.js +57 -18
  124. package/dist/internal/Store.js.map +1 -1
  125. package/dist/internal/Value.d.ts +2 -0
  126. package/dist/internal/Value.d.ts.map +1 -1
  127. package/dist/internal/Value.js +25 -0
  128. package/dist/internal/Value.js.map +1 -1
  129. package/dist/internal/WebhookTransfer.d.ts +20 -0
  130. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  131. package/dist/internal/WebhookTransfer.js +7 -4
  132. package/dist/internal/WebhookTransfer.js.map +1 -1
  133. package/dist/internal/Webhooks.d.ts +139 -0
  134. package/dist/internal/Webhooks.d.ts.map +1 -1
  135. package/dist/internal/Webhooks.js +129 -7
  136. package/dist/internal/Webhooks.js.map +1 -1
  137. package/dist/internal/funding/providers/relay.d.ts +3 -4
  138. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  139. package/dist/internal/funding/providers/relay.js +59 -8
  140. package/dist/internal/funding/providers/relay.js.map +1 -1
  141. package/package.json +3 -3
  142. package/src/ApiKeys.test.ts +20 -0
  143. package/src/ApiKeys.ts +28 -1
  144. package/src/App.test.ts +10 -8
  145. package/src/App.ts +1 -0
  146. package/src/Client.test-d.ts +1 -0
  147. package/src/Webhooks.ts +19 -17
  148. package/src/admin/apps/api-keys.test.ts +9 -0
  149. package/src/admin/apps/api-keys.ts +15 -0
  150. package/src/admin/ui/src/lib/query-client.ts +3 -2
  151. package/src/admin/ui/src/routes/api-keys.tsx +31 -9
  152. package/src/admin/ui.generated.ts +69 -69
  153. package/src/analytics/Analytics.test.ts +8 -0
  154. package/src/analytics/Analytics.ts +26 -0
  155. package/src/apps/Zones.test.ts +20 -3
  156. package/src/apps/Zones.ts +28 -8
  157. package/src/apps/composition.test.ts +1 -0
  158. package/src/apps/data/App.test.ts +2 -2
  159. package/src/apps/data/routes/activities.bench.ts +41 -0
  160. package/src/apps/data/routes/activities.test.ts +103 -2
  161. package/src/apps/data/routes/activities.ts +401 -2
  162. package/src/apps/data/routes/balances.bench.ts +21 -0
  163. package/src/apps/data/routes/balances.ts +11 -3
  164. package/src/apps/data/routes/blocks.bench.ts +34 -0
  165. package/src/apps/data/routes/coingecko.bench.ts +53 -0
  166. package/src/apps/data/routes/earn.bench.ts +71 -0
  167. package/src/apps/data/routes/earn.ts +2 -2
  168. package/src/apps/data/routes/exchanges.bench.ts +85 -0
  169. package/src/apps/data/routes/fee-amm.bench.ts +13 -0
  170. package/src/apps/data/routes/indexer.bench.ts +8 -0
  171. package/src/apps/data/routes/indexer.test.ts +2 -1
  172. package/src/apps/data/routes/indexer.ts +3 -2
  173. package/src/apps/data/routes/receipts.bench.ts +39 -0
  174. package/src/apps/data/routes/rpc.bench.ts +14 -0
  175. package/src/apps/data/routes/rpc.test.ts +27 -0
  176. package/src/apps/data/routes/rpc.ts +5 -1
  177. package/src/apps/data/routes/tokenlist.bench.ts +3 -0
  178. package/src/apps/data/routes/tokens.bench.ts +76 -0
  179. package/src/apps/data/routes/transactions.bench.ts +40 -0
  180. package/src/apps/data/routes/transfers.bench.ts +28 -0
  181. package/src/apps/data/routes/valuation.bench.ts +14 -0
  182. package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
  183. package/src/apps/data/routes/webhooks.bench.ts +26 -0
  184. package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
  185. package/src/apps/data/routes/webhooks.test.ts +53 -6
  186. package/src/apps/data/routes/webhooks.ts +221 -14
  187. package/src/apps/data/routes/zones.bench.ts +32 -0
  188. package/src/apps/data/routes/zones.test.ts +1 -1
  189. package/src/apps/data/routes/zones.ts +1 -1
  190. package/src/apps/funding/routes/chains.test.ts +1 -1
  191. package/src/apps/funding/routes/deposit-addresses.test.ts +1 -1
  192. package/src/apps/funding/routes/deposit-addresses.ts +1 -1
  193. package/src/apps/funding/routes/providers.test.ts +1 -1
  194. package/src/apps/funding/routes/quotes.test.ts +1 -1
  195. package/src/apps/funding/routes/transfers.test.ts +3 -6
  196. package/src/apps/funding/routes/transfers.ts +1 -1
  197. package/src/apps/management/routes/billing.ts +1 -1
  198. package/src/apps/management/routes/faucet.ts +2 -2
  199. package/src/apps/management/routes/me.test.ts +31 -0
  200. package/src/apps/mcp.ts +4 -1
  201. package/src/db/Db.ts +81 -0
  202. package/src/db/Schema.ts +32 -0
  203. package/src/db/tables/webhookQueueEvents.ts +285 -0
  204. package/src/db/tables/webhookSubscriptions.ts +15 -0
  205. package/src/internal/Auth.test.ts +61 -50
  206. package/src/internal/Auth.ts +9 -4
  207. package/src/internal/EarnVaults.test.ts +2 -2
  208. package/src/internal/MetricSink.test.ts +58 -1
  209. package/src/internal/MetricSink.ts +60 -2
  210. package/src/internal/RateLimit.test.ts +23 -18
  211. package/src/internal/RateLimit.ts +23 -15
  212. package/src/internal/Store.test.ts +99 -13
  213. package/src/internal/Store.ts +52 -17
  214. package/src/internal/Tidx.test.ts +4 -4
  215. package/src/internal/Value.test.ts +25 -0
  216. package/src/internal/Value.ts +29 -0
  217. package/src/internal/WebhookTransfer.test.ts +89 -0
  218. package/src/internal/WebhookTransfer.ts +11 -7
  219. package/src/internal/Webhooks.test.ts +479 -0
  220. package/src/internal/Webhooks.ts +281 -7
  221. package/src/internal/funding/Provider.test.ts +1 -1
  222. package/src/internal/funding/providers/index.test.ts +73 -3
  223. package/src/internal/funding/providers/relay.test.ts +1 -5
  224. 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
- queue_disposition: WebhookDestination.isRetryable(result) ? 'retried' : 'acked',
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(subscription.failureCount + 1),
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-window quota in a store', async () => {
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 first = await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
9
- const second = await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
10
- const third = await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
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 window opens', async () => {
39
- // The window reset rides on counter expiry (the key is stable, not
40
- // bucket-suffixed), so the store clock and the consume clock must agree.
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.000Z'))
44
+ vi.setSystemTime(new Date('2026-05-25T00:00:30.250Z'))
44
45
  const store = RateLimit.memory()
45
- await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
46
- await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
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:01:00.001Z'))
49
- const next = await store.consume({ key: 'public:anonymous', limit: { perMinute: 2 } })
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": 1779667320,
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
- // 30s into the minute, so 30s of the window remains.
76
- now: () => new Date('2026-05-25T00:00:30.000Z'),
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({ key: 'public:anonymous', limit: { perMinute: 2 } })
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": 30000,
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
- perMinute: z
9
+ limit: z
10
10
  .number()
11
- .check(z.int(), z.nonnegative(), z.describe('Number of requests allowed per minute.')),
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 is stable per quota key the
53
- * window lives in the TTL: the counter expires exactly at the end of the
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 bucket = Math.floor(time / 60_000)
66
- // Every increment within a window recomputes the TTL against the same
67
- // window end, so the counter's absolute expiry never moves under load.
68
- const count = await Store.increment(store, `ratelimit:${options.key}`, {
69
- ttl: (bucket + 1) * 60_000 - time,
70
- })
71
- const limit = options.limit.perMinute
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) * 60_000) / 1_000),
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:v2:{}')
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: v2]`,
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(JSON.parse((await store.get('memo:test')) ?? 'null')).toMatchInlineSnapshot(`
748
- {
749
- "value": "fresh",
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(JSON.parse((await store.get('memo:test')) ?? 'null')).toMatchInlineSnapshot(`
823
- {
824
- "value": "fresh",
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
 
@@ -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
- if (raw) {
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 Json.parse(raw) as value
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
- await store.put(key, Json.stringify(value), { ttl })
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 durableObjectCodecRoot = 'tempo-api:store:json:'
725
- const durableObjectCodecVersion = 'v1'
726
- const durableObjectCodecPrefix = `${durableObjectCodecRoot}${durableObjectCodecVersion}:`
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(durableObjectCodecRoot)) return value
730
- const encoded = Json.stringify(value)
731
- if (encoded === undefined) throw new TypeError('Durable Object store value is not serializable')
732
- return `${durableObjectCodecPrefix}${encoded}`
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(durableObjectCodecRoot)) return value
737
- if (!value.startsWith(durableObjectCodecPrefix)) {
738
- const [version] = value.slice(durableObjectCodecRoot.length).split(':')
739
- throw new DurableObjectCodecError(version)
740
- }
741
- return Json.parse(value.slice(durableObjectCodecPrefix.length))
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.zoneModerato.id,
333
+ chainId: Zones.zoneModeratoInternal.id,
334
334
  principal: null,
335
- zone: Zones.zoneModerato,
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.zoneModerato.id,
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.zoneModerato,
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(
@@ -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