accounts 0.8.6 → 0.8.8

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 (33) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/core/Schema.d.ts +2 -0
  3. package/dist/core/Schema.d.ts.map +1 -1
  4. package/dist/core/Storage.js +1 -1
  5. package/dist/core/Storage.js.map +1 -1
  6. package/dist/core/zod/rpc.d.ts +2 -0
  7. package/dist/core/zod/rpc.d.ts.map +1 -1
  8. package/dist/core/zod/rpc.js +2 -0
  9. package/dist/core/zod/rpc.js.map +1 -1
  10. package/dist/server/internal/handlers/exchange.d.ts.map +1 -1
  11. package/dist/server/internal/handlers/exchange.js +5 -19
  12. package/dist/server/internal/handlers/exchange.js.map +1 -1
  13. package/dist/server/internal/handlers/relay.d.ts +20 -12
  14. package/dist/server/internal/handlers/relay.d.ts.map +1 -1
  15. package/dist/server/internal/handlers/relay.js +152 -84
  16. package/dist/server/internal/handlers/relay.js.map +1 -1
  17. package/dist/server/internal/kv.d.ts +7 -4
  18. package/dist/server/internal/kv.d.ts.map +1 -1
  19. package/dist/server/internal/kv.js +70 -11
  20. package/dist/server/internal/kv.js.map +1 -1
  21. package/dist/server/internal/tokenlist.d.ts +53 -0
  22. package/dist/server/internal/tokenlist.d.ts.map +1 -0
  23. package/dist/server/internal/tokenlist.js +38 -0
  24. package/dist/server/internal/tokenlist.js.map +1 -0
  25. package/package.json +1 -1
  26. package/src/core/Storage.ts +1 -1
  27. package/src/core/zod/rpc.ts +2 -0
  28. package/src/server/internal/handlers/exchange.ts +7 -31
  29. package/src/server/internal/handlers/relay.test.ts +61 -15
  30. package/src/server/internal/handlers/relay.ts +184 -94
  31. package/src/server/internal/kv.test.ts +129 -0
  32. package/src/server/internal/kv.ts +76 -10
  33. package/src/server/internal/tokenlist.ts +91 -0
@@ -15,15 +15,20 @@ import {
15
15
  } from 'viem'
16
16
  import type { LocalAccount } from 'viem/accounts'
17
17
  import { simulateCalls } from 'viem/actions'
18
- import { tempo, tempoDevnet, tempoLocalnet, tempoMainnet, tempoModerato } from 'viem/chains'
18
+ import { tempo, tempoDevnet, tempoModerato } from 'viem/chains'
19
19
  import { Abis, Actions, Addresses, Capabilities, Transaction } from 'viem/tempo'
20
20
 
21
21
  import * as ExecutionError from '../../../core/ExecutionError.js'
22
22
  import * as Schema from '../../../core/Schema.js'
23
23
  import { type Handler, from } from '../../Handler.js'
24
+ import * as Kv from '../../Kv.js'
25
+ import * as Tokenlist from '../tokenlist.js'
24
26
  import * as Sponsorship from './sponsorship.js'
25
27
  import * as Utils from './utils.js'
26
28
 
29
+ /** Default cache TTL in seconds (10 minutes) for the verified tokenlist. */
30
+ const defaultCacheTtl = 10 * 60
31
+
27
32
  /**
28
33
  * Instantiates a relay handler that proxies `eth_fillTransaction`
29
34
  * with wallet-aware enrichment (fee token resolution, simulation,
@@ -64,16 +69,25 @@ import * as Utils from './utils.js'
64
69
  */
