tapimo 0.10.0 → 0.10.2

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 (295) 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 +6007 -5003
  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 +1 -1
  12. package/dist/Webhooks.d.ts.map +1 -1
  13. package/dist/Webhooks.js +6 -6
  14. package/dist/Webhooks.js.map +1 -1
  15. package/dist/admin/App.d.ts +91 -84
  16. package/dist/admin/App.d.ts.map +1 -1
  17. package/dist/admin/apps/api-keys.d.ts +22 -13
  18. package/dist/admin/apps/api-keys.d.ts.map +1 -1
  19. package/dist/admin/apps/api-keys.js +6 -0
  20. package/dist/admin/apps/api-keys.js.map +1 -1
  21. package/dist/admin/apps/organizations.d.ts +4 -2
  22. package/dist/admin/apps/organizations.d.ts.map +1 -1
  23. package/dist/admin/ui.generated.d.ts +1 -1
  24. package/dist/admin/ui.generated.js +69 -69
  25. package/dist/admin/ui.generated.js.map +1 -1
  26. package/dist/analytics/Analytics.d.ts.map +1 -1
  27. package/dist/analytics/Analytics.js +232 -0
  28. package/dist/analytics/Analytics.js.map +1 -1
  29. package/dist/analytics/tables/requestEvents.d.ts +4 -0
  30. package/dist/analytics/tables/requestEvents.d.ts.map +1 -1
  31. package/dist/analytics/tables/requestEvents.js +2 -0
  32. package/dist/analytics/tables/requestEvents.js.map +1 -1
  33. package/dist/apps/Zones.d.ts +4266 -82
  34. package/dist/apps/Zones.d.ts.map +1 -1
  35. package/dist/apps/Zones.js +27 -8
  36. package/dist/apps/Zones.js.map +1 -1
  37. package/dist/apps/data/App.d.ts +3467 -2696
  38. package/dist/apps/data/App.d.ts.map +1 -1
  39. package/dist/apps/data/App.js +2 -0
  40. package/dist/apps/data/App.js.map +1 -1
  41. package/dist/apps/data/routes/activities.d.ts +2999 -100
  42. package/dist/apps/data/routes/activities.d.ts.map +1 -1
  43. package/dist/apps/data/routes/activities.js +340 -2
  44. package/dist/apps/data/routes/activities.js.map +1 -1
  45. package/dist/apps/data/routes/addresses.d.ts +514 -0
  46. package/dist/apps/data/routes/addresses.d.ts.map +1 -1
  47. package/dist/apps/data/routes/balances.d.ts +26 -26
  48. package/dist/apps/data/routes/balances.d.ts.map +1 -1
  49. package/dist/apps/data/routes/balances.js +7 -3
  50. package/dist/apps/data/routes/balances.js.map +1 -1
  51. package/dist/apps/data/routes/blocks.d.ts +4 -4
  52. package/dist/apps/data/routes/earn.d.ts +75 -75
  53. package/dist/apps/data/routes/earn.d.ts.map +1 -1
  54. package/dist/apps/data/routes/earn.js +65 -36
  55. package/dist/apps/data/routes/earn.js.map +1 -1
  56. package/dist/apps/data/routes/exchanges.d.ts +124 -120
  57. package/dist/apps/data/routes/exchanges.d.ts.map +1 -1
  58. package/dist/apps/data/routes/exchanges.js +115 -77
  59. package/dist/apps/data/routes/exchanges.js.map +1 -1
  60. package/dist/apps/data/routes/fee-amm.d.ts +24 -24
  61. package/dist/apps/data/routes/indexer.d.ts +2 -1
  62. package/dist/apps/data/routes/indexer.d.ts.map +1 -1
  63. package/dist/apps/data/routes/indexer.js +3 -2
  64. package/dist/apps/data/routes/indexer.js.map +1 -1
  65. package/dist/apps/data/routes/receipts.d.ts +4 -3
  66. package/dist/apps/data/routes/receipts.d.ts.map +1 -1
  67. package/dist/apps/data/routes/receipts.js +15 -14
  68. package/dist/apps/data/routes/receipts.js.map +1 -1
  69. package/dist/apps/data/routes/rpc.d.ts.map +1 -1
  70. package/dist/apps/data/routes/rpc.js +5 -1
  71. package/dist/apps/data/routes/rpc.js.map +1 -1
  72. package/dist/apps/data/routes/tokenlist.d.ts +12 -12
  73. package/dist/apps/data/routes/tokens.d.ts +1 -1
  74. package/dist/apps/data/routes/tokens.d.ts.map +1 -1
  75. package/dist/apps/data/routes/tokens.js +4 -3
  76. package/dist/apps/data/routes/tokens.js.map +1 -1
  77. package/dist/apps/data/routes/transfers.d.ts.map +1 -1
  78. package/dist/apps/data/routes/transfers.js +25 -7
  79. package/dist/apps/data/routes/transfers.js.map +1 -1
  80. package/dist/apps/data/routes/verified-tokens.d.ts +37 -37
  81. package/dist/apps/data/routes/webhooks.d.ts +71 -1
  82. package/dist/apps/data/routes/webhooks.d.ts.map +1 -1
  83. package/dist/apps/data/routes/webhooks.js +177 -23
  84. package/dist/apps/data/routes/webhooks.js.map +1 -1
  85. package/dist/apps/data/routes/zones.js +1 -1
  86. package/dist/apps/data/routes/zones.js.map +1 -1
  87. package/dist/apps/funding/App.d.ts +322 -89
  88. package/dist/apps/funding/App.d.ts.map +1 -1
  89. package/dist/apps/funding/routes/deposit-addresses.d.ts +375 -45
  90. package/dist/apps/funding/routes/deposit-addresses.d.ts.map +1 -1
  91. package/dist/apps/funding/routes/deposit-addresses.js +208 -12
  92. package/dist/apps/funding/routes/deposit-addresses.js.map +1 -1
  93. package/dist/apps/funding/routes/transfers.d.ts +32 -32
  94. package/dist/apps/funding/routes/transfers.js +1 -1
  95. package/dist/apps/funding/routes/transfers.js.map +1 -1
  96. package/dist/apps/management/App.d.ts +418 -418
  97. package/dist/apps/management/routes/api-keys.d.ts +50 -50
  98. package/dist/apps/management/routes/billing.d.ts +72 -72
  99. package/dist/apps/management/routes/billing.js +1 -1
  100. package/dist/apps/management/routes/billing.js.map +1 -1
  101. package/dist/apps/management/routes/faucet.d.ts +8 -8
  102. package/dist/apps/management/routes/faucet.js +2 -2
  103. package/dist/apps/management/routes/faucet.js.map +1 -1
  104. package/dist/apps/management/routes/invite-links.d.ts +42 -42
  105. package/dist/apps/management/routes/me.d.ts +6 -6
  106. package/dist/apps/management/routes/members.d.ts +14 -14
  107. package/dist/apps/management/routes/projects.d.ts +40 -40
  108. package/dist/apps/management/routes/usage.d.ts +8 -8
  109. package/dist/apps/management/routes/webhooks.d.ts +49 -49
  110. package/dist/apps/mcp.d.ts.map +1 -1
  111. package/dist/apps/mcp.js +4 -1
  112. package/dist/apps/mcp.js.map +1 -1
  113. package/dist/db/Db.d.ts +2 -0
  114. package/dist/db/Db.d.ts.map +1 -1
  115. package/dist/db/Db.js +90 -0
  116. package/dist/db/Db.js.map +1 -1
  117. package/dist/db/Schema.d.ts +19 -0
  118. package/dist/db/Schema.d.ts.map +1 -1
  119. package/dist/db/Schema.js +24 -0
  120. package/dist/db/Schema.js.map +1 -1
  121. package/dist/db/tables/fundingDepositAddresses.d.ts +20 -0
  122. package/dist/db/tables/fundingDepositAddresses.d.ts.map +1 -1
  123. package/dist/db/tables/fundingDepositAddresses.js +17 -0
  124. package/dist/db/tables/fundingDepositAddresses.js.map +1 -1
  125. package/dist/db/tables/fundingDeposits.d.ts +41 -0
  126. package/dist/db/tables/fundingDeposits.d.ts.map +1 -1
  127. package/dist/db/tables/fundingDeposits.js +69 -0
  128. package/dist/db/tables/fundingDeposits.js.map +1 -1
  129. package/dist/db/tables/webhookQueueEvents.d.ts +122 -0
  130. package/dist/db/tables/webhookQueueEvents.d.ts.map +1 -0
  131. package/dist/db/tables/webhookQueueEvents.js +160 -0
  132. package/dist/db/tables/webhookQueueEvents.js.map +1 -0
  133. package/dist/db/tables/webhookSubscriptions.d.ts +2 -0
  134. package/dist/db/tables/webhookSubscriptions.d.ts.map +1 -1
  135. package/dist/db/tables/webhookSubscriptions.js +10 -0
  136. package/dist/db/tables/webhookSubscriptions.js.map +1 -1
  137. package/dist/internal/Auth.d.ts +3 -26
  138. package/dist/internal/Auth.d.ts.map +1 -1
  139. package/dist/internal/Auth.js +6 -37
  140. package/dist/internal/Auth.js.map +1 -1
  141. package/dist/internal/Credential.d.ts +27 -0
  142. package/dist/internal/Credential.d.ts.map +1 -0
  143. package/dist/internal/Credential.js +34 -0
  144. package/dist/internal/Credential.js.map +1 -0
  145. package/dist/internal/EarnRates.js +11 -7
  146. package/dist/internal/EarnRates.js.map +1 -1
  147. package/dist/internal/Log.d.ts +8 -0
  148. package/dist/internal/Log.d.ts.map +1 -1
  149. package/dist/internal/Log.js +4 -0
  150. package/dist/internal/Log.js.map +1 -1
  151. package/dist/internal/MetricSink.d.ts +51 -0
  152. package/dist/internal/MetricSink.d.ts.map +1 -1
  153. package/dist/internal/MetricSink.js +54 -2
  154. package/dist/internal/MetricSink.js.map +1 -1
  155. package/dist/internal/RateLimit.d.ts +4 -6
  156. package/dist/internal/RateLimit.d.ts.map +1 -1
  157. package/dist/internal/RateLimit.js +15 -14
  158. package/dist/internal/RateLimit.js.map +1 -1
  159. package/dist/internal/Store.d.ts +2 -1
  160. package/dist/internal/Store.d.ts.map +1 -1
  161. package/dist/internal/Store.js +57 -18
  162. package/dist/internal/Store.js.map +1 -1
  163. package/dist/internal/Tidx.d.ts +4 -3
  164. package/dist/internal/Tidx.d.ts.map +1 -1
  165. package/dist/internal/Tidx.js +2 -2
  166. package/dist/internal/Tidx.js.map +1 -1
  167. package/dist/internal/Value.d.ts +2 -0
  168. package/dist/internal/Value.d.ts.map +1 -1
  169. package/dist/internal/Value.js +25 -0
  170. package/dist/internal/Value.js.map +1 -1
  171. package/dist/internal/Viem.d.ts +4 -3
  172. package/dist/internal/Viem.d.ts.map +1 -1
  173. package/dist/internal/Viem.js +3 -2
  174. package/dist/internal/Viem.js.map +1 -1
  175. package/dist/internal/WebhookTransfer.d.ts +20 -0
  176. package/dist/internal/WebhookTransfer.d.ts.map +1 -1
  177. package/dist/internal/WebhookTransfer.js +7 -4
  178. package/dist/internal/WebhookTransfer.js.map +1 -1
  179. package/dist/internal/Webhooks.d.ts +141 -0
  180. package/dist/internal/Webhooks.d.ts.map +1 -1
  181. package/dist/internal/Webhooks.js +139 -10
  182. package/dist/internal/Webhooks.js.map +1 -1
  183. package/dist/internal/funding/providers/relay.d.ts +3 -4
  184. package/dist/internal/funding/providers/relay.d.ts.map +1 -1
  185. package/dist/internal/funding/providers/relay.js +59 -8
  186. package/dist/internal/funding/providers/relay.js.map +1 -1
  187. package/dist/internal/index.d.ts +1 -0
  188. package/dist/internal/index.d.ts.map +1 -1
  189. package/dist/internal/index.js +1 -0
  190. package/dist/internal/index.js.map +1 -1
  191. package/package.json +3 -3
  192. package/src/ApiKeys.test.ts +20 -0
  193. package/src/ApiKeys.ts +28 -1
  194. package/src/App.test.ts +40 -8
  195. package/src/App.ts +1 -0
  196. package/src/Client.test-d.ts +34 -1
  197. package/src/Webhooks.ts +20 -18
  198. package/src/admin/apps/api-keys.test.ts +9 -0
  199. package/src/admin/apps/api-keys.ts +15 -0
  200. package/src/admin/ui/src/lib/query-client.ts +3 -2
  201. package/src/admin/ui/src/routes/api-keys.tsx +31 -9
  202. package/src/admin/ui.generated.ts +69 -69
  203. package/src/analytics/Analytics.test.ts +42 -0
  204. package/src/analytics/Analytics.ts +232 -0
  205. package/src/analytics/tables/requestEvents.ts +6 -0
  206. package/src/apps/Zones.test.ts +20 -3
  207. package/src/apps/Zones.ts +28 -8
  208. package/src/apps/composition.test.ts +2 -0
  209. package/src/apps/data/App.test.ts +2 -2
  210. package/src/apps/data/App.ts +2 -0
  211. package/src/apps/data/routes/activities.bench.ts +41 -0
  212. package/src/apps/data/routes/activities.test.ts +103 -2
  213. package/src/apps/data/routes/activities.ts +401 -2
  214. package/src/apps/data/routes/balances.bench.ts +21 -0
  215. package/src/apps/data/routes/balances.ts +7 -3
  216. package/src/apps/data/routes/blocks.bench.ts +34 -0
  217. package/src/apps/data/routes/coingecko.bench.ts +53 -0
  218. package/src/apps/data/routes/earn.bench.ts +71 -0
  219. package/src/apps/data/routes/earn.ts +66 -36
  220. package/src/apps/data/routes/exchanges.bench.ts +85 -0
  221. package/src/apps/data/routes/exchanges.test.ts +8 -3
  222. package/src/apps/data/routes/exchanges.ts +134 -82
  223. package/src/apps/data/routes/fee-amm.bench.ts +13 -0
  224. package/src/apps/data/routes/indexer.bench.ts +8 -0
  225. package/src/apps/data/routes/indexer.test.ts +2 -1
  226. package/src/apps/data/routes/indexer.ts +3 -2
  227. package/src/apps/data/routes/receipts.bench.ts +39 -0
  228. package/src/apps/data/routes/receipts.ts +16 -14
  229. package/src/apps/data/routes/rpc.bench.ts +14 -0
  230. package/src/apps/data/routes/rpc.test.ts +27 -0
  231. package/src/apps/data/routes/rpc.ts +5 -1
  232. package/src/apps/data/routes/tokenlist.bench.ts +3 -0
  233. package/src/apps/data/routes/tokens.bench.ts +76 -0
  234. package/src/apps/data/routes/tokens.ts +3 -2
  235. package/src/apps/data/routes/transactions.bench.ts +48 -0
  236. package/src/apps/data/routes/transfers.bench.ts +28 -0
  237. package/src/apps/data/routes/transfers.ts +36 -10
  238. package/src/apps/data/routes/valuation.bench.ts +14 -0
  239. package/src/apps/data/routes/verified-tokens.bench.ts +8 -0
  240. package/src/apps/data/routes/webhooks.bench.ts +26 -0
  241. package/src/apps/data/routes/webhooks.scan.test.ts +326 -0
  242. package/src/apps/data/routes/webhooks.test.ts +53 -6
  243. package/src/apps/data/routes/webhooks.ts +252 -24
  244. package/src/apps/data/routes/zones.bench.ts +32 -0
  245. package/src/apps/data/routes/zones.test.ts +1 -1
  246. package/src/apps/data/routes/zones.ts +1 -1
  247. package/src/apps/funding/routes/chains.test.ts +1 -1
  248. package/src/apps/funding/routes/deposit-addresses.test.ts +217 -3
  249. package/src/apps/funding/routes/deposit-addresses.ts +255 -19
  250. package/src/apps/funding/routes/providers.test.ts +1 -1
  251. package/src/apps/funding/routes/quotes.test.ts +1 -1
  252. package/src/apps/funding/routes/transfers.test.ts +20 -8
  253. package/src/apps/funding/routes/transfers.ts +1 -1
  254. package/src/apps/management/routes/billing.ts +1 -1
  255. package/src/apps/management/routes/faucet.ts +2 -2
  256. package/src/apps/management/routes/me.test.ts +31 -0
  257. package/src/apps/mcp.ts +4 -1
  258. package/src/db/Db.ts +117 -0
  259. package/src/db/Schema.ts +32 -0
  260. package/src/db/tables/fundingDepositAddresses.test.ts +28 -0
  261. package/src/db/tables/fundingDepositAddresses.ts +39 -0
  262. package/src/db/tables/fundingDeposits.test.ts +82 -0
  263. package/src/db/tables/fundingDeposits.ts +130 -0
  264. package/src/db/tables/webhookQueueEvents.ts +285 -0
  265. package/src/db/tables/webhookSubscriptions.ts +15 -0
  266. package/src/internal/Auth.test.ts +61 -98
  267. package/src/internal/Auth.ts +9 -63
  268. package/src/internal/Credential.test.ts +51 -0
  269. package/src/internal/Credential.ts +58 -0
  270. package/src/internal/EarnRates.ts +13 -7
  271. package/src/internal/EarnVaults.test.ts +2 -2
  272. package/src/internal/Log.test.ts +3 -1
  273. package/src/internal/Log.ts +14 -0
  274. package/src/internal/MetricSink.test.ts +108 -2
  275. package/src/internal/MetricSink.ts +112 -2
  276. package/src/internal/RateLimit.test.ts +23 -18
  277. package/src/internal/RateLimit.ts +23 -15
  278. package/src/internal/Store.test.ts +99 -13
  279. package/src/internal/Store.ts +52 -17
  280. package/src/internal/Tidx.test.ts +4 -4
  281. package/src/internal/Tidx.ts +5 -4
  282. package/src/internal/Value.test.ts +25 -0
  283. package/src/internal/Value.ts +29 -0
  284. package/src/internal/Viem.test.ts +4 -0
  285. package/src/internal/Viem.ts +6 -4
  286. package/src/internal/WebhookTransfer.test.ts +89 -0
  287. package/src/internal/WebhookTransfer.ts +11 -7
  288. package/src/internal/Webhooks.test.ts +488 -0
  289. package/src/internal/Webhooks.ts +291 -10
  290. package/src/internal/funding/Provider.test.ts +1 -1
  291. package/src/internal/funding/providers/index.test.ts +73 -3
  292. package/src/internal/funding/providers/relay.test.ts +1 -5
  293. package/src/internal/funding/providers/relay.ts +56 -12
  294. package/src/internal/index.test.ts +1 -0
  295. package/src/internal/index.ts +1 -0
