From 25419e68853b5ec85940c30d62be0222fb7149c4 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 16:53:38 +1100 Subject: [PATCH 1/4] fix(cachekitio): 90-day TTL range; bound the writer's L1 copy by the PUT Fresh-For The protocol's TTL range is now 1..7,776,000 seconds (90 days), and each plan has a maximum TTL that the server clamps to rather than rejects. The SDK does not know the plan, so it sends any TTL in the range as is: X-CacheKit-TTL and the PATCH /ttl body accept 1..7,776,000, staleTtl <= 7,775,999 and ttl + staleTtl <= 7,776,000. The X-CacheKit-Fresh-For grammar cap stays 2,592,000 under its own constant. A clamped write is stored for less than the TTL it stated, so the writer's L1 copy must follow the bound the store applied (API-103). Backend.set and setWithStaleTtl may now resolve to a SetResult carrying the PUT 2xx's X-CacheKit-Fresh-For, read with the GET grammar. The cache caps the L1 copy at min(ttl, fresh_for) counted from receipt of the 2xx, on direct writes, on write-behind (once the background PUT lands) and on SWR refreshes; a malformed or 0 value keeps no copy; an absent header keeps today's lifetime. A write that degradation absorbed keeps its copy for the TTL, as before (API-36). PATCH /ttl responses never touch an L1 bound (API-104). Delegating Backend wrappers must return the inner set's result, as they already forward getWithTtl. --- .secrets.baseline | 6 +- packages/cachekit/README.md | 28 +- .../src/backends/cachekitio-freshness.test.ts | 351 +++++++++++++++++- .../src/backends/cachekitio-ttl.test.ts | 13 +- .../cachekit/src/backends/cachekitio.test.ts | 44 ++- packages/cachekit/src/backends/cachekitio.ts | 57 ++- packages/cachekit/src/backends/types.ts | 49 ++- packages/cachekit/src/cache-core.ts | 110 ++++-- .../cachekit/src/cache.stale-grace.test.ts | 17 +- .../cachekit/src/cache/background-refresh.ts | 46 ++- packages/cachekit/src/exports-common.ts | 1 + packages/cachekit/src/l1/lru-cache.ts | 34 ++ packages/cachekit/src/types/cache.ts | 6 +- 13 files changed, 674 insertions(+), 88 deletions(-) diff --git a/.secrets.baseline b/.secrets.baseline index b53783ee..ebddd94a 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -157,14 +157,14 @@ "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "9242986caf890b01f4265ee595e88ee087af4b90", "is_verified": false, - "line_number": 23 + "line_number": 24 }, { "type": "Secret Keyword", "filename": "packages/cachekit/src/backends/cachekitio.test.ts", "hashed_secret": "a62f2225bf70bfaccbc7f1ef2a397836717377de", "is_verified": false, - "line_number": 49 + "line_number": 50 } ], "packages/cachekit/src/intents.test.ts": [ @@ -1332,5 +1332,5 @@ } ] }, - "generated_at": "2026-10-09T09:07:33Z" + "generated_at": "2026-11-01T05:53:25Z" } diff --git a/packages/cachekit/README.md b/packages/cachekit/README.md index e827cccd..a9cde1ff 100644 --- a/packages/cachekit/README.md +++ b/packages/cachekit/README.md @@ -516,14 +516,18 @@ const getReport = cache.wrap(async (id: string) => buildReport(id), { like any write. Past the window the server answers 404 and the call takes the ordinary miss path. - A stale value never enters L1, and the L1 copy of a written value still - expires at `ttl`: the window is served by the server only. + expires at `ttl`, or sooner when the server reports a shorter lifetime + for the write: the window is served by the server only. - On Cloudflare Workers the revalidation needs a context bound with `withExecutionContext(ctx)`; without one, a stale read is treated as a miss and recomputed in the request. - `wrap()` throws `ConfigurationError` when `staleTtl` is set on an encrypted cache (a secure cache's TTL is often a revocation boundary), on a backend - other than CachekitIO, above 2,591,999 seconds, or when `ttl + staleTtl` - exceeds 2,592,000 (30 days). Fractions round up; `0` turns it off. + other than CachekitIO, above 7,775,999 seconds, or when `ttl + staleTtl` + exceeds 7,776,000 (90 days, the protocol's TTL range). Fractions round up; + `0` turns it off. A window that does not fit the tenant's plan maximum TTL + is not an error: the server shortens it (see the CachekitIO TTL note + below). > [!WARNING] > The stale window applies to every reader of the key, not only to this @@ -566,12 +570,28 @@ Four backends implement the same `Backend` interface (raw bytes in/out) and plug | Backend | Import | Runtime | Notes | | ----------------- | ----------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Redis | `redis` from `@cachekit-io/cachekit` | Node | ioredis; TTL inspection + distributed locking | -| CachekitIO (SaaS) | `cachekitio` from `@cachekit-io/cachekit` | Node, Workers | fetch-based; TTL + locking variants; accepts finite TTLs > 0 up to 2,592,000 s (30 days), fractional values round up; invalid values throw `ConfigurationError` per the protocol spec | +| CachekitIO (SaaS) | `cachekitio` from `@cachekit-io/cachekit` | Node, Workers | fetch-based; TTL + locking variants; accepts finite TTLs > 0 up to 7,776,000 s (90 days), fractional values round up; invalid values throw `ConfigurationError` per the protocol spec | | Memcached | `memcached` from `@cachekit-io/cachekit/backends/memcached` | Node | memjs (binary protocol, multi-server); requires the optional `memjs` peer dependency | | File | `file` from `@cachekit-io/cachekit/backends/file` | Node | local disk, on-disk format shared with cachekit-py | The Memcached and File backends are **Node-runtime only** and live behind subpath exports, so the root entry never loads `memjs` or the File backend. The root entry is itself **Node-only**: it loads `node:crypto` and the NAPI core, whose loader requires `node:fs`. ioredis loads when the app creates a Redis backend, not when it imports the package, so an app that never uses Redis never loads it; commands issued before the load finishes wait for it. Bundlers follow that load, so a bundle that uses Redis carries ioredis. ioredis is CommonJS and requires Node builtins, so an esbuild ESM bundle for Node needs `require` defined (the usual `createRequire` banner). Edge code uses the `workerd` export condition or the `@cachekit-io/cachekit/workers` subpath. Bun resolves the root to the Node entry and runs it, NAPI core included (checked on Bun 1.3.8); the `/workers` subpath does not load on Bun. No entry targets Deno, Deno Deploy or Vercel/Next edge. +**CachekitIO TTL and the plan maximum.** The TTL range is 1 to 7,776,000 +seconds (90 days); the SDK rejects anything outside it before any request. +Each plan also has a maximum TTL: Free 86,400 s (24 hours), Startup +2,592,000 s (30 days), Professional 7,776,000 s (90 days), Enterprise none. +The SDK does not know your plan, so it sends a TTL inside the range as is, +and the server stores the write with at most the plan maximum (the fresh +window first, then whatever stale window is left). That clamp never rejects +a write. The server reports the lifetime it applied in `X-CacheKit-Fresh-For` +on the write's response, and the writing process's L1 copy expires after +`min(ttl, X-CacheKit-Fresh-For)` seconds, counted from that response; a +malformed or `0` value keeps no L1 copy. A write whose error degradation +absorbed (a timeout, say) got no response, and keeps its L1 copy for `ttl`. +`refreshTTL` (`PATCH /ttl`) never changes how long an L1 copy is served. +This SDK always sends a TTL (`defaultTtl`, one hour unless set), so it never +stores a CachekitIO entry without an expiry. + **L1 freshness across processes.** When a plain `get()` hits L2, the value is re-populated into this process's L1. On backends that surface the entry's remaining TTL in the same read (`Backend.getWithTtl` — Redis via a pipelined diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index 8eec818b..748499fc 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -6,9 +6,11 @@ import { cachekitioWithLocking, cachekitioWithTTL, } from './cachekitio-factory.js'; -import type { Backend, CachekitIOBackendConfig } from './types.js'; +import type { Backend, CachekitIOBackendConfig, SetResult } from './types.js'; import { createCache } from '../cache.js'; import { CacheImpl } from '../cache-core.js'; +import { generateKey } from '../serialization/key-generator.js'; +import type { L1Cache } from '../l1/lru-cache.js'; // The L1 backfill honours X-CacheKit-Freshness and X-CacheKit-Fresh-For // (protocol spec/saas-api.md § Remaining Freshness, § Reading a stale entry). @@ -40,6 +42,13 @@ describe('freshnessFromHeaders', () => { expect(parse({ 'X-CacheKit-Fresh-For': '2592000' }).freshFor).toBe(2_592_000); }); + // The header's cap is its own grammar bound, not the 90-day TTL range. + it('keeps the 2,592,000 cap below the TTL range', () => { + expect(parse({ 'X-CacheKit-Fresh-For': '2592000' }).freshFor).toBe(2_592_000); + expect(parse({ 'X-CacheKit-Fresh-For': '2592001' }).freshFor).toBe(0); + expect(parse({ 'X-CacheKit-Fresh-For': '7776000' }).freshFor).toBe(0); + }); + it('maps every malformed Fresh-For to 0', () => { for (const raw of [ '', @@ -246,3 +255,343 @@ describe('CachekitIO L1 backfill bound', () => { await reader.close(); }); }); + +// The writer's own copy (protocol spec/saas-api.md § Remaining Freshness, +// API-103): the X-CacheKit-Fresh-For on the PUT 2xx caps the L1 copy of the +// value written, counted from receipt; absent leaves the TTL; malformed or 0 +// keeps no copy. And a PATCH /ttl response never touches an L1 bound (API-104). +describe('CachekitIO writer L1 bound', () => { + let store: Map; + /** Headers on every PUT 2xx. */ + let putHeaders: Record; + let gets: number; + let patches: number; + + beforeEach(() => { + store = new Map(); + putHeaders = {}; + gets = 0; + patches = 0; + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: RequestInit) => { + if (init.method === 'PUT') { + store.set(url, init.body as Uint8Array); + return new Response(null, { status: 200, headers: putHeaders }); + } + if (init.method === 'PATCH') { + patches++; + // A hostile or buggy hop's header on the PATCH: it must change nothing. + return new Response(null, { + status: 200, + headers: { 'X-CacheKit-Fresh-For': '2592000' }, + }); + } + gets++; + const body = store.get(url); + return body ? new Response(body, { status: 200 }) : new Response(null, { status: 404 }); + }) + ); + vi.useFakeTimers({ toFake: ['Date'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + // Every factory wraps CachekitIOCore differently; each must return set's result. + const factories = { cachekitio, cachekitioWithLocking, cachekitioWithTTL, cachekitioFull }; + + for (const [name, factory] of Object.entries(factories)) { + it(`caps the L1 copy at a Fresh-For shorter than the TTL (${name})`, async () => { + const cache = createCache({ backend: factory(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_900); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(0); // inside the bound: L1 hit + + vi.advanceTimersByTime(100); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(1); // at the bound: the L1 copy is gone, back to L2 + + await cache.close(); + }); + } + + it('keeps the TTL when the PUT carries no Fresh-For', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(3_599_000); + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(0); + + vi.advanceTimersByTime(1_000); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it('never lets a Fresh-For longer than the TTL extend the copy', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2592000' }; + await cache.set('ns:k', 'v1', { ttl: 60 }); + + vi.advanceTimersByTime(59_999); + await cache.get('ns:k'); + expect(gets).toBe(0); // the copy was kept, for the TTL + + vi.advanceTimersByTime(1); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it.each(['0', 'abc', '-1', '2592001', '12345678', ''])( + 'keeps no L1 copy when the PUT Fresh-For is %j', + async (raw) => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + await cache.set('ns:k', 'v0', { ttl: 3600 }); // an older copy the write must not leave behind + putHeaders = { 'X-CacheKit-Fresh-For': raw }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(gets).toBe(1); + + await cache.close(); + } + ); + + it('counts the bound from receipt of the 2xx, not from dispatch', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + // The PUT takes 1.5 s on the faked clock between dispatch and receipt. + const fetchStub = vi.mocked(fetch); + const inner = fetchStub.getMockImplementation()!; + fetchStub.mockImplementationOnce(async (url, init) => { + vi.advanceTimersByTime(1_500); + return inner(url as string, init as RequestInit); + }); + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_900); + await cache.get('ns:k'); + expect(gets).toBe(0); // 1.9 s after receipt (3.4 s after dispatch): inside the bound + + vi.advanceTimersByTime(100); + await cache.get('ns:k'); + expect(gets).toBe(1); + + await cache.close(); + }); + + it('holds the bound on the stale-window write (X-CacheKit-Stale-TTL never lengthens it)', async () => { + const cache = createCache({ backend: cachekitioFull(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + const fn = vi.fn(async () => 'computed'); + const wrapped = cache.wrap(fn, { namespace: 'sg', ttl: 3600, staleTtl: 600 }); + + expect(await wrapped()).toBe('computed'); // cold miss: GET 404, then the PUT + expect(gets).toBe(1); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(1); // L1 hit + + vi.advanceTimersByTime(1_000); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); // past Fresh-For, though inside both TTL and stale window + expect(fn).toHaveBeenCalledTimes(1); + + await cache.close(); + }); + + it('caps a write-behind copy once its PUT lands', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + const wrapped = cache.wrap(async () => 'computed', { + namespace: 'wb', + ttl: 3600, + writeBehind: true, + }); + + expect(await wrapped()).toBe('computed'); // L1 filled before the PUT is sent + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); // let the write settle + expect(gets).toBe(1); + + vi.advanceTimersByTime(1_000); + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); // the copy was capped at the PUT's Fresh-For + + await cache.close(); + }); + + it('measures SWR staleness of a capped write-behind copy against the cap, not the TTL', async () => { + const cache = createCache({ + backend: cachekitio(config), + defaultTtl: 3600, + l1: { swrEnabled: true }, + }); + putHeaders = { 'X-CacheKit-Fresh-For': '60' }; + const fn = vi.fn(async () => 'computed'); + const wrapped = cache.wrap(fn, { namespace: 'wb', ttl: 3600, writeBehind: true }); + + expect(await wrapped()).toBe('computed'); + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // 60 s left of a 60 s bound is fresh; read against the 3600 s TTL it + // would look stale and refresh the origin on every call. + const l1 = (cache as unknown as { l1: L1Cache }).l1; + const entry = l1.entry(generateKey('wb', [])) as unknown as { originalTtl: number }; + expect(entry.originalTtl).toBeLessThanOrEqual(60_000); + expect(await wrapped()).toBe('computed'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fn).toHaveBeenCalledTimes(1); + + await cache.close(); + }); + + it('drops a write-behind copy when its PUT Fresh-For is 0', async () => { + const cache = createCache({ backend: cachekitio(config), defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + const wrapped = cache.wrap(async () => 'computed', { + namespace: 'wb', + ttl: 3600, + writeBehind: true, + }); + + expect(await wrapped()).toBe('computed'); + await vi.waitFor(() => expect(store.size).toBe(1)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(await wrapped()).toBe('computed'); + expect(gets).toBe(2); + + await cache.close(); + }); + + describe('SWR refresh (L1 written only through completeRefresh)', () => { + // swrThresholdRatio 2 makes every live L1 entry stale, so the second call refreshes. + const swr = () => + createCache({ + backend: cachekitio(config), + defaultTtl: 3600, + l1: { swrEnabled: true, swrThresholdRatio: 2 }, + }); + + it('caps the refreshed copy at the refresh PUT Fresh-For', async () => { + const cache = swr(); + let gen = 0; + const wrapped = cache.wrap(async () => ++gen, { namespace: 'swr', ttl: 3600 }); + const key = generateKey('swr', []); + + expect(await wrapped()).toBe(1); + putHeaders = { 'X-CacheKit-Fresh-For': '1' }; + expect(await wrapped()).toBe(1); // stale hit: refresh scheduled + await vi.waitFor(async () => expect(await cache.get(key)).toBe(2)); + expect(gets).toBe(1); // the cold miss only: gen 2 came from L1 + + vi.advanceTimersByTime(1_000); + expect(await cache.get(key)).toBe(2); + expect(gets).toBe(2); + + await cache.close(); + }); + + it('keeps neither copy when the refresh PUT Fresh-For is 0', async () => { + const cache = swr(); + let gen = 0; + const wrapped = cache.wrap(async () => ++gen, { namespace: 'swr', ttl: 3600 }); + const key = generateKey('swr', []); + + expect(await wrapped()).toBe(1); + putHeaders = { 'X-CacheKit-Fresh-For': '0' }; + expect(await wrapped()).toBe(1); + await vi.waitFor(() => expect(gen).toBe(2)); + await vi.waitFor(async () => { + const before = gets; + expect(await cache.get(key)).toBe(2); // read from L2: L1 holds neither generation + expect(gets).toBe(before + 1); + }); + + await cache.close(); + }); + }); + + it('a PATCH /ttl response never creates, refreshes or extends an L1 bound (API-104)', async () => { + const backend = cachekitioFull(config); + const cache = createCache({ backend, defaultTtl: 3600 }); + putHeaders = { 'X-CacheKit-Fresh-For': '2' }; + await cache.set('ns:k', 'v1', { ttl: 3600 }); + + vi.advanceTimersByTime(1_000); + expect(await backend.refreshTTL('ns:k', 7_776_000)).toBe(true); + expect(patches).toBe(1); + + vi.advanceTimersByTime(1_000); + await cache.get('ns:k'); + expect(gets).toBe(1); // still expired at the PUT's bound + + // Nor does a PATCH of a key L1 does not hold create a copy. + expect(await backend.refreshTTL('ns:other', 3600)).toBe(true); + expect(await cache.get('ns:other')).toBeNull(); + expect(gets).toBe(2); + + await cache.close(); + }); +}); + +describe('a custom backend reporting a bound', () => { + /** In-memory backend whose set resolves to `result`. */ + class ReportingBackend implements Backend { + store = new Map(); + gets = 0; + constructor(private readonly result: unknown) {} + async get(key: string) { + this.gets++; + return this.store.get(key) ?? null; + } + async set(key: string, value: Uint8Array) { + this.store.set(key, value); + return this.result as SetResult; + } + async delete(key: string) { + return this.store.delete(key); + } + async exists(key: string) { + return this.store.has(key); + } + async close() {} + } + + it.each([NaN, -1, '60'])( + 'keeps no L1 copy for a freshFor of %j (fails closed)', + async (freshFor) => { + const backend = new ReportingBackend({ freshFor }); + const cache = createCache({ backend, defaultTtl: 3600 }); + await cache.set('ns:k', 'v1'); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(backend.gets).toBe(1); + + await cache.close(); + } + ); + + it('keeps the TTL for a backend that resolves to nothing', async () => { + const backend = new ReportingBackend(undefined); + const cache = createCache({ backend, defaultTtl: 3600 }); + await cache.set('ns:k', 'v1'); + + expect(await cache.get('ns:k')).toBe('v1'); + expect(backend.gets).toBe(0); + + await cache.close(); + }); +}); diff --git a/packages/cachekit/src/backends/cachekitio-ttl.test.ts b/packages/cachekit/src/backends/cachekitio-ttl.test.ts index 2729d39d..f6f5d406 100644 --- a/packages/cachekit/src/backends/cachekitio-ttl.test.ts +++ b/packages/cachekit/src/backends/cachekitio-ttl.test.ts @@ -59,11 +59,20 @@ describe('TTLCachekitIO', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('refreshTTL rejects TTL above 2,592,000 (30 days)', async () => { - await expect(ttlBackend.refreshTTL('key', 2_592_001)).rejects.toThrow(ConfigurationError); + it('refreshTTL rejects TTL above 7,776,000 (90 days)', async () => { + await expect(ttlBackend.refreshTTL('key', 7_776_001)).rejects.toThrow(ConfigurationError); expect(fetchSpy).not.toHaveBeenCalled(); }); + // A TTL over a plan's maximum but inside the TTL range is sent as is: the + // SDK does not know the plan, and the store clamps it. + it.each([1, 2_592_001, 7_776_000])('refreshTTL sends %i as is in the PATCH body', async (ttl) => { + fetchSpy.mockResolvedValueOnce(mockResponse(200, { success: true })); + await ttlBackend.refreshTTL('key', ttl); + const [, opts] = fetchSpy.mock.calls[0]; + expect(JSON.parse(new TextDecoder().decode(opts.body))).toEqual({ ttl }); + }); + it('refreshTTL ceils fractional TTL to whole seconds in the PATCH body', async () => { fetchSpy.mockResolvedValueOnce(mockResponse(200, { success: true })); await ttlBackend.refreshTTL('key', 0.5); diff --git a/packages/cachekit/src/backends/cachekitio.test.ts b/packages/cachekit/src/backends/cachekitio.test.ts index e285e35b..be6de919 100644 --- a/packages/cachekit/src/backends/cachekitio.test.ts +++ b/packages/cachekit/src/backends/cachekitio.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { cachekitio } from './cachekitio-factory.js'; +import { validateStaleTtl, validateTtl } from './cachekitio.js'; import type { Backend, CachekitIOBackendConfig } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; @@ -353,7 +354,7 @@ describe('CachekitIO Backend', () => { describe('set edge cases', () => { // Protocol TTL Validation Rules (protocol/spec/saas-api.md): TTL=0, - // negative, and > 30 days MUST be rejected at the SDK boundary. + // negative, and > 90 days (the TTL range) MUST be rejected at the SDK boundary. it('rejects TTL of 0', async () => { await expect(backend.set('key', new Uint8Array([1]), 0)).rejects.toThrow(ConfigurationError); expect(fetchSpy).not.toHaveBeenCalled(); @@ -364,18 +365,20 @@ describe('CachekitIO Backend', () => { expect(fetchSpy).not.toHaveBeenCalled(); }); - it('rejects TTL above 2,592,000 (30 days)', async () => { - await expect(backend.set('key', new Uint8Array([1]), 2_592_001)).rejects.toThrow( + it('rejects TTL above 7,776,000 (90 days)', async () => { + await expect(backend.set('key', new Uint8Array([1]), 7_776_001)).rejects.toThrow( ConfigurationError ); expect(fetchSpy).not.toHaveBeenCalled(); }); - it('accepts TTL at the 30-day ceiling', async () => { + // 2,592,001 is over the Startup plan maximum and the old 30-day ceiling: + // the SDK sends it as is, because it does not know the plan. + it.each([1, 2_592_001, 7_776_000])('sends TTL %i as is', async (ttl) => { fetchSpy.mockResolvedValueOnce(mockResponse(200)); - await backend.set('key', new Uint8Array([1]), 2_592_000); + await backend.set('key', new Uint8Array([1]), ttl); const [, opts] = fetchSpy.mock.calls[0] as [string, RequestInit]; - expect((opts.headers as Record)['X-CacheKit-TTL']).toBe('2592000'); + expect((opts.headers as Record)['X-CacheKit-TTL']).toBe(String(ttl)); }); it('ceils sub-second TTL to 1, never truncates to 0', async () => { @@ -397,9 +400,10 @@ describe('CachekitIO Backend', () => { it('rejects invalid defaultTtl at construction', () => { expect(() => cachekitio({ ...validConfig, defaultTtl: 0 })).toThrow(ConfigurationError); - expect(() => cachekitio({ ...validConfig, defaultTtl: 2_592_001 })).toThrow( + expect(() => cachekitio({ ...validConfig, defaultTtl: 7_776_001 })).toThrow( ConfigurationError ); + expect(() => cachekitio({ ...validConfig, defaultTtl: 7_776_000 })).not.toThrow(); }); it('closed state rejects set', async () => { @@ -517,3 +521,29 @@ describe('CachekitIO Backend', () => { }); }); }); + +// The 90-day TTL range (protocol spec/saas-api.md, TTL Validation Rules and +// stale-window Validation), each bound and one over. The X-CacheKit-Fresh-For +// cap stays 2,592,000: see cachekitio-freshness.test.ts. +describe('TTL range bounds', () => { + it('accepts a TTL of 1..7,776,000 and rejects one over', () => { + expect(validateTtl(1)).toBe(1); + expect(validateTtl(7_776_000)).toBe(7_776_000); + expect(() => validateTtl(7_776_001)).toThrow(ConfigurationError); + expect(() => validateTtl(7_776_000.5)).toThrow(ConfigurationError); + }); + + it('accepts a staleTtl up to 7,775,999 and rejects one over, before the sum', () => { + expect(validateStaleTtl(7_775_999, 1)).toBe(7_775_999); + expect(() => validateStaleTtl(7_776_000, 0)).toThrow(/at most 7775999/); + }); + + it('accepts ttl + staleTtl up to 7,776,000 and rejects one over', () => { + expect(validateStaleTtl(1, 7_775_999)).toBe(1); + expect(validateStaleTtl(7_775_990, 10)).toBe(7_775_990); + expect(() => validateStaleTtl(7_775_991, 10)).toThrow( + /ttl \+ staleTtl must be at most 7776000/ + ); + expect(() => validateStaleTtl(1, 7_776_000)).toThrow(ConfigurationError); + }); +}); diff --git a/packages/cachekit/src/backends/cachekitio.ts b/packages/cachekit/src/backends/cachekitio.ts index eeb85f46..461a82c6 100644 --- a/packages/cachekit/src/backends/cachekitio.ts +++ b/packages/cachekit/src/backends/cachekitio.ts @@ -1,4 +1,4 @@ -import { Backend, CachekitIOBackendConfig, GetWithTtlResult } from './types.js'; +import { Backend, CachekitIOBackendConfig, GetWithTtlResult, SetResult } from './types.js'; import { BackendError, ConfigurationError, TimeoutError } from '../errors.js'; import { DEFAULT_TTL_SECONDS } from '../constants.js'; import { getSessionHeaders } from './session.js'; @@ -87,26 +87,39 @@ function parseFreshFor(raw: string): number { if (digit < 0 || digit > 9) return 0; seconds = seconds * 10 + digit; } - return seconds > MAX_TTL_SECONDS ? 0 : seconds; + return seconds > MAX_FRESH_FOR_SECONDS ? 0 : seconds; } const DEFAULT_API_URL = 'https://api.cachekit.io'; const DEFAULT_TIMEOUT_MS = 5_000; -/** Protocol TTL ceiling: 30 days in seconds (protocol/spec/saas-api.md, TTL Validation Rules). */ -const MAX_TTL_SECONDS = 2_592_000; +/** + * The `X-CacheKit-Fresh-For` cap: 30 days in seconds (protocol spec/saas-api.md + * § Remaining Freshness, API-32). A grammar bound on the header, deliberately + * NOT the TTL range below: the header never exceeds it, so a larger value is a + * buggy tier and reads as `0`. Never merge the two constants. + */ +const MAX_FRESH_FOR_SECONDS = 2_592_000; + +/** + * The TTL range ceiling: 90 days in seconds (protocol spec/saas-api.md, TTL + * Validation Rules). It bounds a stated TTL, not an entry's lifetime: the + * store clamps a TTL above the tenant's plan maximum, and the SDK sends it + * as is because it does not know the plan. + */ +const MAX_TTL_SECONDS = 7_776_000; /** * Validate a TTL per the protocol's normative TTL Validation Rules * (protocol/spec/saas-api.md): zero, negative, non-finite, and values over - * 30 days are rejected; sub-second/fractional durations are ceiled to whole + * 90 days are rejected; sub-second/fractional durations are ceiled to whole * seconds (never truncated to 0). Exported for the TTL decorator's * refreshTTL, which sends the same value in the PATCH body. */ export function validateTtl(ttl: number): number { if (!Number.isFinite(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) { throw new ConfigurationError( - `TTL must be greater than 0 and at most ${MAX_TTL_SECONDS} seconds (30 days), got ${ttl}` + `TTL must be greater than 0 and at most ${MAX_TTL_SECONDS} seconds (90 days), got ${ttl}` ); } return Math.ceil(ttl); @@ -115,9 +128,9 @@ export function validateTtl(ttl: number): number { /** * Validate a stale-grace window per the protocol's Stale-While-Revalidate * validation rules (protocol/spec/saas-api.md): negative, non-finite and - * values over 2,591,999 are rejected before any arithmetic; fractions are - * ceiled (API-54); the window shares the 30-day cap with `wireTtl`, the - * already-validated TTL. Returns the whole seconds to send; `0` sends nothing. + * values over 7,775,999 are rejected before any arithmetic; fractions are + * ceiled (API-54); the window shares the 90-day TTL range with `wireTtl`, + * the already-validated TTL. Returns the whole seconds to send; `0` sends nothing. */ export function validateStaleTtl(staleTtl: number, wireTtl: number): number { if (!Number.isFinite(staleTtl) || staleTtl < 0 || staleTtl > MAX_TTL_SECONDS - 1) { @@ -128,7 +141,7 @@ export function validateStaleTtl(staleTtl: number, wireTtl: number): number { const wire = Math.ceil(staleTtl); if (wireTtl + wire > MAX_TTL_SECONDS) { throw new ConfigurationError( - `ttl + staleTtl must be at most ${MAX_TTL_SECONDS} seconds (30 days), got ${wireTtl} + ${wire}` + `ttl + staleTtl must be at most ${MAX_TTL_SECONDS} seconds (90 days), got ${wireTtl} + ${wire}` ); } return wire; @@ -226,25 +239,28 @@ export class CachekitIOCore implements Backend { encodeKey(key); } - async set(key: string, value: Uint8Array, ttl?: number): Promise { + /** Resolves to the store's `X-CacheKit-Fresh-For` off the `PUT` 2xx, when + * it sent one: the bound a plan maximum TTL applied (see SetResult). */ + async set(key: string, value: Uint8Array, ttl?: number): Promise { this.ensureNotClosed(); const effectiveTtl = validateTtl(ttl ?? this.defaultTtl); - await this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl) }); + return this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl) }); } /** Backend.setWithStaleTtl capability: the same PUT, also carrying * `X-CacheKit-Stale-TTL`. Validated before any I/O; a `0` window sends no - * header, which the server reads the same way. */ + * header, which the server reads the same way. Resolves like `set`: the + * stale window never lengthens the reported bound. */ async setWithStaleTtl( key: string, value: Uint8Array, ttl: number, staleTtl: number - ): Promise { + ): Promise { this.ensureNotClosed(); const effectiveTtl = validateTtl(ttl); const wireStaleTtl = validateStaleTtl(staleTtl, effectiveTtl); - await this.put(key, value, { + return this.put(key, value, { 'X-CacheKit-TTL': String(effectiveTtl), ...(wireStaleTtl > 0 && { 'X-CacheKit-Stale-TTL': String(wireStaleTtl) }), }); @@ -326,13 +342,16 @@ export class CachekitIOCore implements Backend { // ── Internal ────────────────────────────────────────────── - /** PUT the value with `headers`. Validate before calling: the catch - * below would wrap a ConfigurationError as a BackendError. */ + /** PUT the value with `headers`, and return the 2xx's `X-CacheKit-Fresh-For` + * read with the GET grammar (malformed = `0`), or `{}` when it is absent + * (protocol spec/saas-api.md § Remaining Freshness, API-103). Validate + * before calling: the catch below would wrap a ConfigurationError as a + * BackendError. */ private async put( key: string, value: Uint8Array, headers: Record - ): Promise { + ): Promise { const url = this.cacheUrl(key); try { @@ -344,6 +363,8 @@ export class CachekitIOCore implements Backend { if (!response.ok) { throw await this.httpError('set', response); } + const freshFor = response.headers.get('X-CacheKit-Fresh-For'); + return freshFor === null ? {} : { freshFor: parseFreshFor(freshFor) }; } catch (error) { if (error instanceof BackendError || error instanceof TimeoutError) throw error; throw this.wrapError('set', error); diff --git a/packages/cachekit/src/backends/types.ts b/packages/cachekit/src/backends/types.ts index f412c940..30cafdc0 100644 --- a/packages/cachekit/src/backends/types.ts +++ b/packages/cachekit/src/backends/types.ts @@ -54,12 +54,21 @@ export interface Backend { /** * Store a value with optional TTL. * + * A backend whose store can apply a shorter lifetime than the one it was + * given (CachekitIO: a plan maximum TTL clamps the write) resolves to a + * {@link SetResult} reporting that bound, and CacheImpl caps the L1 copy of + * the value it wrote with it. Every other backend resolves to nothing. + * + * Delegating wrappers MUST return the inner backend's result, or they hide + * the bound from CacheImpl. + * * @param key - Cache key * @param value - Raw bytes to store * @param ttl - Time-to-live in seconds (optional, backend default if omitted) + * @returns Nothing, or the bound the store applied (see {@link SetResult}) * @throws {BackendError} if the operation fails */ - set(key: string, value: Uint8Array, ttl?: number): Promise; + set(key: string, value: Uint8Array, ttl?: number): Promise; /** * Optional capability: `set` plus a stale-grace window, the time the store @@ -69,13 +78,20 @@ export interface Backend { * CacheImpl refuses `WrapOptions.staleTtl` on a backend without both, because * a reader that cannot see the stale label caches stale bytes as fresh. * - * Delegating wrappers MUST forward the inner backend's implementation. + * Delegating wrappers MUST forward the inner backend's implementation and + * return its result, as for {@link set}. * * @param ttl - Time-to-live in seconds (required: the window counts from it) * @param staleTtl - Stale-grace window in seconds, >= 0; `0` sends no window + * @returns Nothing, or the bound the store applied (see {@link SetResult}) * @throws {BackendError} if the operation fails */ - setWithStaleTtl?(key: string, value: Uint8Array, ttl: number, staleTtl: number): Promise; + setWithStaleTtl?( + key: string, + value: Uint8Array, + ttl: number, + staleTtl: number + ): Promise; /** * Optional capability: synchronously reject a TTL this backend cannot @@ -87,7 +103,7 @@ export interface Backend { * failures. Same rationale as the interop-rejection path in CacheImpl. * * Implement only where the backend has hard TTL bounds (CachekitIO's - * protocol rules: reject 0/negative/> 30 days). Backends that map + * protocol rules: reject 0/negative/> 90 days). Backends that map * out-of-range TTLs to a defined behavior (e.g. `ttl <= 0` = no expiry * on Redis/File/Workers KV) omit it. * @@ -225,6 +241,24 @@ export interface GetWithTtlResult { freshFor?: number; } +/** + * Result of a {@link Backend.set} (or `setWithStaleTtl`) whose store reported + * the bound it applied to the write. + */ +export interface SetResult { + /** + * The stored entry's remaining freshness in whole seconds, as the store + * reported it on the write's success response (CachekitIO + * `X-CacheKit-Fresh-For` on the `PUT` 2xx, read with the same grammar as + * on a `GET`). CacheImpl stops serving its L1 copy of the value once this + * many seconds have passed since it received the response, whatever the + * TTL; `0` (a malformed header included) means L1 keeps no copy (protocol + * spec/saas-api.md § Remaining Freshness, API-103). Omitted = the store + * applied the TTL it was given, and the L1 copy keeps its usual lifetime. + */ + freshFor?: number; +} + /** * Configuration for Redis backend. */ @@ -350,9 +384,12 @@ export interface CachekitIOBackendConfig { /** * Default TTL in seconds for set operations without explicit TTL. * Protocol bounds apply (saas-api.md TTL Validation Rules): must be - * greater than 0 and at most 2,592,000 (30 days) — out-of-range values + * greater than 0 and at most 7,776,000 (90 days) — out-of-range values * throw ConfigurationError at construction. Unlike File/Memcached, - * `0` does NOT mean "never expire" here; the protocol rejects it. + * `0` does NOT mean "never expire" here; the protocol rejects it. The + * server clamps a TTL above the tenant's plan maximum TTL (Free 24 hours, + * Startup 30 days, Professional 90 days) rather than rejecting it, and + * the write's L1 copy follows the clamped lifetime. */ defaultTtl?: number; /** Per-attempt request timeout in milliseconds (default: 5000, the protocol's CACHEKIT_TIMEOUT default) */ diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 3475ccd1..7b29968c 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -8,7 +8,7 @@ import type { EncryptionConfig, InvalidationConfig, } from './types/cache.js'; -import type { Backend, L1Metrics, LockableBackend } from './backends/types.js'; +import type { Backend, L1Metrics, LockableBackend, SetResult } from './backends/types.js'; import type { InvalidationEvent } from './l1/types.js'; import type { MetricsCollector, MetricsConfig } from './metrics/prometheus.js'; import { checkDefaultLabels } from './metrics/labels.js'; @@ -17,6 +17,7 @@ import { L1Cache, type L1Entry } from './l1/lru-cache.js'; import { ReliabilityExecutor } from './reliability/executor.js'; import { BackgroundRefreshManager, + boundedL1TtlMs, type WaitUntil, type L1Write, } from './cache/background-refresh.js'; @@ -167,6 +168,21 @@ const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout const WASM_RUNTIME_ERROR = (globalThis as { WebAssembly?: { RuntimeError?: ErrorConstructor } }) .WebAssembly?.RuntimeError; +/** + * When (Date.now() ms) the store's bound on a write ends, read off what + * Backend.set resolved to at receipt of its success response: `freshFor` + * seconds from now, or undefined when the backend reported none. A value + * that is not a non-negative number (a custom backend's bug) fails closed, + * as `0`: NaN would otherwise slip past every bound check and keep the L1 + * copy forever. + */ +function freshUntil(result: SetResult | void): number | undefined { + const freshFor = (result as SetResult | undefined)?.freshFor; + if (freshFor === undefined) return undefined; + const now = Date.now(); + return typeof freshFor === 'number' && freshFor >= 0 ? now + freshFor * 1000 : now; +} + /** * A failure inside unpack that is not a verdict on the bytes: a wasm trap * (an allocation abort on a Workers isolate surfaces as @@ -1255,7 +1271,7 @@ export class CacheImpl implements SecureCache { throw new ConfigurationError(`Invalid TTL: ${ttl}. Must be a non-negative finite number.`); } - // A backend with hard TTL bounds (CachekitIO: reject 0 / > 30 days per + // A backend with hard TTL bounds (CachekitIO: reject 0 / > 90 days per // protocol) or a key it cannot address (CachekitIO's reserved path // segments) rejects here, synchronously — for the same reason as the // interop rejection below: inside `run`, degradation would swallow the @@ -1340,11 +1356,15 @@ export class CacheImpl implements SecureCache { }; // The L2 write, under retry, the breaker and degradation; the stale - // window rides it on a stale-grace wrap. - const writeL2 = (): Promise => - staleTtl > 0 - ? this.backend.setWithStaleTtl!(key, data, ttl, staleTtl) - : this.backend.set(key, data, ttl); + // window rides it on a stale-grace wrap. It resolves to when the store's + // bound on the write ends (L1Write.freshUntil), counted from receipt of + // the success response, or undefined when the store reported none. + const writeL2 = async (): Promise => + freshUntil( + staleTtl > 0 + ? await this.backend.setWithStaleTtl!(key, data, ttl, staleTtl) + : await this.backend.set(key, data, ttl) + ); // A stale-grace revalidation never fills L1 (its l1Mode is skip), and may // be dropped: the stale entry still serves. @@ -1363,13 +1383,25 @@ export class CacheImpl implements SecureCache { // keeps the value, as it keeps a degraded write's below. A write sent // back to the caller fills L1 after its L2 write, as with the option off. // A skipL1 call fills L1 in neither case, so until its write lands this - // process misses the key. + // process misses the key. When the write lands with a bound from the + // store, the L1 copy is capped at it then (API-103), if L1 still holds + // that copy; L1 is filled before the write can settle, so `held` is set. const fillsL1 = l1Mode === 'fill' && this.l1 !== null; + let held: L1Entry | undefined; + const writeBehind = async (): Promise => { + const until = await writeL2(); + if (held === undefined || until === undefined || !this.l1) return; + this.l1.bound(held, until); + this.publishL1Stats(); + }; const wroteBehind = deferred?.kind === 'write-behind' && - this.tryWriteBehind(key, data.length, writeL2, deferred.waitUntil, fillsL1); + this.tryWriteBehind(key, data.length, writeBehind, deferred.waitUntil, fillsL1); if (wroteBehind) { - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); + if (l1Mode === 'fill') { + this.fillL1(key, l1Write, ttl, namespace); + held = this.l1?.entry(key); + } return l1Write; } @@ -1398,29 +1430,47 @@ export class CacheImpl implements SecureCache { // L1 but is overwritten in L2 by the refresh's value until the entry next // expires or refreshes (a conditional L2 write would need CAS the Backend // contract doesn't have). + // A write that got a success response carrying the store's bound caps + // the L1 copy at it (API-103), here and on the refresh path through the + // returned L1Write. A write degradation absorbed got no response: its copy + // keeps the TTL, counted from the L1 write (API-36). + let until: number | undefined; if (this.keyTails.has(key)) { await this.inKeyOrder(key, async () => { - await this.run('set', key, undefined, writeL2); - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); + until = await this.run('set', key, undefined, writeL2); + if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace, until); }); - return l1Write; - } - // An idle key: what inKeyOrder does for it, inline. Every plain set() - // takes this path, and inKeyOrder's closure and promise chain were most - // of what per-key ordering added to it, so the two lines above repeat. - this.enterInFlight(key); - try { - await this.run('set', key, undefined, writeL2); - if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace); - } finally { - this.leaveInFlight(key); + } else { + // An idle key: what inKeyOrder does for it, inline. Every plain set() + // takes this path, and inKeyOrder's closure and promise chain were most + // of what per-key ordering added to it, so the two lines above repeat. + this.enterInFlight(key); + try { + until = await this.run('set', key, undefined, writeL2); + if (l1Mode === 'fill') this.fillL1(key, l1Write, ttl, namespace, until); + } finally { + this.leaveInFlight(key); + } } - return l1Write; + return until === undefined ? l1Write : { ...l1Write, freshUntil: until }; } - private fillL1(key: string, l1Write: L1Write, ttl: number, namespace: string): void { + /** + * Put the value a write produced in L1 for `ttl` seconds, capped at the + * store's bound `until` (L1Write.freshUntil). Past the bound L1 keeps no + * copy, and drops the key's older one, which the write superseded. + */ + private fillL1( + key: string, + l1Write: L1Write, + ttl: number, + namespace: string, + until?: number + ): void { if (!this.l1) return; - this.l1.set(key, l1Write.l1, ttl * 1000, namespace, l1Write.serializedSize, l1Write.count); + const ttlMs = boundedL1TtlMs(ttl, until); + if (ttlMs === null) this.l1.delete(key); + else this.l1.set(key, l1Write.l1, ttlMs, namespace, l1Write.serializedSize, l1Write.count); this.publishL1Stats(); } @@ -1446,7 +1496,7 @@ export class CacheImpl implements SecureCache { private tryWriteBehind( key: string, bytes: number, - writeL2: () => Promise, + writeL2: () => Promise, waitUntil: WaitUntil | undefined, heldInL1: boolean ): boolean { @@ -1575,7 +1625,7 @@ export class CacheImpl implements SecureCache { private queueRevalidationWrite( key: string, bytes: number, - writeL2: () => Promise + writeL2: () => Promise ): Promise | null { if ( this.closed || @@ -1585,7 +1635,9 @@ export class CacheImpl implements SecureCache { this.revalidationDropped(key); return null; } - const send = (): Promise => this.run('set', key, undefined, writeL2); + const send = async (): Promise => { + await this.run('set', key, undefined, writeL2); + }; // On Workers no tail exists here (checked above), so inKeyOrder counts it in flight. if (this.swrRequiresWaitUntil) return this.inKeyOrder(key, send); return this.startPendingWrite(key, bytes, () => { diff --git a/packages/cachekit/src/cache.stale-grace.test.ts b/packages/cachekit/src/cache.stale-grace.test.ts index e1917847..7b72b4cc 100644 --- a/packages/cachekit/src/cache.stale-grace.test.ts +++ b/packages/cachekit/src/cache.stale-grace.test.ts @@ -462,18 +462,19 @@ describe('server stale-grace (WrapOptions.staleTtl)', () => { ['negative', -1], ['NaN', NaN], ['Infinity', Infinity], - ['over 2,591,999', 2_591_999.5], + ['over 7,775,999', 7_775_999.5], + ['7,776,000, one over the standalone bound', 7_776_000], ['a string', '60' as unknown as number], - ['over the 30-day cap with the TTL', 2_591_991], + ['over the 90-day TTL range with the TTL', 7_775_991], ])('rejects a staleTtl that is %s', (_name, staleTtl) => { const cache = process(new StaleGraceBackend()); expect(() => cache.wrap(async () => 'v', { ...WRAP, staleTtl })).toThrow(ConfigurationError); }); - it('accepts the largest window the 30-day cap leaves', async () => { + it('accepts the largest window the 90-day TTL range leaves', async () => { const backend = new StaleGraceBackend(); - await process(backend).wrap(async () => 'v', { ...WRAP, staleTtl: 2_591_990 })(); - expect(backend.writes).toEqual([{ ttl: 10, staleTtl: 2_591_990 }]); + await process(backend).wrap(async () => 'v', { ...WRAP, staleTtl: 7_775_990 })(); + expect(backend.writes).toEqual([{ ttl: 10, staleTtl: 7_775_990 }]); }); it('ceils a fractional window (API-54) and treats 0 as off', async () => { @@ -616,8 +617,10 @@ describe('CachekitIO stale window on the wire', () => { for (const [ttl, staleTtl] of [ [10, -1], [10, NaN], - [10, 2_591_999.5], - [10, 2_591_991], + [10, 7_775_999.5], + [1, 7_776_000], + [10, 7_775_991], + [7_776_000, 1], [0, 60], ]) { await expect(backend.setWithStaleTtl!('k', value, ttl!, staleTtl!)).rejects.toThrow( diff --git a/packages/cachekit/src/cache/background-refresh.ts b/packages/cachekit/src/cache/background-refresh.ts index 30acd54c..210ec140 100644 --- a/packages/cachekit/src/cache/background-refresh.ts +++ b/packages/cachekit/src/cache/background-refresh.ts @@ -33,6 +33,29 @@ export interface L1Write { readonly serializedSize?: number; /** Heap objects and values in the value, counted as it was serialized (see L1Cache.set). */ readonly count?: ObjectCount; + /** + * When (Date.now() ms) the store's bound on the write ends: the receipt of + * its success response plus the `freshFor` it reported (Backend.set's + * SetResult). Unset when the store reported none, or the write never got a + * success response. See boundedL1TtlMs. + */ + readonly freshUntil?: number; +} + +/** + * The L1 lifetime, in ms, for the copy a cache keeps of a value it wrote with + * `ttlSeconds`, the store having bounded it until `freshUntil` (see + * L1Write.freshUntil): the shorter of the two, counted from now. `0` is L1's + * no-expiry encoding (a `ttlSeconds` of 0 or less, unbounded). Null when the + * bound has passed, so L1 must keep no copy (protocol spec/saas-api.md + * § Remaining Freshness, API-103). + */ +export function boundedL1TtlMs(ttlSeconds: number, freshUntil?: number): number | null { + const ttlMs = ttlSeconds > 0 ? ttlSeconds * 1000 : 0; + if (freshUntil === undefined) return ttlMs; + const remaining = freshUntil - Date.now(); + if (remaining <= 0) return null; + return ttlMs > 0 ? Math.min(ttlMs, remaining) : remaining; } /** @@ -121,15 +144,20 @@ export class BackgroundRefreshManager { // per minute. l1Cache.deferRefresh(key, versionToken); } else { - l1Cache.completeRefresh( - key, - persisted.l1, - options.ttl * 1000, - versionToken, - options.namespace, - persisted.serializedSize, - persisted.count - ); + // The store's bound on the write caps the copy; past it, the + // refreshed value is not kept and the stale entry goes too. + const ttlMs = boundedL1TtlMs(options.ttl, persisted.freshUntil); + if (ttlMs === null) l1Cache.discardRefresh(key, versionToken); + else + l1Cache.completeRefresh( + key, + persisted.l1, + ttlMs, + versionToken, + options.namespace, + persisted.serializedSize, + persisted.count + ); } } } catch (error) { diff --git a/packages/cachekit/src/exports-common.ts b/packages/cachekit/src/exports-common.ts index 35b141f5..cb01717e 100644 --- a/packages/cachekit/src/exports-common.ts +++ b/packages/cachekit/src/exports-common.ts @@ -23,6 +23,7 @@ export type { export type { Backend, GetWithTtlResult, + SetResult, CachekitIOBackendConfig, LockableBackend, TTLBackend, diff --git a/packages/cachekit/src/l1/lru-cache.ts b/packages/cachekit/src/l1/lru-cache.ts index 1e5871ed..d272b88a 100644 --- a/packages/cachekit/src/l1/lru-cache.ts +++ b/packages/cachekit/src/l1/lru-cache.ts @@ -294,6 +294,40 @@ export class L1Cache { return true; } + /** + * Cap an entry `entry` returned at `until` (a Date.now() time): it stops + * being served then, or now if `until` has passed, and is never extended. + * The SWR threshold follows the shorter lifetime, so a capped entry is not + * read as stale for the whole of it. Nothing changes unless L1 still holds + * this very entry: a later write or invalidation of the key is untouched. + * Package-internal. + * @internal + */ + bound(entry: L1Entry, until: number): void { + const node = entry as Node; + if (this.cache.get(node.key) !== node) return; + const remaining = until - Date.now(); + if (remaining <= 0) { + this.delete(node.key); + return; + } + if (until >= node.expiresAt) return; + node.expiresAt = until; + node.originalTtl = remaining; + } + + /** + * End a SWR refresh whose value L1 may not keep (its store bounded it to + * nothing), dropping the stale entry it would have replaced if the version + * still matches. A newer write or invalidation of the key is kept. + * Package-internal. + * @internal + */ + discardRefresh(key: string, versionToken: number): void { + this.refreshingKeys.delete(key); + if ((this.entryVersion.get(key) ?? 0) === versionToken) this.delete(key); + } + /** * Complete a SWR refresh, updating the cache if version matches (a value * charged above an eighth of maxMemory drops the entry instead; see `set`). diff --git a/packages/cachekit/src/types/cache.ts b/packages/cachekit/src/types/cache.ts index d17233bb..782c3555 100644 --- a/packages/cachekit/src/types/cache.ts +++ b/packages/cachekit/src/types/cache.ts @@ -96,8 +96,10 @@ export interface WrapOptionsBase { * * Checked at wrap time (ConfigurationError): requires a backend that reads * and writes the stale window (CachekitIO); refused on encrypted caches; at - * most 2,591,999 seconds, and `ttl + staleTtl` at most 2,592,000 (30 days); - * fractions round up; `0` turns it off. + * most 7,775,999 seconds, and `ttl + staleTtl` at most 7,776,000 (90 days, + * the protocol's TTL range); fractions round up; `0` turns it off. The + * server shortens a window the tenant's plan maximum TTL leaves no room + * for, and never rejects it for that. * * On Workers the revalidation needs a bound `waitUntil` * (`withExecutionContext`); without one a stale read is a miss, recomputed From 8c186517a0d0f6c400852e6fffdb9838351e6b63 Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 17:18:42 +1100 Subject: [PATCH 2/4] test(cachekitio): cover every branch of the L1 bound helpers --- .../src/cache/background-refresh.test.ts | 26 ++++++++- packages/cachekit/src/l1/lru-cache.test.ts | 54 +++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/cachekit/src/cache/background-refresh.test.ts b/packages/cachekit/src/cache/background-refresh.test.ts index 13587b6b..2a1fc33e 100644 --- a/packages/cachekit/src/cache/background-refresh.test.ts +++ b/packages/cachekit/src/cache/background-refresh.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; -import { BackgroundRefreshManager, type PersistCallback } from './background-refresh.js'; +import { + BackgroundRefreshManager, + boundedL1TtlMs, + type PersistCallback, +} from './background-refresh.js'; import { L1Cache } from '../l1/lru-cache.js'; // Pin the SWR jitter draw so tests are deterministic. 0.5 lands the @@ -329,3 +333,23 @@ describe('BackgroundRefreshManager', () => { }); }); }); + +describe('boundedL1TtlMs', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('keeps the TTL without a bound, and 0 (no expiry) for ttl <= 0', () => { + expect(boundedL1TtlMs(60)).toBe(60_000); + expect(boundedL1TtlMs(0)).toBe(0); + expect(boundedL1TtlMs(-1)).toBe(0); + }); + + it('takes the shorter of TTL and bound, the bound alone for no expiry, null once past', () => { + vi.useFakeTimers({ now: 1_000 }); + expect(boundedL1TtlMs(60, 1_000 + 5_000)).toBe(5_000); + expect(boundedL1TtlMs(2, 1_000 + 5_000)).toBe(2_000); + expect(boundedL1TtlMs(0, 1_000 + 5_000)).toBe(5_000); + expect(boundedL1TtlMs(60, 1_000)).toBeNull(); + }); +}); diff --git a/packages/cachekit/src/l1/lru-cache.test.ts b/packages/cachekit/src/l1/lru-cache.test.ts index d60d20a3..87687ce9 100644 --- a/packages/cachekit/src/l1/lru-cache.test.ts +++ b/packages/cachekit/src/l1/lru-cache.test.ts @@ -1338,3 +1338,57 @@ describe('L1Cache', () => { }); }); }); + +describe('L1Cache bound and discardRefresh', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('bound caps the entry and shortens its SWR lifetime, never extends it', () => { + vi.useFakeTimers({ now: 0 }); + const c = new L1Cache(); + c.set('a', 'v', 10_000, 'ns'); + const entry = c.entry('a')!; + + c.bound(entry, 20_000); // later than expiry: no change + vi.advanceTimersByTime(9_999); + expect(c.get('a')).toBe('v'); + + c.set('b', 'v', 10_000, 'ns'); + c.bound(c.entry('b')!, Date.now() + 2_000); + vi.advanceTimersByTime(1_999); + expect(c.get('b')).toBe('v'); + vi.advanceTimersByTime(1); + expect(c.get('b')).toBeNull(); + }); + + it('bound past its deadline drops the entry; a superseded entry is left alone', () => { + vi.useFakeTimers({ now: 0 }); + const c = new L1Cache(); + c.set('a', 'old', 10_000, 'ns'); + const old = c.entry('a')!; + c.set('a', 'new', 10_000, 'ns'); + + c.bound(old, 0); // not the entry L1 holds now + expect(c.get('a')).toBe('new'); + + c.bound(c.entry('a')!, 0); + expect(c.get('a')).toBeNull(); + }); + + it('discardRefresh drops the entry only while its version matches', () => { + const c = new L1Cache({ swrEnabled: true, swrThresholdRatio: 2 }); + c.set('a', 'v', 10_000, 'ns'); + const { versionToken } = c.getWithSwr('a'); + + c.set('a', 'newer', 10_000, 'ns'); + c.discardRefresh('a', versionToken); + expect(c.get('a')).toBe('newer'); + + c.discardRefresh('a', c.getWithSwr('a').versionToken); + expect(c.get('a')).toBeNull(); + + c.discardRefresh('missing', 0); // no version: reads as 0, nothing to drop + expect(c.get('missing')).toBeNull(); + }); +}); From d5657af50d1c184e2f171b406df6ec5322b6395b Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 08:21:13 +0000 Subject: [PATCH 3/4] refactor(l1): rename bound to capExpiryAt and drop its cast; narrow SetResult without a cast capExpiryAt looks the entry up by key and compares identity, so it needs no cast. Its doc says it deletes an entry whose cap has passed. freshUntil narrows the set result by truthiness. Test stubs check the PUT body is bytes, and the discardRefresh test freezes time. --- .../src/backends/cachekitio-freshness.test.ts | 6 ++++-- packages/cachekit/src/cache-core.ts | 5 +++-- packages/cachekit/src/l1/lru-cache.test.ts | 11 ++++++----- packages/cachekit/src/l1/lru-cache.ts | 9 +++++---- 4 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index 748499fc..f2f30715 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -83,7 +83,8 @@ describe('CachekitIO L1 backfill bound', () => { 'fetch', vi.fn(async (url: string, init: RequestInit) => { if (init.method === 'PUT') { - store.set(url, init.body as Uint8Array); + if (!(init.body instanceof Uint8Array)) throw new TypeError('PUT body must be bytes'); + store.set(url, init.body); return new Response(null, { status: 200 }); } gets++; @@ -276,7 +277,8 @@ describe('CachekitIO writer L1 bound', () => { 'fetch', vi.fn(async (url: string, init: RequestInit) => { if (init.method === 'PUT') { - store.set(url, init.body as Uint8Array); + if (!(init.body instanceof Uint8Array)) throw new TypeError('PUT body must be bytes'); + store.set(url, init.body); return new Response(null, { status: 200, headers: putHeaders }); } if (init.method === 'PATCH') { diff --git a/packages/cachekit/src/cache-core.ts b/packages/cachekit/src/cache-core.ts index 7b29968c..db1ab70e 100644 --- a/packages/cachekit/src/cache-core.ts +++ b/packages/cachekit/src/cache-core.ts @@ -177,7 +177,8 @@ const WASM_RUNTIME_ERROR = (globalThis as { WebAssembly?: { RuntimeError?: Error * copy forever. */ function freshUntil(result: SetResult | void): number | undefined { - const freshFor = (result as SetResult | undefined)?.freshFor; + if (!result) return undefined; + const { freshFor } = result; if (freshFor === undefined) return undefined; const now = Date.now(); return typeof freshFor === 'number' && freshFor >= 0 ? now + freshFor * 1000 : now; @@ -1391,7 +1392,7 @@ export class CacheImpl implements SecureCache { const writeBehind = async (): Promise => { const until = await writeL2(); if (held === undefined || until === undefined || !this.l1) return; - this.l1.bound(held, until); + this.l1.capExpiryAt(held, until); this.publishL1Stats(); }; const wroteBehind = diff --git a/packages/cachekit/src/l1/lru-cache.test.ts b/packages/cachekit/src/l1/lru-cache.test.ts index 87687ce9..a7bac233 100644 --- a/packages/cachekit/src/l1/lru-cache.test.ts +++ b/packages/cachekit/src/l1/lru-cache.test.ts @@ -1339,7 +1339,7 @@ describe('L1Cache', () => { }); }); -describe('L1Cache bound and discardRefresh', () => { +describe('L1Cache capExpiryAt and discardRefresh', () => { afterEach(() => { vi.useRealTimers(); }); @@ -1350,12 +1350,12 @@ describe('L1Cache bound and discardRefresh', () => { c.set('a', 'v', 10_000, 'ns'); const entry = c.entry('a')!; - c.bound(entry, 20_000); // later than expiry: no change + c.capExpiryAt(entry, 20_000); // later than expiry: no change vi.advanceTimersByTime(9_999); expect(c.get('a')).toBe('v'); c.set('b', 'v', 10_000, 'ns'); - c.bound(c.entry('b')!, Date.now() + 2_000); + c.capExpiryAt(c.entry('b')!, Date.now() + 2_000); vi.advanceTimersByTime(1_999); expect(c.get('b')).toBe('v'); vi.advanceTimersByTime(1); @@ -1369,14 +1369,15 @@ describe('L1Cache bound and discardRefresh', () => { const old = c.entry('a')!; c.set('a', 'new', 10_000, 'ns'); - c.bound(old, 0); // not the entry L1 holds now + c.capExpiryAt(old, 0); // not the entry L1 holds now expect(c.get('a')).toBe('new'); - c.bound(c.entry('a')!, 0); + c.capExpiryAt(c.entry('a')!, 0); expect(c.get('a')).toBeNull(); }); it('discardRefresh drops the entry only while its version matches', () => { + vi.useFakeTimers({ now: 0 }); const c = new L1Cache({ swrEnabled: true, swrThresholdRatio: 2 }); c.set('a', 'v', 10_000, 'ns'); const { versionToken } = c.getWithSwr('a'); diff --git a/packages/cachekit/src/l1/lru-cache.ts b/packages/cachekit/src/l1/lru-cache.ts index d272b88a..085f74de 100644 --- a/packages/cachekit/src/l1/lru-cache.ts +++ b/packages/cachekit/src/l1/lru-cache.ts @@ -296,16 +296,17 @@ export class L1Cache { /** * Cap an entry `entry` returned at `until` (a Date.now() time): it stops - * being served then, or now if `until` has passed, and is never extended. + * being served then, and is never extended. If `until` has passed, the + * entry is deleted now. * The SWR threshold follows the shorter lifetime, so a capped entry is not * read as stale for the whole of it. Nothing changes unless L1 still holds * this very entry: a later write or invalidation of the key is untouched. * Package-internal. * @internal */ - bound(entry: L1Entry, until: number): void { - const node = entry as Node; - if (this.cache.get(node.key) !== node) return; + capExpiryAt(entry: L1Entry, until: number): void { + const node = this.cache.get(entry.key); + if (node === undefined || node !== entry) return; const remaining = until - Date.now(); if (remaining <= 0) { this.delete(node.key); From 0b5a6b6197f70443e93890f8190af7a7057e153f Mon Sep 17 00:00:00 2001 From: Ray Walker Date: Sun, 1 Nov 2026 14:17:37 +0000 Subject: [PATCH 4/4] test(cachekitio): drop the fetch stub's needless argument casts vi.mocked(fetch).getMockImplementation() is typed as fetch, so the wrapper passes url and init through unchanged with no assertion. --- packages/cachekit/src/backends/cachekitio-freshness.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cachekit/src/backends/cachekitio-freshness.test.ts b/packages/cachekit/src/backends/cachekitio-freshness.test.ts index f2f30715..b1b95b1c 100644 --- a/packages/cachekit/src/backends/cachekitio-freshness.test.ts +++ b/packages/cachekit/src/backends/cachekitio-freshness.test.ts @@ -378,7 +378,7 @@ describe('CachekitIO writer L1 bound', () => { const inner = fetchStub.getMockImplementation()!; fetchStub.mockImplementationOnce(async (url, init) => { vi.advanceTimersByTime(1_500); - return inner(url as string, init as RequestInit); + return inner(url, init); }); await cache.set('ns:k', 'v1', { ttl: 3600 });