65
70
  export function relay(options: relay.Options = {}): Handler {
66
71
  const {
72
+ cacheTtl = defaultCacheTtl,
67
73
  chains = [tempo, tempoModerato, tempoDevnet],
74
+ kv = Kv.memory(),
68
75
  onRequest,
69
76
  path = '/',
70
- resolveTokens = (chainId) =>
71
- relay.defaultTokens[chainId as keyof typeof relay.defaultTokens] ?? [],
77
+ resolveTokens,
72
78
  transports = {},
73
79
  ...rest
74
80
  } = options
75
81
  const feePayerOptions = options.feePayer
76
82
 
83
+ // Resolves the verified tokenlist for `chainId`, sharing the KV cache with
84
+ // `Handler.exchange` so a single `kv: Kv.cloudflare(env.KV)` covers both.
85
+ // The relay only needs addresses, so map down from the full metadata.
86
+ const getTokens = async (chainId: number): Promise<readonly Address[]> => {
87
+ const tokens = await Tokenlist.fetch(chainId, kv, { cacheTtl, resolver: resolveTokens })
88
+ return tokens.map((t) => t.address)
89
+ }
90
+
77
91
  const features = {
78
92
  autoSwap: options.autoSwap ?? options.features === 'all',
79
93
  feeTokenResolution: options.resolveTokens ?? options.features === 'all',
@@ -149,16 +163,29 @@ export function relay(options: relay.Options = {}): Handler {
149
163
 
150
164
  // Lazily resolve a swap source token when autoSwap needs one.
151
165
  const resolveFeeTokenForSwap = from
152
- ? (insufficientToken: Address) =>
166
+ ? async (insufficientToken: Address) =>
153
167
  resolveFeeToken(client, {
154
168
  account: from,
155
169
  feeToken: undefined,
156
- tokens: (resolveTokens(chainId) ?? []).filter(
170
+ tokens: (await getTokens(chainId)).filter(
157
171
  (t) => t.toLowerCase() !== insufficientToken.toLowerCase(),
158
172
  ),
159
173
  })
160
174
  : undefined
161
175
 
176
+ // When no sponsor will pay, prefer fee tokens the user actually
177
+ // holds. Extend the configured token list with any TIP20 token
178
+ // the transaction is calling — typically the token being
179
+ // transferred — so a user transferring USDC.e can pay gas in
180
+ // USDC.e even when the configured list defaults to pathUSD.
181
+ const configuredTokens = await getTokens(chainId)
182
+ const unsponsoredTokens = [
183
+ ...configuredTokens,
184
+ ...callTargetTokens(baseTx).filter(
185
+ (t) => !configuredTokens.some((rt) => rt.toLowerCase() === t.toLowerCase()),
186
+ ),
187
+ ]
188
+
162
189
  // When the app provides its own fee payer URL, route the fill
163
190
  // through that service so it can sign the transaction.
164
191
  const fillClient = externalFeePayerUrl
@@ -205,14 +232,30 @@ export function relay(options: relay.Options = {}): Handler {
205
232
  })
206
233
  filled = prepared
207
234
  } else {
235
+ // Resolve a fee token for the unsponsored fallback so the
236
+ // node doesn't pick one the user has zero balance of.
237
+ const unsponsoredFeeToken = features.feeTokenResolution
238
+ ? await resolveFeeToken(client, {
239
+ account: from,
240
+ feeToken: requestFeeToken,
241
+ tokens: unsponsoredTokens,
242
+ })
243
+ : requestFeeToken
244
+ const unsponsoredTx = {
245
+ ...baseTx,
246
+ ...(unsponsoredFeeToken ? { feeToken: unsponsoredFeeToken } : {}),
247
+ }
208
248
  const fillOptions = {
209
249
  autoSwap,
210
- feeToken,
211
250
  resolveFeeToken: resolveFeeTokenForSwap,
212
251
  }
213
252
  const [sponsoredFill, unsponsoredFill] = await Promise.all([
214
- fill(fillClient, { ...fillOptions, transaction: sponsoredTx }),
215
- fill(client, { ...fillOptions, transaction: baseTx }),
253
+ fill(fillClient, { ...fillOptions, feeToken, transaction: sponsoredTx }),
254
+ fill(client, {
255
+ ...fillOptions,
256
+ feeToken: unsponsoredFeeToken,
257
+ transaction: unsponsoredTx,
258
+ }),
216
259
  ])
217
260
  sponsored = await Sponsorship.shouldSponsor({
218
261
  sender: from,
@@ -220,6 +263,7 @@ export function relay(options: relay.Options = {}): Handler {
220
263
  validate: feePayerOptions!.validate,
221
264
  })
222
265
  filled = sponsored ? sponsoredFill : unsponsoredFill
266
+ if (!sponsored) feeToken = unsponsoredFeeToken
223
267
  }
224
268
  } else {
225
269
  // Path C: no sponsorship configured — resolve fee token, fill once.
@@ -227,7 +271,7 @@ export function relay(options: relay.Options = {}): Handler {
227
271
  ? await resolveFeeToken(client, {
228
272
  account: from,
229
273
  feeToken: requestFeeToken,
230
- tokens: resolveTokens(chainId),
274
+ tokens: unsponsoredTokens,
231
275
  })
232
276
  : requestFeeToken
233
277
  const transaction = { ...baseTx, ...(feeToken ? { feeToken } : {}) }
@@ -243,7 +287,8 @@ export function relay(options: relay.Options = {}): Handler {
243
287
  const swap = 'swap' in filled ? filled.swap : undefined
244
288
  if (!feeToken)
245
289
  feeToken =
246
- (transaction_filled.feeToken as Address | undefined) ?? resolveTokens(chainId)?.[0]
290
+ (transaction_filled.feeToken as Address | undefined) ??
291
+ (await getTokens(chainId))[0]
247
292
 
248
293
  // Parallelize: simulate, fee payer signing, and autoSwap metadata.
249
294
  const alreadySigned =
@@ -448,45 +493,6 @@ export function relay(options: relay.Options = {}): Handler {
448
493
  }
449
494
 
450
495
  export namespace relay {
451
- /** Default token lists per chain ID for fee token resolution. */
452
- // TODO: extract from tokenlist workspace.
453
- export const defaultTokens = {
454
- [tempoMainnet.id]: [
455
- '0x20c0000000000000000000000000000000000000', // pathUSD
456
- '0x20c000000000000000000000b9537d11c60e8b50', // USDC.e
457
- '0x20c0000000000000000000001621e21f71cf12fb', // EURC.e
458
- '0x20c00000000000000000000014f22ca97301eb73', // USDT0
459
- '0x20c0000000000000000000003554d28269e0f3c2', // frxUSD
460
- '0x20c0000000000000000000000520792dcccccccc', // cUSD
461
- '0x20c0000000000000000000008ee4fcff88888888', // stcUSD
462
- '0x20c0000000000000000000005c0bac7cef389a11', // GUSD
463
- '0x20c0000000000000000000007f7ba549dd0251b9', // rUSD
464
- '0x20c000000000000000000000aeed2ec36a54d0e5', // wsrUSD
465
- '0x20c0000000000000000000009a4a4b17e0dc6651', // EURAU
466
- '0x20c000000000000000000000383a23bacb546ab9', // reUSD
467
- ],
468
- [tempoModerato.id]: [
469
- '0x20c0000000000000000000000000000000000000', // pathUSD
470
- '0x20c0000000000000000000000000000000000001', // alphaUSD
471
- '0x20c0000000000000000000000000000000000002', // betaUSD
472
- '0x20c0000000000000000000000000000000000003', // thetaUSD
473
- '0x20c0000000000000000000009e8d7eb59b783726', // USDC.e
474
- '0x20c000000000000000000000d72572838bbee59c', // EURC.e
475
- ],
476
- [tempoDevnet.id]: [
477
- '0x20c0000000000000000000000000000000000000', // pathUSD
478
- '0x20c0000000000000000000000000000000000001', // alphaUSD
479
- '0x20c0000000000000000000000000000000000002', // betaUSD
480
- '0x20c0000000000000000000000000000000000003', // thetaUSD
481
- ],
482
- [tempoLocalnet.id]: [
483
- '0x20c0000000000000000000000000000000000000', // pathUSD
484
- '0x20c0000000000000000000000000000000000001', // alphaUSD
485
- '0x20c0000000000000000000000000000000000002', // betaUSD
486
- '0x20c0000000000000000000000000000000000003', // thetaUSD
487
- ],
488
- } as const
489
-
490
496
  export type Options = from.Options & {
491
497
  /**
492
498
  * Auto-swap options.
@@ -498,6 +504,11 @@ export namespace relay {
498
504
  slippage?: number | undefined
499
505
  }
500
506
  | undefined
507
+ /**
508
+ * TTL in seconds for cached `resolveTokens` results.
509
+ * @default 600 (10 minutes)
510
+ */
511
+ cacheTtl?: number | undefined
501
512
  /**
502
513
  * Supported chains. The handler resolves the client based on the
503
514
  * `chainId` in the incoming transaction.
@@ -526,11 +537,21 @@ export namespace relay {
526
537
  }
527
538
  | undefined
528
539
  /**
529
- * Returns token addresses to check balances for during fee token resolution.
530
- * The relay checks `balanceOf` for each token and picks the one with the
531
- * highest balance.
540
+ * Kv store used to cache `resolveTokens` results across requests.
541
+ * Provide `Kv.cloudflare(env.KV)` for cross-instance caching, or omit
542
+ * for an in-process LRU.
543
+ * @default Kv.memory()
532
544
  */
533
- resolveTokens?: ((chainId?: number | undefined) => readonly Address[]) | undefined
545
+ kv?: Kv.Kv | undefined
546
+ /**
547
+ * Resolves the list of known tokens for a chain. The relay checks
548
+ * `balanceOf` for each token and picks the one with the highest balance
549
+ * during fee token resolution.
550
+ * @default Fetches `https://tokenlist.tempo.xyz/list/:chainId`
551
+ */
552
+ resolveTokens?:
553
+ | ((chainId: number) => readonly Tokenlist.Token[] | Promise<readonly Tokenlist.Token[]>)
554
+ | undefined
534
555
  /**
535
556
  * Relay features.
536
557
  *
@@ -564,6 +585,51 @@ async function fill(
564
585
  const format = (value: Record<string, unknown>) =>
565
586
  value.type === '0x76' ? value : Utils.formatFillTransactionRequest(client, value)
566
587
 
588
+ // Re-fill the transaction with prepended swap calls so the user can
589
+ // mint the missing `insufficientToken` (typically the fee token) from
590
+ // a token they already hold. Returns null if no source token is
591
+ // available or autoSwap is disabled.
592
+ async function fillWithSwap(insufficientToken: Address, deficit: bigint) {
593
+ if (!autoSwap) return null
594
+ const sourceToken =
595
+ feeToken && feeToken.toLowerCase() !== insufficientToken.toLowerCase()
596
+ ? feeToken
597
+ : await options.resolveFeeToken?.(insufficientToken)
598
+ if (!sourceToken || sourceToken.toLowerCase() === insufficientToken.toLowerCase()) return null
599
+
600
+ const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n
601
+ const originalCalls = (request.calls as Call[] | undefined) ?? []
602
+ const swapCalls = buildSwapCalls(sourceToken, insufficientToken, deficit, maxAmountIn)
603
+
604
+ const result = await client.request({
605
+ method: 'eth_fillTransaction',
606
+ params: [
607
+ format({
608
+ ...request,
609
+ calls: [...swapCalls, ...originalCalls],
610
+ }) as never,
611
+ ],
612
+ })
613
+ const sponsor = (result as Record<string, any>).capabilities?.sponsor as
614
+ | { address: Address; name?: string; url?: string }
615
+ | undefined
616
+ const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
617
+ ...request,
618
+ calls: [...swapCalls, ...originalCalls],
619
+ })
620
+ return {
621
+ transaction: Utils.normalizeTempoTransaction(mergedTx),
622
+ sponsor,
623
+ swap: {
624
+ calls: swapCalls,
625
+ tokenIn: sourceToken,
626
+ tokenOut: insufficientToken,
627
+ amountOut: deficit,
628
+ maxAmountIn,
629
+ },
630
+ }
631
+ }
632
+
567
633
  try {
568
634
  const formatted = format(request)
569
635
  const result = await client.request({
@@ -602,6 +668,46 @@ async function fill(
602
668
  // them in from the original request before normalizing — otherwise the
603
669
  // typed envelope built for sponsorship signing throws CallsEmptyError.
604
670
  const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, request)
671
+
672
+ // The node's `eth_fillTransaction` may pick a fee token the user
673
+ // doesn't yet hold (e.g. one this transaction will mint via a
674
+ // swap). Tempo's gas check runs before the calls execute, so the
675
+ // tx would revert at broadcast with `have 0 want N`. Validate the
676
+ // sender's pre-tx balance against the computed gas cost and, if
677
+ // short, autoSwap a token they do hold into the fee token.
678
+ if (autoSwap && !swap) {
679
+ const fromAddress = request.from as Address | undefined
680
+ const resolvedFeeToken = ((mergedTx.feeToken as Address | undefined) ?? feeToken) as
681
+ | Address
682
+ | undefined
683
+ const gas = mergedTx.gas ? BigInt(mergedTx.gas as `0x${string}`) : 0n
684
+ const maxFeePerGas = mergedTx.maxFeePerGas
685
+ ? BigInt(mergedTx.maxFeePerGas as `0x${string}`)
686
+ : 0n
687
+ if (fromAddress && resolvedFeeToken && gas > 0n && maxFeePerGas > 0n) {
688
+ const [balance, metadata] = await Promise.all([
689
+ Actions.token
690
+ .getBalance(client, { account: fromAddress, token: resolvedFeeToken })
691
+ .catch(() => 0n),
692
+ resolveTokenMetadata(client, resolvedFeeToken).catch(() => undefined),
693
+ ])
694
+ if (metadata) {
695
+ const requiredFee =
696
+ (gas * maxFeePerGas) / 10n ** BigInt(Math.max(0, 18 - metadata.decimals))
697
+ if (balance < requiredFee) {
698
+ // Best-effort: if the swap itself can't be filled (e.g. the
699
+ // source token also has insufficient balance), fall through
700
+ // and return the original tx with the resolved feeToken
701
+ // rather than failing the whole request.
702
+ const swapResult = await fillWithSwap(resolvedFeeToken, requiredFee - balance).catch(
703
+ () => null,
704
+ )
705
+ if (swapResult) return swapResult
706
+ }
707
+ }
708
+ }
709
+ }
710
+
605
711
  return {
606
712
  transaction: Utils.normalizeTempoTransaction(mergedTx),
607
713
  sponsor,
@@ -617,47 +723,9 @@ async function fill(
617
723
  const [available, required, token] = revert.args
618
724
  if (typeof available === 'undefined' || typeof required === 'undefined' || !token) throw error
619
725
 
620
- // Resolve a source token for the swap: use the provided feeToken,
621
- // or fall back to resolveFeeToken() to find one the sender holds.
622
- const sourceToken =
623
- feeToken && feeToken.toLowerCase() !== token.toLowerCase()
624
- ? feeToken
625
- : await options.resolveFeeToken?.(token as Address)
626
- if (!sourceToken || sourceToken.toLowerCase() === token.toLowerCase()) throw error
627
-
628
- const deficit = required - available
629
- const maxAmountIn = deficit + (deficit * BigInt(Math.round(autoSwap.slippage * 1000))) / 1000n
630
-
631
- const originalCalls = (request.calls as Call[] | undefined) ?? []
632
- const swapCalls = buildSwapCalls(sourceToken, token, deficit, maxAmountIn)
633
-
634
- const result = await client.request({
635
- method: 'eth_fillTransaction',
636
- params: [
637
- format({
638
- ...request,
639
- calls: [...swapCalls, ...originalCalls],
640
- }) as never,
641
- ],
642
- })
643
- const sponsor = (result as Record<string, any>).capabilities?.sponsor as
644
- | { address: Address; name?: string; url?: string }
645
- | undefined
646
- const mergedTx = mergeCallsFromRequest(result.tx as Record<string, unknown>, {
647
- ...request,
648
- calls: [...swapCalls, ...originalCalls],
649
- })
650
- return {
651
- transaction: Utils.normalizeTempoTransaction(mergedTx),
652
- sponsor,
653
- swap: {
654
- calls: swapCalls,
655
- tokenIn: sourceToken,
656
- tokenOut: token,
657
- amountOut: deficit,
658
- maxAmountIn,
659
- },
660
- }
726
+ const swapResult = await fillWithSwap(token as Address, required - available)
727
+ if (!swapResult) throw error
728
+ return swapResult
661
729
  }
662
730
  }
663
731
 
@@ -713,6 +781,28 @@ async function resolveFeeToken(
713
781
  if (best) return best.address
714
782
  }
715
783
 
784
+ /**
785
+ * Extracts unique TIP20 token addresses (Tempo `0x20c0…` prefix) that the
786
+ * transaction's calls target. Used as fallback fee-token candidates so a
787
+ * user transferring a token they hold can pay gas in that token even when
788
+ * the configured fee-token list defaults to one they don't hold.
789
+ */
790
+ function callTargetTokens(transaction: Record<string, unknown>): readonly Address[] {
791
+ const calls = transaction.calls as readonly { to?: Address }[] | undefined
792
+ if (!calls) return []
793
+ const out: Address[] = []
794
+ const seen = new Set<string>()
795
+ for (const c of calls) {
796
+ if (!c.to) continue
797
+ const lower = c.to.toLowerCase()
798
+ if (!lower.startsWith('0x20c0')) continue
799
+ if (seen.has(lower)) continue
800
+ seen.add(lower)
801
+ out.push(c.to)
802
+ }
803
+ return out
804
+ }
805
+
716
806
  // TODO: cleanup/remove
717
807
  function extractCalls(transaction: Record<string, unknown>): readonly Call[] {
718
808
  const calls = transaction.calls as readonly Call[] | undefined
@@ -0,0 +1,129 @@
1
+ import { describe, expect, test } from 'vp/test'
2
+
3
+ import * as Kv from '../Kv.js'
4
+ import { cached } from './kv.js'
5
+
6
+ /**
7
+ * Wraps a memory Kv and records the number of `get`/`set` calls per key —
8
+ * lets the tests below assert that L1 cache hits short-circuit L2 reads.
9
+ */
10
+ function counting() {
11
+ const inner = Kv.memory()
12
+ const calls = { get: 0, set: 0 }
13
+ const kv: Kv.Kv = {
14
+ async get(key) {
15
+ calls.get++
16
+ return inner.get(key)
17
+ },
18
+ async set(key, value) {
19
+ calls.set++
20
+ return inner.set(key, value)
21
+ },
22
+ async delete(key) {
23
+ return inner.delete(key)
24
+ },
25
+ }
26
+ return { calls, kv }
27
+ }
28
+
29
+ describe('cached', () => {
30
+ test('behavior: caches the result of fn for the configured ttl', async () => {
31
+ const { calls, kv } = counting()
32
+ let invocations = 0
33
+ const fn = async () => ++invocations
34
+
35
+ expect(await cached(kv, 'k', fn, { ttl: 60 })).toBe(1)
36
+ expect(await cached(kv, 'k', fn, { ttl: 60 })).toBe(1)
37
+ expect(invocations).toBe(1)
38
+ // First call writes through to L2; subsequent reads stay in L1.
39
+ expect(calls.set).toBe(1)
40
+ })
41
+
42
+ test('behavior: re-fetches after the entry expires', async () => {
43
+ const { kv } = counting()
44
+ let invocations = 0
45
+ const fn = async () => ++invocations
46
+
47
+ // ttl: 0 expires immediately (Date.now() + 0 is not strictly >).
48
+ expect(await cached(kv, 'k', fn, { ttl: 0 })).toBe(1)
49
+ expect(await cached(kv, 'k', fn, { ttl: 0 })).toBe(2)
50
+ expect(invocations).toBe(2)
51
+ })
52
+
53
+ test('behavior: L1 cache short-circuits L2 reads', async () => {
54
+ const { calls, kv } = counting()
55
+ await cached(kv, 'k', async () => 1, { ttl: 60 })
56
+ expect(calls.get).toBe(1) // miss → reads L2 once
57
+ await cached(kv, 'k', async () => 2, { ttl: 60 })
58
+ await cached(kv, 'k', async () => 3, { ttl: 60 })
59
+ // Subsequent calls hit L1 — no extra L2 reads.
60
+ expect(calls.get).toBe(1)
61
+ })
62
+
63
+ test('behavior: coalesces concurrent calls for the same key', async () => {
64
+ const { kv } = counting()
65
+ let invocations = 0
66
+ const fn = async () => {
67
+ invocations++
68
+ // Force the second call to start before fn() resolves.
69
+ await new Promise((r) => setTimeout(r, 10))
70
+ return invocations
71
+ }
72
+
73
+ const [a, b, c] = await Promise.all([
74
+ cached(kv, 'k', fn, { ttl: 60 }),
75
+ cached(kv, 'k', fn, { ttl: 60 }),
76
+ cached(kv, 'k', fn, { ttl: 60 }),
77
+ ])
78
+
79
+ expect(invocations).toBe(1)
80
+ expect([a, b, c]).toEqual([1, 1, 1])
81
+ })
82
+
83
+ test('behavior: isolates L1 cache per kv instance', async () => {
84
+ const a = counting()
85
+ const b = counting()
86
+ let invocations = 0
87
+ const fn = async () => ++invocations
88
+
89
+ await cached(a.kv, 'k', fn, { ttl: 60 })
90
+ await cached(b.kv, 'k', fn, { ttl: 60 })
91
+ expect(invocations).toBe(2)
92
+ })
93
+
94
+ test('behavior: falls back to fn when L2 read throws', async () => {
95
+ const failing: Kv.Kv = {
96
+ async get() {
97
+ throw new Error('boom')
98
+ },
99
+ async set() {},
100
+ async delete() {},
101
+ }
102
+
103
+ expect(await cached(failing, 'k', async () => 'value', { ttl: 60 })).toBe('value')
104
+ })
105
+
106
+ test('behavior: does not throw when L2 write fails', async () => {
107
+ const failing: Kv.Kv = {
108
+ async get() {
109
+ return null as never
110
+ },
111
+ async set() {
112
+ throw new Error('boom')
113
+ },
114
+ async delete() {},
115
+ }
116
+
117
+ expect(await cached(failing, 'k', async () => 'value', { ttl: 60 })).toBe('value')
118
+ })
119
+
120
+ test('behavior: caches forever when ttl is omitted', async () => {
121
+ const { kv } = counting()
122
+ let invocations = 0
123
+ const fn = async () => ++invocations
124
+
125
+ expect(await cached(kv, 'k', fn)).toBe(1)
126
+ expect(await cached(kv, 'k', fn)).toBe(1)
127
+ expect(invocations).toBe(1)
128
+ })
129
+ })
@@ -1,10 +1,33 @@
1
1
  import type * as Kv from '../Kv.js'
2
2
 
3
+ type Entry = { expiresAt: number; value: unknown }
4
+
5
+ /**
6
+ * Per-`kv`-instance in-memory L1 cache. Lives for the lifetime of the worker
7
+ * (or process) and short-circuits the L2 `kv.get` on hot keys so a request
8
+ * with N callers that share the same `cached()` key only pays for one
9
+ * remote read.
10
+ *
11
+ * `WeakMap` keys mean dropping a `kv` reference releases its L1 entries
12
+ * automatically — no need to thread a lifecycle hook through callers.
13
+ */
14
+ const memoryStore = new WeakMap<Kv.Kv, Map<string, Entry>>()
15
+
3
16
  /**
4
- * Reads `key` from `kv`. If absent or expired, calls `fn`, stores the result
5
- * with a `Date.now() + ttl * 1000` expiry, and returns it. Defaults to
6
- * caching forever (no expiry). Kv read/write failures are swallowed so cache
7
- * misses never break the caller.
17
+ * Per-`kv`-instance in-flight dedupe table. Multiple concurrent
18
+ * `cached(kv, key, fn)` calls for the same key share a single `fn()`
19
+ * invocation instead of stampeding the upstream.
20
+ */
21
+ const inflightStore = new WeakMap<Kv.Kv, Map<string, Promise<unknown>>>()
22
+
23
+ /**
24
+ * Reads `key` from a two-tier cache (in-memory L1, `kv`-backed L2). If absent
25
+ * or expired in both tiers, calls `fn`, stores the result with a
26
+ * `Date.now() + ttl * 1000` expiry in both tiers, and returns it. Defaults
27
+ * to caching forever (no expiry). Kv read/write failures are swallowed so
28
+ * cache misses never break the caller.
29
+ *
30
+ * Concurrent calls for the same key share a single `fn()` invocation.
8
31
  */
9
32
  export async function cached<value>(
10
33
  kv: Kv.Kv,
@@ -14,13 +37,38 @@ export async function cached<value>(
14
37
  ): Promise<value> {
15
38
  const { ttl = Infinity } = options
16
39
 
17
- const entry = await kv.get<{ expiresAt: number; value: value } | null>(key).catch(() => null)
18
- if (entry && entry.expiresAt > Date.now()) return entry.value
40
+ // L1: in-memory.
41
+ const memory = memoryFor(kv)
42
+ const local = memory.get(key)
43
+ if (local && local.expiresAt > Date.now()) return local.value as value
44
+
45
+ // Coalesce concurrent calls for the same key.
46
+ const inflight = inflightFor(kv)
47
+ const pending = inflight.get(key) as Promise<value> | undefined
48
+ if (pending) return pending
19
49
 
20
- const value = await fn()
21
- const expiresAt = ttl === Infinity ? Infinity : Date.now() + ttl * 1000
22
- await kv.set(key, { expiresAt, value }).catch(() => {})
23
- return value
50
+ const promise = (async () => {
51
+ // L2: kv.
52
+ const entry = await kv.get<Entry | null>(key).catch(() => null)
53
+ if (entry && entry.expiresAt > Date.now()) {
54
+ memory.set(key, entry)
55
+ return entry.value as value
56
+ }
57
+
58
+ const value = await fn()
59
+ const expiresAt = ttl === Infinity ? Infinity : Date.now() + ttl * 1000
60
+ const fresh: Entry = { expiresAt, value }
61
+ memory.set(key, fresh)
62
+ await kv.set(key, fresh).catch(() => {})
63
+ return value
64
+ })()
65
+
66
+ inflight.set(key, promise)
67
+ try {
68
+ return await promise
69
+ } finally {
70
+ inflight.delete(key)
71
+ }
24
72
  }
25
73
 
26
74
  export declare namespace cached {
@@ -33,3 +81,21 @@ export declare namespace cached {
33
81
  ttl?: number | undefined
34
82
  }
35
83
  }
84
+
85
+ function memoryFor(kv: Kv.Kv) {
86
+ let map = memoryStore.get(kv)
87
+ if (!map) {
88
+ map = new Map()
89
+ memoryStore.set(kv, map)
90
+ }
91
+ return map
92
+ }
93
+
94
+ function inflightFor(kv: Kv.Kv) {
95
+ let map = inflightStore.get(kv)
96
+ if (!map) {
97
+ map = new Map()
98
+ inflightStore.set(kv, map)
99
+ }
100
+ return map
101
+ }