@@ -0,0 +1,58 @@
1
+ /** Shared or per-chain credentials. */
2
+ export type ChainCredential = string | Record<number, string>
3
+
4
+ /** Parses shared or per-chain credentials from an environment value. */
5
+ export function parseCredential(value: ChainCredential | undefined): parseCredential.ReturnType {
6
+ if (!value) return undefined
7
+ if (typeof value !== 'string') return value
8
+ const credentials = (() => {
9
+ try {
10
+ return JSON.parse(value) as unknown
11
+ } catch {
12
+ return value
13
+ }
14
+ })()
15
+ if (typeof credentials === 'string') return credentials
16
+ if (
17
+ !credentials ||
18
+ typeof credentials !== 'object' ||
19
+ Array.isArray(credentials) ||
20
+ Object.values(credentials).some((credential) => typeof credential !== 'string')
21
+ )
22
+ throw new Error('Expected a credential or a chain-id-to-credential JSON object.')
23
+ return credentials as Record<number, string>
24
+ }
25
+
26
+ export declare namespace parseCredential {
27
+ /** Parsed shared or per-chain credentials. */
28
+ type ReturnType = ChainCredential | undefined
29
+ }
30
+
31
+ /** Resolves a chain credential and infers its HTTP authorization scheme. */
32
+ export function resolveCredential(
33
+ options: resolveCredential.Options,
34
+ ): resolveCredential.ReturnType {
35
+ const credentials = parseCredential(options.value)
36
+ const credential = typeof credentials === 'string' ? credentials : credentials?.[options.chainId]
37
+ if (!credential) return { basicAuth: undefined }
38
+ if (credential.includes(':')) return { basicAuth: credential }
39
+ return { basicAuth: undefined, bearerAuth: credential }
40
+ }
41
+
42
+ export declare namespace resolveCredential {
43
+ /** Options for resolving a chain credential. */
44
+ type Options = {
45
+ /** Chain id selecting a per-chain credential. */
46
+ chainId: number
47
+ /** Shared or per-chain credentials. */
48
+ value?: ChainCredential | undefined
49
+ }
50
+
51
+ /** Resolved HTTP authorization credentials. */
52
+ type ReturnType = {
53
+ /** Basic credentials inferred from a value containing a colon. */
54
+ basicAuth: string | undefined
55
+ /** Bearer credential inferred from a value without a colon. */
56
+ bearerAuth?: string | undefined
57
+ }
58
+ }
@@ -246,6 +246,12 @@ async function measurePage(options: measurePage.Options): Promise<Measurement |
246
246
  () => undefined,
