import { HonoRequest } from 'hono/request' import { Store } from 'tapimo' import * as TestStore from '../../test/Store.js' import * as Timeout from './Timeout.js' describe('from', () => { const ops = { async delete() {}, async get() { return null }, async list() { return { keys: [] } }, async put() {}, } test('defaults to a state store when type is omitted', () => { expect(Store.from(ops).type).toMatchInlineSnapshot(`"state"`) }) test('opts into a cache store with type: cache', () => { expect(Store.from({ ...ops, type: 'cache' }).type).toMatchInlineSnapshot(`"cache"`) }) }) describe('increment', () => { test('falls back to a get + put read-modify-write without a native counter', async () => { const cells = new Map() const store = Store.from({ async delete(key) { cells.delete(key) }, async get(key) { return cells.get(key) ?? null }, async list() { return { keys: [] } }, async put(key, value) { cells.set(key, value) }, }) expect(await Store.increment(store, 'counter')).toMatchInlineSnapshot(`1`) expect(await Store.increment(store, 'counter')).toMatchInlineSnapshot(`2`) expect(cells.get('counter')).toMatchInlineSnapshot(`"2"`) }) test('prefers the store-native counter when implemented', async () => { const calls: { key: string; options: Store.Store.PutOptions | undefined }[] = [] const store = Store.from({ ...Store.memory(), async increment(key, options) { calls.push({ key, options }) return 7 }, }) expect(await Store.increment(store, 'counter', { ttl: 30_000 })).toMatchInlineSnapshot(`7`) expect(calls).toMatchInlineSnapshot(` [ { "key": "counter", "options": { "ttl": 30000, }, }, ] `) }) }) describe('update', () => { test('falls back to a get + put read-modify-write without a native swap', async () => { const cells = new Map() const store = Store.from({ async delete(key) { cells.delete(key) }, async get(key) { return cells.get(key) ?? null }, async list() { return { keys: [] } }, async put(key, value) { cells.set(key, value) }, }) expect(await Store.update(store, 'value', (current) => `${current ?? 'start'}+`)).toMatchInlineSnapshot(`"start+"`) // prettier-ignore expect(await Store.update(store, 'value', (current) => `${current}+`)).toMatchInlineSnapshot(`"start++"`) // prettier-ignore }) test('retries the transform when the compare-and-swap loses', async () => { const store = Store.memory() await store.put('value', 'a') // Interleave a competing write between the read and the swap once. let raced = false const get = store.get.bind(store) store.get = async (key) => { const current = await get(key) if (!raced) { raced = true await store.put('value', 'b') } return current } const transforms: (null | string)[] = [] const written = await Store.update(store, 'value', (current) => { transforms.push(current) return `${current}c` }) expect(written).toMatchInlineSnapshot(`"bc"`) expect(transforms).toMatchInlineSnapshot(` [ "a", "b", ] `) }) test('fails after exhausting compare-and-swap attempts', async () => { const store = Store.memory() await store.put('value', 'a') // Every read is immediately invalidated by a competing write. const get = store.get.bind(store) store.get = async (key) => { const current = await get(key) await store.put('value', `${current}!`) return current } await expect( Store.update(store, 'value', (current) => `${current}c`, { attempts: 2 }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Store.UpdateContentionError: Store update on "value" lost 2 compare-and-swap attempts.]`, ) }) }) describe('change', () => { test('applies typed set, no-op, and delete transitions', async () => { const store = Store.memory() const first = await Store.change(store, 'value', (current) => ({ op: 'set', result: current, value: 'first', })) const second = await Store.change(store, 'value', (current) => ({ op: 'noop', result: current, })) await Store.change(store, 'value', () => ({ op: 'delete', result: undefined })) expect({ first, second, value: await store.get('value') }).toMatchInlineSnapshot(` { "first": null, "second": "first", "value": null, } `) }) }) describe('memory', () => { test('swaps atomically against the expected value', async () => { const store = Store.memory() expect(await store.swap!('value', null, 'first')).toMatchInlineSnapshot(`true`) expect(await store.swap!('value', null, 'clobber')).toMatchInlineSnapshot(`false`) expect(await store.swap!('value', 'first', 'second')).toMatchInlineSnapshot(`true`) expect(await store.get('value')).toMatchInlineSnapshot(`"second"`) }) test('treats expired entries as absent', async () => { const store = Store.memory() await store.put('short', 'soon-gone', { ttl: 10 }) await store.put('long', 'sticks-around', { ttl: 60_000 }) await new Promise((resolve) => setTimeout(resolve, 25)) expect(await store.get('short')).toMatchInlineSnapshot(`null`) expect(await store.list()).toMatchInlineSnapshot(` { "keys": [ { "name": "long", }, ], } `) expect(await store.get('long')).toMatchInlineSnapshot(`"sticks-around"`) }) test('reads, writes, deletes, and lists string values', async () => { const store = Store.memory({ entries: [ ['other:1', 'ignored'], ['secret:1', 'alpha'], ], }) await store.put('secret:2', 'beta') await store.delete('other:1') expect(await store.get('other:1')).toMatchInlineSnapshot(`null`) expect(await store.list({ prefix: 'secret:' })).toMatchInlineSnapshot(` { "keys": [ { "name": "secret:1", }, { "name": "secret:2", }, ], } `) expect(await store.get('missing')).toMatchInlineSnapshot(`null`) expect(await store.get('secret:1')).toMatchInlineSnapshot(`"alpha"`) }) test('increments counters and restarts expired ones at 1', async () => { const store = Store.memory() expect(await Store.increment(store, 'counter', { ttl: 10 })).toMatchInlineSnapshot(`1`) expect(await Store.increment(store, 'counter', { ttl: 10 })).toMatchInlineSnapshot(`2`) await new Promise((resolve) => setTimeout(resolve, 25)) expect(await Store.increment(store, 'counter', { ttl: 10 })).toMatchInlineSnapshot(`1`) }) }) describe('cloudflareKv', () => { test('adapts a Cloudflare Workers KV namespace', async () => { const cells = new Map([ ['other:1', 'ignored'], ['secret:1', 'alpha'], ]) const writes: unknown[] = [] const namespace = { async delete(key) { cells.delete(key) }, async get(key) { return cells.get(key) ?? null }, async list(options = {}) { return { keys: Array.from(cells.keys()) .filter((key) => !options.prefix || key.startsWith(options.prefix)) .sort() .map((name) => ({ name })), list_complete: true, cursor: '', } }, async put(key, value, options) { writes.push({ key, options, value }) cells.set(key, value) }, } satisfies Store.cloudflareKv.Namespace const store = Store.cloudflareKv(namespace) await store.put('secret:2', 'beta', { ttl: 60_500 }) await store.delete('other:1') expect(writes).toMatchInlineSnapshot(` [ { "key": "secret:2", "options": { "expirationTtl": 61, }, "value": "beta", }, ] `) expect(await store.get('other:1')).toMatchInlineSnapshot(`null`) expect(await store.list({ prefix: 'secret:' })).toMatchInlineSnapshot(` { "keys": [ { "name": "secret:1", }, { "name": "secret:2", }, ], } `) await expect( store.put('short', 'nope', { ttl: 10 }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[RangeError: Cloudflare KV expirationTtl must be at least 60 seconds]`, ) }) test('lists every Cloudflare Workers KV page', async () => { const calls: unknown[] = [] const namespace = { async delete() {}, async get() { return null }, async list(options = {}) { calls.push(options) if (options.cursor === 'page-2') return { keys: [{ name: 'secret:2' }], list_complete: true, cursor: '', } return { keys: [{ name: 'secret:1' }], list_complete: false, cursor: 'page-2', } }, async put() {}, } satisfies Store.cloudflareKv.Namespace const store = Store.cloudflareKv(namespace) expect(await store.list({ prefix: 'secret:' })).toMatchInlineSnapshot(` { "keys": [ { "name": "secret:1", }, { "name": "secret:2", }, ], } `) expect(calls).toMatchInlineSnapshot(` [ { "prefix": "secret:", }, { "cursor": "page-2", "prefix": "secret:", }, ] `) }) }) describe('durableObject', () => { type TypedItems = { channel: { spent: bigint } other: bigint } // Derive native RPC operations for in-memory fakes. function stubFor(backing: Store.State): Store.durableObject.Stub { return { ...backing, deleteIf: (key, expected) => backing.deleteIf!(key, expected), increment: (key, options) => Store.increment(backing, key, options), swap: (key, expected, next, options) => backing.swap!(key, expected, next, options), } } test('supports typed values with per-key sharding by default', async () => { const { names, namespace } = TestStore.durableObjectNamespace() const storeA = Store.durableObject(namespace) const storeB = Store.durableObject(namespace) await storeA.put('channel', { spent: 42n }) await storeA.put('other', 7n) expect(await storeB.get('channel')).toMatchInlineSnapshot(` { "spent": 42n, } `) expect([...new Set(names)]).toMatchInlineSnapshot(` [ "channel", "other", ] `) }) test('preserves bigint values and collision-shaped strings', async () => { const { namespace } = TestStore.durableObjectNamespace() const store = Store.durableObject<{ value: { bigint: bigint; legacyMarker: string; valueMarker: string } }>(namespace) const value = { bigint: 42n, legacyMarker: '123#__bigint', valueMarker: 'tempo-api:store:value:bigint:123', } await store.put('value', value) await store.update('value', (current) => ({ op: 'set', result: undefined, value: { ...current!, bigint: current!.bigint + 1n }, })) expect(await store.get('value')).toEqual({ ...value, bigint: 43n }) }) test('reads typed values written with the legacy codec', async () => { const { getState, namespace } = TestStore.durableObjectNamespace() await getState('channel').put('channel', 'tempo-api:store:json:v1:{"spent":"42#__bigint"}') const store = Store.durableObject(namespace) expect(await store.get('channel')).toMatchInlineSnapshot(` { "spent": 42n, } `) }) test('keeps typed updates linearizable', async () => { const { namespace } = TestStore.durableObjectNamespace() const storeA = Store.durableObject(namespace) const storeB = Store.durableObject(namespace) await storeA.put('channel', { spent: 0n }) for (let index = 0; index < 20; index++) await Promise.all([ storeA.update('channel', (current) => ({ op: 'set', result: undefined, value: { spent: (current?.spent ?? 0n) + 1n }, })), storeB.update('channel', (current) => ({ op: 'set', result: undefined, value: { spent: (current?.spent ?? 0n) + 1n }, })), ]) expect(await storeA.get('channel')).toMatchInlineSnapshot(` { "spent": 40n, } `) }) test('supports no-op and delete update results', async () => { const { getState, namespace } = TestStore.durableObjectNamespace() const store = Store.durableObject(namespace) await store.put('channel', { spent: 42n }) const unchanged = await store.update('channel', (current) => ({ op: 'noop', result: current?.spent, })) const deleted = await store.update('channel', () => ({ op: 'delete', result: 'deleted' })) expect(unchanged).toMatchInlineSnapshot(`42n`) expect(deleted).toMatchInlineSnapshot(`"deleted"`) expect(await store.get('channel')).toBeNull() expect(await getState('channel').list()).toMatchInlineSnapshot(` { "keys": [], } `) }) test('rejects typed values written with an unknown codec version', async () => { const { getState, namespace } = TestStore.durableObjectNamespace() await getState('channel').put('channel', 'tempo-api:store:json:v3:{}') const store = Store.durableObject(namespace) await expect(store.get('channel')).rejects.toThrowErrorMatchingInlineSnapshot( `[Store.DurableObjectCodecError: Unsupported Durable Object store codec version: v3]`, ) }) test('bounds typed update contention', async () => { const backing = Store.memory() const namespace = { getByName() { return { ...stubFor(backing), swap: async () => false } }, } satisfies Store.durableObject.Namespace const store = Store.durableObject<{ channel: bigint }>(namespace) let updates = 0 await expect( store.update('channel', () => { updates += 1 return { op: 'set', result: undefined, value: 1n } }), ).rejects.toThrowErrorMatchingInlineSnapshot( `[Store.UpdateContentionError: Store update on "channel" lost 8 compare-and-swap attempts.]`, ) expect(updates).toMatchInlineSnapshot(`8`) }) test('does not retry ambiguous typed update writes', async () => { const backing = Store.memory() let swaps = 0 const namespace = { getByName() { return { ...stubFor(backing), swap() { swaps += 1 throw Object.assign(new Error('Network connection lost.'), { retryable: true }) }, } }, } satisfies Store.durableObject.Namespace const store = Store.durableObject<{ channel: bigint }>(namespace) await expect( store.update('channel', () => ({ op: 'set', result: undefined, value: 1n })), ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Network connection lost.]`) expect(swaps).toMatchInlineSnapshot(`1`) }) test('round trips strings that use the codec namespace', async () => { const { namespace } = TestStore.durableObjectNamespace() const store = Store.durableObject(namespace) const value = 'tempo-api:store:json:v2:not-a-cell' await store.put('key', value) expect(await store.get('key')).toBe(value) }) test('rejects unserializable typed values', async () => { const { namespace } = TestStore.durableObjectNamespace() const store = Store.durableObject(namespace) await expect(store.put('invalid', undefined)).rejects.toThrow( 'Durable Object store value is not serializable', ) }) test('adapts a Durable Object namespace without caching stubs', async () => { const calls: string[] = [] const backing = Store.memory({ entries: [['secret:1', 'alpha']], }) const namespace = { getByName(name) { calls.push(name) return stubFor(backing) }, } satisfies Store.durableObject.Namespace const store = Store.durableObject(namespace, { name: 'tempo-api' }) await store.put('secret:2', 'beta') expect(await store.get('secret:1')).toMatchInlineSnapshot(`"alpha"`) expect(await store.list({ prefix: 'secret:' })).toMatchInlineSnapshot(` { "keys": [ { "name": "secret:1", }, { "name": "secret:2", }, ], } `) await store.delete('secret:1') expect(calls).toMatchInlineSnapshot(` [ "tempo-api", "tempo-api", "tempo-api", "tempo-api", ] `) }) test('shards keys across objects with a name resolver', async () => { const calls: { method: string; name: string }[] = [] const objects = new Map() const namespace = { getByName(name) { const backing = objects.get(name) ?? Store.memory() objects.set(name, backing) const stub = { delete: (key) => backing.delete(key), deleteIf: (key, expected) => backing.deleteIf!(key, expected), get: (key) => backing.get(key), increment: (key, options) => Store.increment(backing, key, options), list: (options) => backing.list(options), put: (key, value, options) => backing.put(key, value, options), swap: (key, expected, next, options) => backing.swap!(key, expected, next, options), } satisfies Store.durableObject.Stub return new Proxy(stub, { get(target, method: string) { calls.push({ method, name }) return target[method as keyof Store.durableObject.Stub] }, }) }, } satisfies Store.durableObject.Namespace const store = Store.durableObject(namespace, { name: (key) => `tempo-api:${key}` }) expect(await Store.increment(store, 'ratelimit:a')).toMatchInlineSnapshot(`1`) expect(await Store.increment(store, 'ratelimit:a')).toMatchInlineSnapshot(`2`) expect(await Store.increment(store, 'ratelimit:b')).toMatchInlineSnapshot(`1`) expect(calls).toMatchInlineSnapshot(` [ { "method": "increment", "name": "tempo-api:ratelimit:a", }, { "method": "increment", "name": "tempo-api:ratelimit:a", }, { "method": "increment", "name": "tempo-api:ratelimit:b", }, ] `) await expect(store.list()).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: cannot list a sharded Durable Object store]`, ) }) test('retries a retryable stub error once on a fresh stub', async () => { let stubs = 0 let failures = 1 const backing = Store.memory() const namespace = { getByName() { stubs += 1 return { ...stubFor(backing), increment: (key, options) => { if (failures > 0) { failures -= 1 throw Object.assign(new Error('Network connection lost.'), { retryable: true }) } return Store.increment(backing, key, options) }, } }, } satisfies Store.durableObject.Namespace const store = Store.durableObject(namespace, { name: 'tempo-api' }) expect(await Store.increment(store, 'counter')).toMatchInlineSnapshot(`1`) // One stub per attempt: a stub that threw is disconnected. expect(stubs).toMatchInlineSnapshot(`2`) }) test('propagates a retryable stub error when the retry also fails', async () => { let attempts = 0 const namespace = { getByName() { return { ...stubFor(Store.memory()), increment: () => { attempts += 1 throw Object.assign(new Error('Network connection lost.'), { retryable: true }) }, } }, } satisfies Store.durableObject.Namespace const store = Store.durableObject(namespace, { name: 'tempo-api' }) await expect(Store.increment(store, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: Network connection lost.]`, ) expect(attempts).toMatchInlineSnapshot(`2`) }) test('does not retry non-retryable or overloaded stub errors', async () => { let attempts = 0 const namespaceFor = (error: Error) => ({ getByName() { return { ...stubFor(Store.memory()), increment: () => { attempts += 1 throw error }, } }, }) satisfies Store.durableObject.Namespace const plain = Store.durableObject(namespaceFor(new Error('boom')), { name: 'tempo-api' }) await expect(Store.increment(plain, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: boom]`, ) expect(attempts).toMatchInlineSnapshot(`1`) attempts = 0 const overloaded = Store.durableObject( namespaceFor( Object.assign(new Error('Durable Object is overloaded.'), { overloaded: true, retryable: true, }), ), { name: 'tempo-api' }, ) await expect(Store.increment(overloaded, 'counter')).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: Durable Object is overloaded.]`, ) expect(attempts).toMatchInlineSnapshot(`1`) }) test('defers missing namespace failures until an operation is attempted', async () => { const store = Store.durableObject(undefined as unknown as Store.durableObject.Namespace, { name: 'tempo-api', }) expect(store.type).toMatchInlineSnapshot(`"state"`) await expect(store.get('key')).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: Cannot read properties of undefined (reading 'get')]`, ) }) test('adapts Durable Object storage and lazily removes expired entries', async () => { const storage = durableObjectStorage() const store = Store.durableObject(storage) await store.put('secret:1', 'alpha', { ttl: 10 }) await store.put('secret:2', 'beta', { ttl: 60_000 }) await store.put('other:1', 'ignored') await new Promise((resolve) => setTimeout(resolve, 25)) expect(await store.get('secret:1')).toMatchInlineSnapshot(`null`) expect(await store.get('secret:2')).toMatchInlineSnapshot(`"beta"`) expect(await store.list({ prefix: 'secret:' })).toMatchInlineSnapshot(` { "keys": [ { "name": "secret:2", }, ], } `) expect(await store.deleteIf!('secret:2', 'wrong')).toBe(false) expect(await store.deleteIf!('secret:2', 'beta')).toBe(true) expect(await store.get('secret:2')).toBeNull() expect(storage.cells.has('secret:1')).toMatchInlineSnapshot(`false`) }) }) describe('withRequest', () => { test('preserves the source store contract', async () => { const source = new ReceiverStore() const store = Store.withRequest(source, new HonoRequest(new Request('https://api.tempo.xyz'))) await store.put('value', 'stored') expect(store.type).toMatchInlineSnapshot(`"cache"`) expect(await source.get('value')).toMatchInlineSnapshot(`"stored"`) }) }) describe('memoize', () => { test('coalesces concurrent cache misses for the same key', async () => { const store = Store.memory() const pending = deferred() let calls = 0 const requests = [1, 2, 3].map(() => Store.memoize( async () => { calls += 1 await pending.promise return { value: 'fresh' } }, { key: 'memo:test', store, ttl: 60_000 }, ), ) await Promise.resolve() pending.resolve() const values = await Promise.all(requests) expect(calls).toMatchInlineSnapshot(`1`) expect(await store.get('memo:test')).toMatchInlineSnapshot( `"tempo-api:store:json:v2:{"value":"fresh"}"`, ) expect(values).toMatchInlineSnapshot(` [ { "value": "fresh", }, { "value": "fresh", }, { "value": "fresh", }, ] `) }) test('hands cache persistence to waitUntil without retaining the resolved flight', async () => { const pending = deferred() const waited: Promise[] = [] const source = Store.memory() const store = Store.withRequest( Store.from({ ...source, put: () => pending.promise, type: 'cache', }), new HonoRequest(new Request('https://api.tempo.xyz')), { waitUntil: (promise) => void waited.push(promise) }, ) let calls = 0 const options = { key: 'memo:stalled-write', store, ttl: 60_000 } const first = await Store.memoize(async () => ({ value: ++calls }), options) const second = await Store.memoize(async () => ({ value: ++calls }), options) expect(first).toEqual({ value: 1 }) expect(second).toEqual({ value: 2 }) expect(calls).toBe(2) expect(waited).toHaveLength(2) pending.resolve() await Promise.all(waited) }) test('ignores cache persistence failures without waitUntil', async () => { const store = Store.from({ ...Store.memory(), async put() { throw new Error('cache unavailable') }, type: 'cache', }) await expect( Store.memoize(async () => ({ value: 'fresh' }), { key: 'memo:failed-write', store, ttl: 60_000, }), ).resolves.toEqual({ value: 'fresh' }) }) test('releases a no-cache flight after deletion fails', async () => { let deletes = 0 let fetches = 0 const source = Store.from({ ...Store.memory(), async delete() { if (++deletes === 1) throw new Error('cache unavailable') }, }) const store = Store.withRequest( source, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'no-cache' } }), ), ) const options = { key: 'memo:failed-delete', store, ttl: 60_000 } await expect(Store.memoize(async () => ({ value: ++fetches }), options)).rejects.toThrow( 'cache unavailable', ) await expect(Store.memoize(async () => ({ value: ++fetches }), options)).resolves.toEqual({ value: 1, }) expect(deletes).toBe(2) }) test('times out and evicts a stalled cache-miss flight', async () => { vi.useFakeTimers() try { const store = Store.memory() const stalled = deferred<{ value: string }>() let calls = 0 let leaderSignal: AbortSignal | undefined const options = { flightTimeout: 100, key: 'memo:test', store, ttl: 60_000, } const leader = Store.memoize(async (signal) => { calls += 1 leaderSignal = signal return stalled.promise }, options) await vi.advanceTimersByTimeAsync(0) const follower = Store.memoize(async () => { calls += 1 return { value: 'unexpected' } }, options) const failures = Promise.all([ expect(leader).rejects.toBeInstanceOf(Store.MemoizeTimeoutError), expect(follower).rejects.toBeInstanceOf(Store.MemoizeTimeoutError), ]) await vi.advanceTimersByTimeAsync(100) await failures expect(calls).toBe(1) expect(leaderSignal?.aborted).toBe(true) expect(leaderSignal?.reason).toMatchObject({ name: 'TimeoutError' }) const recovered = await Store.memoize(async () => { calls += 1 return { value: 'fresh' } }, options) stalled.resolve({ value: 'stale' }) await stalled.promise await Promise.resolve() expect(recovered).toEqual({ value: 'fresh' }) expect(calls).toBe(2) expect(await store.get('memo:test')).toBe('tempo-api:store:json:v2:{"value":"fresh"}') } finally { vi.useRealTimers() } }) test('retains a timed-out flight until a cache mutation settles', async () => { vi.useFakeTimers() try { const source = Store.memory() const store = Store.withRequest( source, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'no-cache' }, }), ), ) let calls = 0 const options = { flightTimeout: 100, key: 'memo:mutation', store, ttl: 60_000 } const leader = Store.memoize(async () => ({ value: ++calls }), options) const leaderFailure = expect(leader).rejects.toBeInstanceOf(Store.MemoizeTimeoutError) // The first microtask enters the real in-memory store's async delete. await Promise.resolve() vi.advanceTimersByTime(100) const followerFailure = expect( Store.memoize(async () => ({ value: ++calls }), options), ).rejects.toBeInstanceOf(Store.MemoizeTimeoutError) await Promise.all([leaderFailure, followerFailure]) expect(calls).toBe(0) await expect(Store.memoize(async () => ({ value: ++calls }), options)).resolves.toEqual({ value: 1, }) } finally { vi.useRealTimers() } }) test('defaults cache-miss flights to the API deadline', async () => { vi.useFakeTimers() try { const store = Store.memory() const stalled = deferred<{ value: string }>() const failure = expect( Store.memoize(() => stalled.promise, { key: 'memo:default-timeout', store, ttl: 60_000, }), ).rejects.toBeInstanceOf(Store.MemoizeTimeoutError) await vi.advanceTimersByTimeAsync(Timeout.duration) await failure } finally { vi.useRealTimers() } }) test('returns cached JSON values until the entry expires', async () => { const store = Store.memory() let calls = 0 const first = await Store.memoize(async () => ({ value: ++calls }), { key: 'memo:test', store, ttl: 10, }) const second = await Store.memoize(async () => ({ value: ++calls }), { key: 'memo:test', store, ttl: 10, }) await new Promise((resolve) => setTimeout(resolve, 25)) const third = await Store.memoize(async () => ({ value: ++calls }), { key: 'memo:test', store, ttl: 10, }) expect(calls).toMatchInlineSnapshot(`2`) expect(first).toMatchInlineSnapshot(` { "value": 1, } `) expect(second).toMatchInlineSnapshot(` { "value": 1, } `) expect(third).toMatchInlineSnapshot(` { "value": 2, } `) }) test('refreshes the entry when request headers send no-cache', async () => { const store = Store.memory() let calls = 0 const fetch = async () => ({ value: ++calls }) await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) const refreshed = await Store.memoize(fetch, { key: 'memo:test', store: Store.withRequest( store, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'no-cache' }, }), ), ), ttl: 60_000, }) const after = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) expect(calls).toMatchInlineSnapshot(`2`) expect(refreshed).toMatchInlineSnapshot(` { "value": 2, } `) expect(after).toEqual(refreshed) }) test('propagates no-cache through nested memoizers', async () => { const source = Store.memory() let calls = 0 const fetch = async () => ({ value: ++calls }) await Store.memoize(fetch, { key: 'memo:inner', store: source, ttl: 60_000 }) const store = Store.withRequest( source, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'no-cache' }, }), ), ) const value = await Store.memoize( () => Store.memoize(fetch, { key: 'memo:inner', store, ttl: 60_000 }), { key: 'memo:outer', store, ttl: 60_000 }, ) expect(calls).toMatchInlineSnapshot(`2`) expect(value).toMatchInlineSnapshot(` { "value": 2, } `) }) test('bypasses without replacing the entry when request headers send no-store', async () => { const store = Store.memory() let calls = 0 const fetch = async () => ({ value: ++calls }) const first = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) const bypassed = await Store.memoize(fetch, { key: 'memo:test', store: Store.withRequest( store, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'no-store' }, }), ), ), ttl: 60_000, }) const after = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) expect(calls).toMatchInlineSnapshot(`2`) expect(bypassed).toMatchInlineSnapshot(` { "value": 2, } `) expect(after).toEqual(first) }) test('honors Pragma no-cache only without Cache-Control', async () => { const store = Store.memory() let calls = 0 const fetch = async () => ({ value: ++calls }) await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) await Store.memoize(fetch, { key: 'memo:test', store: Store.withRequest( store, new HonoRequest(new Request('https://api.tempo.xyz', { headers: { pragma: 'no-cache' } })), ), ttl: 60_000, }) const cached = await Store.memoize(fetch, { key: 'memo:test', store: Store.withRequest( store, new HonoRequest( new Request('https://api.tempo.xyz', { headers: { 'cache-control': 'max-age=0', pragma: 'no-cache' }, }), ), ), ttl: 60_000, }) expect(calls).toMatchInlineSnapshot(`2`) expect(cached).toMatchInlineSnapshot(` { "value": 2, } `) }) test('preserves bigint values and collision-shaped strings on cache hits', async () => { const store = Store.memory() let calls = 0 const fetch = async () => { calls += 1 return { bigint: 42n, legacyMarker: '123#__bigint', valueMarker: 'tempo-api:store:value:bigint:123', } } const first = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) const second = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) expect(first).toEqual(second) expect(second).toEqual({ bigint: 42n, legacyMarker: '123#__bigint', valueMarker: 'tempo-api:store:value:bigint:123', }) expect(calls).toBe(1) }) test('does not cache undefined results', async () => { const store = Store.memory() let calls = 0 const fetch = async () => { calls += 1 return undefined } const first = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) const second = await Store.memoize(fetch, { key: 'memo:test', store, ttl: 60_000 }) expect(first).toBeUndefined() expect(second).toBeUndefined() expect(await store.get('memo:test')).toBeNull() expect(calls).toBe(2) }) test('coalesces values excluded by the cache predicate without storing them', async () => { const store = Store.memory() const pending = deferred() let calls = 0 const fetch = async () => { calls += 1 await pending.promise return { cacheable: false, value: 'fresh' } } const options = { key: 'memo:test', shouldCache: (value: Awaited>) => value.cacheable, store, ttl: 60_000, } const requests = [Store.memoize(fetch, options), Store.memoize(fetch, options)] await Promise.resolve() pending.resolve() expect(await Promise.all(requests)).toStrictEqual([ { cacheable: false, value: 'fresh' }, { cacheable: false, value: 'fresh' }, ]) expect(await store.get('memo:test')).toBeNull() expect(calls).toBe(1) }) test('refreshes legacy untagged cache entries', async () => { const store = Store.memory({ entries: [['memo:test', '{"value":"123#__bigint"}']] }) let calls = 0 const value = await Store.memoize( async () => { calls += 1 return { value: '123#__bigint' } }, { key: 'memo:test', store, ttl: 60_000 }, ) expect(value).toEqual({ value: '123#__bigint' }) expect(calls).toBe(1) }) test('refreshes corrupt cache entries', async () => { const store = Store.memory({ entries: [['memo:test', 'tempo-api:store:json:v2:not-json']] }) const value = await Store.memoize(async () => ({ value: 'fresh' }), { key: 'memo:test', store, ttl: 60_000, }) expect(value).toMatchInlineSnapshot(` { "value": "fresh", } `) expect(await store.get('memo:test')).toMatchInlineSnapshot( `"tempo-api:store:json:v2:{"value":"fresh"}"`, ) }) }) function deferred() { let reject!: (cause?: unknown) => void let resolve!: (value: PromiseLike | value) => void const promise = new Promise((resolve_, reject_) => { reject = reject_ resolve = resolve_ }) return { promise, reject, resolve } } function durableObjectStorage(): Store.durableObject.Storage & { cells: Map } { const cells = new Map() return { cells, async delete(key) { return cells.delete(key) }, async get(key: string) { return cells.get(key) as undefined | value }, async list(options: { prefix?: string | undefined } = {}) { const entries = Array.from(cells.entries()).filter( ([key]) => !options.prefix || key.startsWith(options.prefix), ) return new Map(entries) as Map }, async put(key, value) { cells.set(key, value) }, } } class ReceiverStore implements Store.Store { #cells = new Map() type = 'cache' as const async delete(key: string) { this.#cells.delete(key) } async get(key: string) { return this.#cells.get(key) ?? null } async list(options: { prefix?: string | undefined } = {}) { return { keys: [...this.#cells.keys()] .filter((key) => options.prefix === undefined || key.startsWith(options.prefix)) .map((name) => ({ name })), } } async put(key: string, value: string) { this.#cells.set(key, value) } }