247
247
  )
248
248
 
249
+ // The migration scan is independent of historical quotes, so keep its TIDX
250
+ // round trip off the RPC critical path.
251
+ const migrations = recentMigrations(options).then(
252
+ (value) => ({ status: 'fulfilled', value }) as const,
253
+ (reason: unknown) => ({ reason, status: 'rejected' }) as const,
254
+ )
249
255
  const rates = new Map<string, string>()
250
256
  await Promise.all(
251
257
  options.vaults.map(async (vault) => {
@@ -263,13 +269,13 @@ async function measurePage(options: measurePage.Options): Promise<Measurement |
263
269
  }),
264
270
  )
265
271
  if (rates.size === 0) return { measuredAt, rates }
272
+ const migrationResult = await migrations
266
273
 
267
274
  // A migration inside the window bridges two different yield engines, so the
268
- // window resets instead of reporting growth across the swap. Scanned only
269
- // once a rate exists for it to invalidate.
270
- const migrations = await recentMigrations(options)
271
- if (!migrations) return undefined
272
- for (const { blockNumber, vault } of migrations)
275
+ // window resets instead of reporting growth across the swap.
276
+ if (migrationResult.status === 'rejected') throw migrationResult.reason
277
+ if (!migrationResult.value) return undefined
278
+ for (const { blockNumber, vault } of migrationResult.value)
273
279
  if (blockNumber > start.number && blockNumber <= end.number) rates.delete(vault)
274
280
  return { measuredAt, rates }
275
281
  }
@@ -306,9 +312,9 @@ async function boundaryBlocks(options: boundaryBlocks.Options): Promise<Boundari
306
312
  const result = await options.getTidx(chainId).fetch({
307
313
  chainId,
308
314
  query: `
309
- (SELECT num, timestamp FROM blocks WHERE timestamp <= '${endInstant}' ORDER BY num DESC LIMIT 1)
315
+ (SELECT num, timestamp FROM blocks WHERE timestamp <= '${endInstant}' ORDER BY timestamp DESC, num DESC LIMIT 1)
310
316
  UNION ALL
311
- (SELECT num, timestamp FROM blocks WHERE timestamp <= '${startInstant}' ORDER BY num DESC LIMIT 1)
317
+ (SELECT num, timestamp FROM blocks WHERE timestamp <= '${startInstant}' ORDER BY timestamp DESC, num DESC LIMIT 1)
312
318
  ` as string,
313
319
  })
314
320
  const rows = result.rows.flatMap((row) => {
@@ -76,7 +76,7 @@ describe('resolve', () => {
76
76
  const getClient = Viem.createGetClient({
77
77
  defaultChainId: tempoTestnet.id,
78
78
  rpc: TestViem.rpc,
79
- zones: [Zones.zoneModerato],
79
+ zones: [Zones.zoneModeratoInternal],
80
80
  })
81
81
  const { instantLiquidity, sharePrice, state, ...discovery } = await EarnVaults.resolve({
82
82
  getClient,
@@ -84,7 +84,7 @@ describe('resolve', () => {
84
84
  chainId: tempoTestnet.id,
85
85
  vaultAddress,
86
86
  },
87
- zones: [Zones.zoneModerato],
87
+ zones: [Zones.zoneModeratoInternal],
88
88
  })
89
89
  expect({
90
90
  depositsPaused: state.depositsPaused,
@@ -51,10 +51,11 @@ describe('middleware', () => {
51
51
  `)
52
52
  })
53
53
 
54
- test('includes a failed optional funding transfer count', async () => {
54
+ test('includes failed optional funding counts', async () => {
55
55
  const entries: Log.Entry[] = []
56
56
  const api = app({ emit: (entry) => void entries.push(entry) })
57
57
  api.get('/v1/funding/transfers', (c) => {
58
+ c.set('fundingDepositCountFailed', true)
58
59
  c.set('fundingTransferCountFailed', true)
59
60
  return c.json({ data: [], nextCursor: null }, 200)
60
61
  })
@@ -63,6 +64,7 @@ describe('middleware', () => {
63
64
 
64
65
  expect(stable(entries[0]!)).toMatchInlineSnapshot(`
65
66
  {
67
+ "fundingDepositCountFailed": true,
66
68
  "fundingTransferCountFailed": true,
67
69
  "level": "info",
68
70
  "method": "GET",
@@ -10,10 +10,14 @@ import type * as Timing from './Timing.js'
10
10
 
11
11
  /** Hono variables read by the request logger and written by other layers. */
12
12
  export type Variables = {
13
+ /** Tempo chain selected for the request. */
14
+ chainId?: number | undefined
13
15
  /** Whether the original request exception was already reported. */
14
16
  errorCaptured?: boolean | undefined
15
17
  /** Stable error code recorded by `Response.error` for the request log entry. */
16
18
  errorCode?: string | undefined
19
+ /** Whether the optional funding deposit count query failed. */
20
+ fundingDepositCountFailed?: true | undefined
17
21
  /** Bounded MPP relay outcome emitted by the credential routes. */
18
22
  mpp?: Entry['mpp'] | undefined
19
23
  /** Bounded outcomes for funding providers attempted by the request. */
@@ -125,12 +129,16 @@ export type Sponsorship = {
125
129
  export type Entry = {
126
130
  /** `'hit'` when the edge response cache served the request without reaching the origin handlers. */
127
131
  cache?: 'hit' | undefined
132
+ /** Tempo chain selected for the request. */
133
+ chainId?: number | undefined
128
134
  /** Total request duration in milliseconds. */
129
135
  duration: number
130
136
  /** Whether the original request exception was already reported. */
131
137
  errorCaptured?: boolean | undefined
132
138
  /** Stable error code when the response is a JSON error envelope (e.g. `not_found`, `api_key_invalid`). */
133
139
  errorCode?: string | undefined
140
+ /** Whether the optional funding deposit count query failed. */
141
+ fundingDepositCountFailed?: true | undefined
134
142
  /** Bounded funding provider attempts made while serving the request. */
135
143
  fundingProviderAttempts?: readonly FundingProviderAttempt[] | undefined
136
144
  /** Whether the optional funding transfer count query failed. */
@@ -253,7 +261,11 @@ export function middleware<environment extends Environment = Environment>(
253
261
  const error = c.error as ErrorMetadata | undefined
254
262
  const principal = c.get('principal' as never) as Auth.Principal | undefined
255
263
  const errorCaptured = c.get('errorCaptured' as never) as boolean | undefined
264
+ const chainId = c.get('chainId' as never) as number | undefined
256
265
  const errorCode = c.get('errorCode' as never) as string | undefined
266
+ const fundingDepositCountFailed = c.get('fundingDepositCountFailed' as never) as
267
+ | true
268
+ | undefined
257
269
  const fundingProviderAttempts =
258
270
  error?.fundingProviderAttempts ??
259
271
  (c.get('fundingProviderAttempts' as never) as Entry['fundingProviderAttempts'] | undefined)
@@ -300,9 +312,11 @@ export function middleware<environment extends Environment = Environment>(
300
312
 
301
313
  const result = fn({
302
314
  ...(c.get('edgeCacheStatus' as never) === 'hit' ? { cache: 'hit' as const } : {}),
315
+ ...(chainId === undefined ? {} : { chainId }),
303
316
  duration: round(duration),
304
317
  ...(errorCaptured === undefined ? {} : { errorCaptured }),
305
318
  ...(errorCode === undefined ? {} : { errorCode }),
319
+ ...(fundingDepositCountFailed === undefined ? {} : { fundingDepositCountFailed }),
306
320
  ...(fundingProviderAttempts === undefined ? {} : { fundingProviderAttempts }),
307
321
  ...(fundingTransferCountFailed === undefined ? {} : { fundingTransferCountFailed }),
308
322
  level:
@@ -11,7 +11,7 @@ type MetricEvent = {
11
11
  }
12
12
 
13
13
  describe('requests', () => {
14
- test('records optional funding transfer count failures on successful pages', () => {
14
+ test('records optional funding count failures on successful pages', () => {
15
15
  const events: MetricEvent[] = []
16
16
  const metrics = Metrics.from({
17
17
  count(name, value, tags) {
@@ -25,6 +25,7 @@ describe('requests', () => {
25
25
 
26
26
  record({
27
27
  duration: 10,
28
+ fundingDepositCountFailed: true,
28
29
  fundingTransferCountFailed: true,
29
30
  level: 'info',
30
31
  method: 'GET',
@@ -34,6 +35,22 @@ describe('requests', () => {
34
35
  status: 200,
35
36
  })
36
37
 
38
+ expect(events.find((event) => event.name === 'funding_deposit_count_failure_count'))
39
+ .toMatchInlineSnapshot(`
40
+ {
41
+ "name": "funding_deposit_count_failure_count",
42
+ "tags": {
43
+ "cache": "miss",
44
+ "method": "GET",
45
+ "principal_environment": "none",
46
+ "principal_type": "unknown",
47
+ "route": "/v1/funding/transfers",
48
+ },
49
+ "type": "count",
50
+ "value": 1,
51
+ }
52
+ `)
53
+
37
54
  expect(events.find((event) => event.name === 'funding_transfer_count_failure_count'))
38
55
  .toMatchInlineSnapshot(`
39
56
  {
@@ -637,6 +654,93 @@ describe('webhooks', () => {
637
654
  `)
638
655
  })
639
656
 
657
+ test('records scan pipeline blocks, backlog, and finality lag', () => {
658
+ const events: MetricEvent[] = []
659
+ const metrics = Metrics.from({
660
+ count(name, value, tags) {
661
+ events.push({ name, tags, type: 'count', value })
662
+ },
663
+ flush() {},
664
+ gauge(name, value, tags) {
665
+ events.push({ name, tags, type: 'gauge', value })
666
+ },
667
+ histogram() {},
668
+ })
669
+ const sink = MetricSink.webhooks(metrics)
670
+
671
+ sink.record({ chainId: 42431, matches: 3, origin: 'socket', outcome: 'dispatched', type: 'webhook:scan-block' }) // prettier-ignore
672
+ sink.record({ chainId: 42431, matches: 0, origin: 'poll', outcome: 'deferred', type: 'webhook:scan-block' }) // prettier-ignore
673
+ sink.record({ chainId: 42431, oldestPendingAgeMs: 5_000, pendingCount: 2, type: 'webhook:scan-backlog' }) // prettier-ignore
674
+ sink.record({ chainId: 42431, lagBlocks: 1, type: 'webhook:scan-finality' })
675
+
676
+ expect(events.map(({ name, tags, value }) => `${name}=${value} ${JSON.stringify(tags)}`))
677
+ .toMatchInlineSnapshot(`
678
+ [
679
+ "webhook_scan_block_count=1 {"chain_id":"42431","origin":"socket","outcome":"dispatched"}",
680
+ "webhook_scan_match_count=3 {"chain_id":"42431","origin":"socket","outcome":"dispatched"}",
681
+ "webhook_scan_block_count=1 {"chain_id":"42431","origin":"poll","outcome":"deferred"}",
682
+ "webhook_scan_pending_count=2 {"chain_id":"42431"}",
683
+ "webhook_scan_oldest_pending_age_ms=5000 {"chain_id":"42431"}",
684
+ "webhook_scan_finality_lag_blocks=1 {"chain_id":"42431"}",
685
+ ]
686
+ `)
687
+ })
688
+
689
+ test('records obligation staging and claim outcomes', () => {
690
+ const events: MetricEvent[] = []
691
+ const metrics = Metrics.from({
692
+ count(name, value, tags) {
693
+ events.push({ name, tags, type: 'count', value })
694
+ },
695
+ flush() {},
696
+ gauge() {},
697
+ histogram() {},
698
+ })
699
+ const sink = MetricSink.webhooks(metrics)
700
+
701
+ sink.record({ count: 3, outcome: 'created', type: 'webhook:delivery-job-ensure' })
702
+ sink.record({ count: 2, outcome: 'existing', type: 'webhook:delivery-job-ensure' })
703
+ sink.record({ outcome: 'claimed', type: 'webhook:delivery-job-claim' })
704
+ sink.record({ outcome: 'terminal', type: 'webhook:delivery-job-claim' })
705
+
706
+ expect(events).toMatchInlineSnapshot(`
707
+ [
708
+ {
709
+ "name": "webhook_delivery_job_ensure_count",
710
+ "tags": {
711
+ "outcome": "created",
712
+ },
713
+ "type": "count",
714
+ "value": 3,
715
+ },
716
+ {
717
+ "name": "webhook_delivery_job_ensure_count",
718
+ "tags": {
719
+ "outcome": "existing",
720
+ },
721
+ "type": "count",
722
+ "value": 2,
723
+ },
724
+ {
725
+ "name": "webhook_delivery_job_claim_count",
726
+ "tags": {
727
+ "outcome": "claimed",
728
+ },
729
+ "type": "count",
730
+ "value": 1,
731
+ },
732
+ {
733
+ "name": "webhook_delivery_job_claim_count",
734
+ "tags": {
735
+ "outcome": "terminal",
736
+ },
737
+ "type": "count",
738
+ "value": 1,
739
+ },
740
+ ]
741
+ `)
742
+ })
743
+
640
744
  test('records scan boundaries with bounded workload dimensions', () => {
641
745
  const events: MetricEvent[] = []
642
746
  const metrics = Metrics.from({
@@ -867,6 +971,7 @@ describe('webhooks', () => {
867
971
  } satisfies Webhooks.Envelope
868
972
 
869
973
  sink.record({
974
+ attempt: 1,
870
975
  envelope,
871
976
  queueAttempt: 2,
872
977
  result: { durationMs: 25, ok: true, status: 204 },
@@ -957,6 +1062,7 @@ describe('webhooks', () => {
957
1062
  expect(JSON.stringify(events)).not.toMatch(/evt_private|org_private|wh_private|whsec_private/)
958
1063
 
959
1064
  sink.record({
1065
+ attempt: 3,
960
1066
  envelope,
961
1067
  queueAttempt: 3,
962
1068
  result: { error: 'upstream unavailable', ok: false, status: 503 },
@@ -967,7 +1073,7 @@ describe('webhooks', () => {
967
1073
 
968
1074
  expect(events.at(-1)?.tags).toMatchObject({
969
1075
  queue_attempt_bucket: '3-5',
970
- queue_disposition: 'retried',
1076
+ queue_disposition: 'scheduled_retry',
971
1077
  })
972
1078
  })
973
1079
  })
@@ -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,49 @@ export type WebhookEvent =
145
160
  type: 'webhook:enqueue'
146
161
  }
147
162
  | {
163
+ chainId: number
164
+ /** Rows matched when dispatched; zero otherwise. */
165
+ matches: number
166
+ /** How the head that enqueued this block was observed. */
167
+ origin: string
168
+ /** One scanned block's outcome. */
169
+ outcome: 'deferred' | 'dispatched' | 'empty'
170
+ /** Identifies one block-fanout scan. */
171
+ type: 'webhook:scan-block'
172
+ }
173
+ | {
174
+ chainId: number
175
+ /** Age of the oldest tracked block, absent when none are pending. */
176
+ oldestPendingAgeMs?: number | undefined
177
+ /** Tracked blocks awaiting completion reports. */
178
+ pendingCount: number
179
+ /** Identifies the coordinator's tracked-scan backlog. */
180
+ type: 'webhook:scan-backlog'
181
+ }
182
+ | {
183
+ chainId: number
184
+ /** Blocks between the observed head and the finalized head. */
185
+ lagBlocks: number
186
+ /** Identifies the finality gate's settling distance. */
187
+ type: 'webhook:scan-finality'
188
+ }
189
+ | {
190
+ /** Atomic ledger claim outcome. */
191
+ outcome: 'attempting' | 'claimed' | 'missing' | 'scheduled' | 'terminal'
192
+ /** Identifies a delivery-obligation claim. */
193
+ type: 'webhook:delivery-job-claim'
194
+ }
195
+ | {
196
+ /** Obligations represented by this outcome. */
197
+ count: number
198
+ /** Whether staging created or found the obligation. */
199
+ outcome: 'created' | 'existing'
200
+ /** Identifies obligation staging ahead of Queue handoff. */
201
+ type: 'webhook:delivery-job-ensure'
202
+ }
203
+ | {
204
+ /** Delivery attempt ordinal: the ledger claim count on the queue path. */
205
+ attempt: number
148
206
  envelope: Webhooks.Envelope
149
207
  /** Cloudflare Queue delivery attempt, absent outside the Queue path. */
150
208
  queueAttempt?: number | undefined
@@ -244,6 +302,9 @@ export function requests(backend: Metrics): RequestSink {
244
302
  backend.histogram('funding_quote_provider_duration_ms', attempt.durationMs, tags_attempt)
245
303
  }
246
304
 
305
+ if (entry.fundingDepositCountFailed)
306
+ backend.count('funding_deposit_count_failure_count', 1, tags)
307
+
247
308
  if (entry.fundingTransferCountFailed)
248
309
  backend.count('funding_transfer_count_failure_count', 1, tags)
249
310
 
@@ -340,6 +401,16 @@ export function webhooks(backend: Metrics): WebhookSink {
340
401
  chain_id: String(event.chainId),
341
402
  })
342
403
  return
404
+ case 'webhook:head-skipped':
405
+ backend.gauge('webhook_head_skipped_count', event.skippedHeads, {
406
+ chain_id: String(event.chainId),
407
+ })
408
+ return
409
+ case 'webhook:listener-head-lag':
410
+ backend.gauge('webhook_listener_head_lag_blocks', event.lagBlocks, {
411
+ chain_id: String(event.chainId),
412
+ })
413
+ return
343
414
  case 'webhook:poll-failure':
344
415
  backend.count('webhook_poll_failure_count', 1, {
345
416
  chain_id: String(event.chainId),
@@ -456,6 +527,38 @@ export function webhooks(backend: Metrics): WebhookSink {
456
527
  })
457
528
  return
458
529
  }
530
+ case 'webhook:scan-block': {
531
+ const tags = {
532
+ chain_id: String(event.chainId),
533
+ origin: event.origin,
534
+ outcome: event.outcome,
535
+ }
536
+ backend.count('webhook_scan_block_count', 1, tags)
537
+ if (event.matches > 0) backend.count('webhook_scan_match_count', event.matches, tags)
538
+ return
539
+ }
540
+ case 'webhook:scan-backlog': {
541
+ const tags = { chain_id: String(event.chainId) }
542
+ backend.gauge('webhook_scan_pending_count', event.pendingCount, tags)
543
+ backend.gauge('webhook_scan_oldest_pending_age_ms', event.oldestPendingAgeMs ?? 0, tags)
544
+ return
545
+ }
546
+ case 'webhook:scan-finality': {
547
+ backend.gauge('webhook_scan_finality_lag_blocks', event.lagBlocks, {
548
+ chain_id: String(event.chainId),
549
+ })
550
+ return
551
+ }
552
+ case 'webhook:delivery-job-claim': {
553
+ backend.count('webhook_delivery_job_claim_count', 1, { outcome: event.outcome })
554
+ return
555
+ }
556
+ case 'webhook:delivery-job-ensure': {
557
+ backend.count('webhook_delivery_job_ensure_count', event.count, {
558
+ outcome: event.outcome,
559
+ })
560
+ return
561
+ }
459
562
  case 'webhook:delivery': {
460
563
  const { envelope, result, subscription, timings } = event
461
564
  const queueTags =
@@ -463,7 +566,11 @@ export function webhooks(backend: Metrics): WebhookSink {
463
566
  ? {}
464
567
  : {
465
568
  queue_attempt_bucket: attemptBucket(event.queueAttempt),
466
- queue_disposition: WebhookDestination.isRetryable(result) ? 'retried' : 'acked',
569
+ // Retryable results ack the message and schedule a ledger
570
+ // retry; the Queue transport never redelivers them.
571
+ queue_disposition: WebhookDestination.isRetryable(result)
572
+ ? 'scheduled_retry'
573
+ : 'acked',
467
574
  }
468
575
  const tags = {
469
576
  chain_id: String(envelope.chainId),
@@ -473,7 +580,7 @@ export function webhooks(backend: Metrics): WebhookSink {
473
580
  const outcome = result.ok ? 'succeeded' : 'failed'
474
581
  backend.count('webhook_delivery_attempt_count', 1, {
475
582
  ...tags,
476
- attempt_bucket: attemptBucket(subscription.failureCount + 1),
583
+ attempt_bucket: attemptBucket(event.attempt),
477
584
  outcome,
478
585
  ...queueTags,
479
586
  status_class: statusClass(result),
@@ -495,6 +602,9 @@ export function webhooks(backend: Metrics): WebhookSink {
495
602
  if (timings?.endToEndMs !== undefined)
496
603
  backend.histogram('webhook_delivery_end_to_end_ms', timings.endToEndMs, {
497
604
  ...tags,
605
+ // Unlimited-horizon ledger retries emit hour-plus samples;
606
+ // the bucket keeps first-attempt percentiles interpretable.
607
+ attempt_bucket: attemptBucket(event.attempt),
498
608
  outcome,
499
609
  })
500
610
  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
  }