@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,226 @@
1
+ // `@brust/core/cache-adapter/conformance`: the cases every cache adapter must pass (spec §9),
2
+ // registered with bun:test. Built-ins (lanes B/C) and custom adapters run the same suite:
3
+ //
4
+ // import { runConformance } from '@brust/core/cache-adapter/conformance'
5
+ // runConformance(() => myAdapter(opts), { push: true, guard: true })
6
+ //
7
+ // Each case builds a fresh adapter with `make()` and closes it afterwards. Keys are unique per case.
8
+ import { afterEach, describe, expect, test } from 'bun:test'
9
+ import type { CacheAdapter, InvalidationEvent, SetMeta, StoredPage } from './types'
10
+
11
+ export interface ConformanceCaps {
12
+ /** The broadcast is push (`subscribe`). */
13
+ push?: boolean
14
+ /** The broadcast is pull (`since`). */
15
+ pull?: boolean
16
+ /** The store refuses stale writes exactly (Redis Lua, DO, memory). */
17
+ guard?: boolean
18
+ /** The store invalidates (default true); `false` skips the tag/path invalidation cases (Cloudflare KV alone never invalidates). */
19
+ invalidate?: boolean
20
+ }
21
+
22
+ const enc = new TextEncoder()
23
+ const page = (body: string | Uint8Array, headers: [string, string][] = [['content-type', 'text/html']]): StoredPage => ({
24
+ status: 200,
25
+ headers,
26
+ body: typeof body === 'string' ? enc.encode(body) : body,
27
+ })
28
+ const meta = (o: Partial<SetMeta> = {}): SetMeta => ({ ttlMs: 60_000, tags: [], method: 'GET', path: '/', stamp: null, ...o })
29
+
30
+ async function until(what: string, f: () => boolean | Promise<boolean>, ms = 2000): Promise<void> {
31
+ const t0 = Date.now()
32
+ while (!(await f())) {
33
+ if (Date.now() - t0 > ms) throw new Error(`timed out: ${what}`)
34
+ await new Promise(r => setTimeout(r, 10))
35
+ }
36
+ }
37
+
38
+ export function runConformance(make: () => CacheAdapter | Promise<CacheAdapter>, caps: ConformanceCaps = {}): void {
39
+ let open: CacheAdapter[] = []
40
+ let n = 0
41
+ const key = () => `conf:${Date.now().toString(36)}:${n++}`
42
+ const fresh = async () => {
43
+ const a = await make()
44
+ await a.init?.({ namespace: 'conformance', buildId: 'b0', runtime: 'bun', workerId: 0 })
45
+ open.push(a)
46
+ return a
47
+ }
48
+ const store = async () => {
49
+ const a = await fresh()
50
+ if (!a.store) throw new Error(`adapter ${a.name} has no store`)
51
+ return { a, s: a.store }
52
+ }
53
+ afterEach(async () => {
54
+ const all = open
55
+ open = []
56
+ await Promise.allSettled(all.map(a => a.close?.()))
57
+ })
58
+
59
+ describe('cache adapter conformance', () => {
60
+ test('get of an absent key is a miss', async () => {
61
+ const { s } = await store()
62
+ const r = await s.get(key())
63
+ expect(r.page).toBeNull()
64
+ expect(r.entryStamp).toBeNull()
65
+ })
66
+
67
+ test('set then get round-trips status, headers (order + repeats) and body', async () => {
68
+ const { s } = await store()
69
+ const k = key()
70
+ const headers: [string, string][] = [
71
+ ['content-type', 'text/html; charset=utf-8'],
72
+ ['x-a', '1'],
73
+ ['x-a', '2'],
74
+ ['cache-control', 'no-store'],
75
+ ]
76
+ expect(await s.set(k, { status: 203, headers, body: enc.encode('<p>hi</p>') }, meta())).toBe(true)
77
+ const r = await s.get(k)
78
+ expect(r.page?.status).toBe(203)
79
+ expect(r.page?.headers).toEqual(headers)
80
+ expect(new TextDecoder().decode(r.page!.body)).toBe('<p>hi</p>')
81
+ expect(typeof r.storedAt === 'number' || r.storedAt === null).toBe(true)
82
+ })
83
+
84
+ test('bodies are binary-safe (all 256 byte values)', async () => {
85
+ const { s } = await store()
86
+ const k = key()
87
+ const body = Uint8Array.from({ length: 512 }, (_, i) => i % 256)
88
+ expect(await s.set(k, page(body), meta())).toBe(true)
89
+ expect(Array.from((await s.get(k)).page!.body)).toEqual(Array.from(body))
90
+ })
91
+
92
+ test('entryStamp round-trips the set stamp', async () => {
93
+ const { a, s } = await store()
94
+ const k = key()
95
+ const g = await s.get(k)
96
+ if (a.broadcast) expect(typeof g.stamp).toBe('number')
97
+ expect(await s.set(k, page('x'), meta({ stamp: g.stamp }))).toBe(true)
98
+ expect((await s.get(k)).entryStamp).toBe(g.stamp)
99
+ })
100
+
101
+ test('entries expire after ttlMs', async () => {
102
+ const { s } = await store()
103
+ const k = key()
104
+ expect(await s.set(k, page('x'), meta({ ttlMs: 1 }))).toBe(true)
105
+ await until('expiry', async () => (await s.get(k)).page === null)
106
+ })
107
+
108
+ if (caps.invalidate !== false) test('tag invalidation drops tagged pages and keeps unrelated ones', async () => {
109
+ const { s } = await store()
110
+ const [k1, k2, k3] = [key(), key(), key()]
111
+ await s.set(k1, page('1'), meta({ tags: ['items'], path: '/a' }))
112
+ await s.set(k2, page('2'), meta({ tags: ['other', 'items'], path: '/b' }))
113
+ await s.set(k3, page('3'), meta({ tags: ['other'], path: '/c' }))
114
+ await s.invalidate({ tags: ['items'] })
115
+ expect((await s.get(k1)).page).toBeNull()
116
+ expect((await s.get(k2)).page).toBeNull()
117
+ expect((await s.get(k3)).page).not.toBeNull()
118
+ })
119
+
120
+ if (caps.invalidate !== false) test('path + method invalidation drops that page only', async () => {
121
+ const { s } = await store()
122
+ const [k1, k2] = [key(), key()]
123
+ await s.set(k1, page('1'), meta({ path: '/x' }))
124
+ await s.set(k2, page('2'), meta({ path: '/y' }))
125
+ await s.invalidate({ path: '/x', method: 'POST' })
126
+ expect((await s.get(k1)).page).not.toBeNull()
127
+ await s.invalidate({ path: '/x' })
128
+ expect((await s.get(k1)).page).toBeNull()
129
+ expect((await s.get(k2)).page).not.toBeNull()
130
+ })
131
+
132
+ test('key invalidation is a store no-op', async () => {
133
+ const { s } = await store()
134
+ const k = key()
135
+ await s.set(k, page('1'), meta({ tags: ['t'] }))
136
+ await s.invalidate({ key: 'job-key' })
137
+ expect((await s.get(k)).page).not.toBeNull()
138
+ })
139
+
140
+ test('a body over maxBodyBytes is refused', async () => {
141
+ const { s } = await store()
142
+ const k = key()
143
+ expect(await s.set(k, page(new Uint8Array(s.maxBodyBytes + 1)), meta())).toBe(false)
144
+ expect((await s.get(k)).page).toBeNull()
145
+ })
146
+
147
+ if (caps.guard) {
148
+ test('a set whose stamp predates an invalidation of its tag is refused', async () => {
149
+ const { a, s } = await store()
150
+ const k = key()
151
+ const g = await s.get(k)
152
+ await s.invalidate({ tags: ['items'] })
153
+ await a.broadcast?.publish({ tags: ['items'] }, 'conf')
154
+ expect(await s.set(k, page('stale'), meta({ tags: ['items'], stamp: g.stamp }))).toBe(false)
155
+ expect((await s.get(k)).page).toBeNull()
156
+ const g2 = await s.get(k)
157
+ expect(await s.set(k, page('fresh'), meta({ tags: ['items'], stamp: g2.stamp }))).toBe(true)
158
+ })
159
+
160
+ test('L7b: a stale set racing invalidate (before its publish) never lands', async () => {
161
+ const { s } = await store()
162
+ const k = key()
163
+ const g = await s.get(k)
164
+ await s.invalidate({ tags: ['items'], path: '/p' })
165
+ expect(await s.set(k, page('stale'), meta({ tags: ['items'], stamp: g.stamp }))).toBe(false)
166
+ expect(await s.set(k, page('stale'), meta({ path: '/p', stamp: g.stamp }))).toBe(false)
167
+ expect((await s.get(k)).page).toBeNull()
168
+ })
169
+ }
170
+
171
+ test('publish seqs strictly increase; get().stamp is the last seq (one space)', async () => {
172
+ const a = await fresh()
173
+ if (!a.broadcast) return
174
+ const s1 = await a.broadcast.publish({ tags: ['a'] }, 'conf')
175
+ const s2 = await a.broadcast.publish({ path: '/b' }, 'conf')
176
+ expect(s2).toBeGreaterThan(s1)
177
+ if (a.store) expect((await a.store.get(key())).stamp).toBe(s2)
178
+ })
179
+
180
+ if (caps.push) {
181
+ test('push: in-order delivery with origin and args; unsubscribe stops it', async () => {
182
+ const a = await fresh()
183
+ const got: InvalidationEvent[] = []
184
+ const stop = await a.broadcast!.subscribe!(e => got.push(e), () => {})
185
+ const s1 = await a.broadcast!.publish({ tags: ['a'] }, 'o1')
186
+ const s2 = await a.broadcast!.publish({ path: '/b', method: 'GET' }, 'o2')
187
+ await until('delivery', () => got.length === 2)
188
+ expect(got).toEqual([
189
+ { seq: s1, origin: 'o1', args: { tags: ['a'] } },
190
+ { seq: s2, origin: 'o2', args: { path: '/b', method: 'GET' } },
191
+ ])
192
+ await stop()
193
+ await a.broadcast!.publish({ tags: ['c'] }, 'o1')
194
+ await new Promise(r => setTimeout(r, 50))
195
+ expect(got.length).toBe(2)
196
+ })
197
+ }
198
+
199
+ if (caps.pull) {
200
+ test('pull: since(0) after three publishes is contiguous', async () => {
201
+ const a = await fresh()
202
+ const b = a.broadcast!
203
+ const before = await b.since!(0)
204
+ const base = before.seq
205
+ for (const t of ['a', 'b', 'c']) await b.publish({ tags: [t] }, 'o')
206
+ const r = await b.since!(base)
207
+ expect(r.behind).toBe(false)
208
+ expect(r.seq).toBe(base + 3)
209
+ expect(r.events.map(e => e.seq)).toEqual([base + 1, base + 2, base + 3])
210
+ expect(r.events.map(e => e.args.tags?.[0])).toEqual(['a', 'b', 'c'])
211
+ expect(r.events[0]!.origin).toBe('o')
212
+ expect((await b.since!(r.seq)).events).toEqual([])
213
+ expect(typeof r.gen).toBe('string')
214
+ })
215
+
216
+ test('pull: a reader past the retained history is behind', async () => {
217
+ const a = await fresh()
218
+ const b = a.broadcast!
219
+ const base = (await b.since!(0)).seq
220
+ // 1025 events is beyond the spec's 1024-event ring.
221
+ for (let i = 0; i < 1025; i++) await b.publish({ tags: [`t${i}`] }, 'o')
222
+ expect((await b.since!(base)).behind).toBe(true)
223
+ })
224
+ }
225
+ })
226
+ }
@@ -0,0 +1,10 @@
1
+ // `@brust/core/cache-adapter`: the contract, the in-memory adapter and `defineCacheAdapter`.
2
+ // No `bun`/`bun:*` import here (the Cloudflare bundle imports it); the conformance suite is its own
3
+ // subpath, `@brust/core/cache-adapter/conformance`.
4
+ import type { CacheAdapter, CacheAdapterFactory } from './types'
5
+
6
+ export type * from './types'
7
+ export { type MemoryAdapter, type MemoryAdapterOptions, type MemoryAdapterTest, memoryAdapter } from './memory'
8
+
9
+ /** Identity, for type inference: `export default defineCacheAdapter((opts) => ({ … }))`. */
10
+ export const defineCacheAdapter = <T extends CacheAdapter | CacheAdapterFactory>(a: T): T => a
@@ -0,0 +1,144 @@
1
+ // Resolving and validating the configured adapter (cache-adapter R1, R8, R14). The build checks
2
+ // that a built-in package is installed; this runs where the adapter lives (each Bun worker, or the
3
+ // Workers isolate's `install`). No `bun` import: resolution is the caller's (`resolve`).
4
+ import type { CacheAdapter, CacheAdapterFactory } from './types'
5
+
6
+ /** What `brust build` writes to `manifest.cache` / `CONFIG.cache`. */
7
+ export interface CacheManifest {
8
+ adapter: string
9
+ /** `custom`: the bundled module, relative to dist (native) — Workers import it in the entry. */
10
+ module?: string
11
+ /** A built-in broadcast composed onto the adapter (`broadcast = "cloudflare-do"`). */
12
+ broadcast?: string
13
+ options: Record<string, unknown>
14
+ /** Options of the composed broadcast's factory. */
15
+ broadcastOptions?: Record<string, unknown>
16
+ namespace: string
17
+ getTimeoutMs: number
18
+ maxBodyBytes: number
19
+ epochIntervalMs: number
20
+ buildId: string
21
+ }
22
+
23
+ /** The built-in adapters: each package subpath's default export is a factory (the lanes B/C contract). */
24
+ export const BUILTINS: Readonly<Record<string, string>> = {
25
+ redis: '@brust/cache-redis',
26
+ dragonfly: '@brust/cache-redis/dragonfly',
27
+ 'cloudflare-kv': '@brust/cache-cloudflare/kv',
28
+ 'cloudflare-do': '@brust/cache-cloudflare/do',
29
+ }
30
+
31
+ export const ADAPTER_NAMES = ['redis', 'dragonfly', 'cloudflare-kv', 'cloudflare-do', 'custom'] as const
32
+
33
+ /** A built-in name's install hint (the build's error). */
34
+ export function installHint(name: string): string {
35
+ const spec = BUILTINS[name] ?? name
36
+ const pkg = spec.split('/').slice(0, 2).join('/')
37
+ return `adapter "${name}" needs ${pkg} (bun add ${pkg})`
38
+ }
39
+
40
+ /** `env:NAME` strings of a built-in's option table → `env[NAME]` (missing → a boot error naming NAME only). */
41
+ export function resolveEnvOptions(o: Record<string, unknown>, env: Record<string, string | undefined>): Record<string, unknown> {
42
+ const out: Record<string, unknown> = {}
43
+ for (const [k, v] of Object.entries(o)) {
44
+ if (typeof v === 'string' && v.startsWith('env:')) {
45
+ const name = v.slice(4)
46
+ const val = env[name]
47
+ if (val === undefined || val === '') throw new Error(`cache adapter option ${k}: environment variable ${name} is not set`)
48
+ out[k] = val
49
+ } else out[k] = v
50
+ }
51
+ return out
52
+ }
53
+
54
+ /** A module's default export → the adapter (a factory is called with `options`). */
55
+ export function instantiate(mod: unknown, options: Record<string, unknown>, what: string): CacheAdapter {
56
+ const d = (mod as { default?: unknown } | null)?.default ?? mod
57
+ const a = typeof d === 'function' ? (d as CacheAdapterFactory)(options) : d
58
+ return validateAdapter(a, what)
59
+ }
60
+
61
+ const fn = (o: unknown, k: string) => typeof (o as Record<string, unknown> | undefined)?.[k] === 'function'
62
+
63
+ /** The shape check (TypeError naming what is wrong). */
64
+ export function validateAdapter(a: unknown, what: string): CacheAdapter {
65
+ const bad = (m: string): never => {
66
+ throw new TypeError(`cache adapter ${what}: ${m}`)
67
+ }
68
+ if (!a || typeof a !== 'object') bad('the default export is not an adapter or a factory returning one')
69
+ const x = a as CacheAdapter
70
+ if (typeof x.name !== 'string' || !x.name) bad('no name')
71
+ if (!Array.isArray(x.runtimes) || x.runtimes.some(r => r !== 'bun' && r !== 'workers')) bad('runtimes must list "bun" and/or "workers"')
72
+ if (x.store && !(fn(x.store, 'get') && fn(x.store, 'set') && fn(x.store, 'invalidate') && typeof x.store.maxBodyBytes === 'number'))
73
+ bad('store needs get, set, invalidate and maxBodyBytes')
74
+ if (x.broadcast) {
75
+ if (!fn(x.broadcast, 'publish')) bad('broadcast needs publish')
76
+ const push = fn(x.broadcast, 'subscribe')
77
+ const pull = fn(x.broadcast, 'since')
78
+ if (push === pull) bad('broadcast needs exactly one of subscribe (push) or since (pull)')
79
+ }
80
+ if (!x.store && !x.broadcast) bad('neither a store nor a broadcast')
81
+ return x
82
+ }
83
+
84
+ /** R1 (native) / R14 (Workers): the runtime and the broadcast mode must fit where it runs. */
85
+ export function checkRuntime(a: CacheAdapter, runtime: 'bun' | 'workers'): void {
86
+ if (!a.runtimes.includes(runtime))
87
+ throw new TypeError(`cache adapter "${a.name}" does not run on ${runtime === 'bun' ? 'Bun' : 'Cloudflare Workers'} (runtimes: ${a.runtimes.join(', ')})`)
88
+ if (!a.broadcast) return
89
+ const push = typeof a.broadcast.subscribe === 'function'
90
+ if (runtime === 'bun' && !push) throw new TypeError(`cache adapter "${a.name}": native needs a push broadcast (subscribe); pull (since) is Workers-only in v1`)
91
+ if (runtime === 'workers' && push) throw new TypeError(`cache adapter "${a.name}": push-only broadcast (subscribe) cannot run on Workers; it needs since (pull)`)
92
+ }
93
+
94
+ /**
95
+ * The configured adapter. `load(spec, builtin)` imports a module (the caller resolves it: a custom
96
+ * `module` relative to dist natively, a built-in package from the app root). `env` resolves
97
+ * built-in `env:` options.
98
+ */
99
+ export async function loadAdapter(
100
+ m: CacheManifest,
101
+ runtime: 'bun' | 'workers',
102
+ load: (spec: string, builtin: boolean) => Promise<unknown>,
103
+ env: Record<string, string | undefined>,
104
+ ): Promise<CacheAdapter> {
105
+ let adapter: CacheAdapter
106
+ if (m.adapter === 'custom') {
107
+ if (!m.module) throw new Error('cache: adapter "custom" without a module')
108
+ adapter = instantiate(await load(m.module, false), m.options, `"custom" (${m.module})`)
109
+ } else {
110
+ const spec = BUILTINS[m.adapter]
111
+ if (!spec) throw new Error(`cache: unknown adapter "${m.adapter}"`)
112
+ adapter = instantiate(await load(spec, true), resolveEnvOptions(m.options, env), `"${m.adapter}"`)
113
+ }
114
+ if (m.broadcast) {
115
+ const spec = BUILTINS[m.broadcast]
116
+ if (!spec) throw new Error(`cache: unknown broadcast "${m.broadcast}"`)
117
+ const other = instantiate(await load(spec, true), resolveEnvOptions(m.broadcastOptions ?? {}, env), `"${m.broadcast}" (broadcast)`)
118
+ if (!other.broadcast) throw new TypeError(`cache adapter "${m.broadcast}" has no broadcast to compose`)
119
+ adapter = compose(adapter, other)
120
+ }
121
+ checkRuntime(adapter, runtime)
122
+ return adapter
123
+ }
124
+
125
+ /** `base`'s store with `other`'s broadcast; both are initialised and closed. Built explicitly (not a
126
+ * spread) so a class-based adapter keeps its prototype methods. */
127
+ export function compose(base: CacheAdapter, other: CacheAdapter): CacheAdapter {
128
+ // A broadcast that knows its store partner (the Cloudflare DO + KV floors) composes itself.
129
+ const own = (other as { composeWith?: (store: CacheAdapter) => CacheAdapter }).composeWith
130
+ if (typeof own === 'function') return own.call(other, base)
131
+ return {
132
+ name: base.name,
133
+ runtimes: base.runtimes.filter(r => other.runtimes.includes(r)),
134
+ ...(base.store ? { store: base.store } : {}),
135
+ broadcast: other.broadcast,
136
+ async init(ctx) {
137
+ await base.init?.(ctx)
138
+ await other.init?.(ctx)
139
+ },
140
+ async close() {
141
+ await Promise.allSettled([base.close?.(), other.close?.()])
142
+ },
143
+ }
144
+ }
@@ -0,0 +1,229 @@
1
+ // The in-memory cache adapter: the reference implementation of the contract (exact stale-write
2
+ // guard, push or pull broadcast with a bounded ring) and the test double for brust's own suites.
3
+ // Not a production backend: it shares nothing across processes. No `bun` import (CF-bundle safe).
4
+ import type {
5
+ CacheAdapter,
6
+ CacheBroadcast,
7
+ CacheStore,
8
+ EpochSince,
9
+ GetResult,
10
+ InvalidateArgs,
11
+ InvalidationEvent,
12
+ SetMeta,
13
+ StoredPage,
14
+ } from './types'
15
+
16
+ export interface MemoryAdapterOptions {
17
+ broadcast?: 'push' | 'pull' | 'none'
18
+ /** Ring capacity in events (pull). Default 1024. */
19
+ ring?: number
20
+ /** Ring capacity in serialized bytes (pull). Default 1 MiB. */
21
+ ringBytes?: number
22
+ /** Default 8 MiB. */
23
+ maxBodyBytes?: number
24
+ now?: () => number
25
+ }
26
+
27
+ /** One event above this many serialized bytes is kept as a gap marker (readers clear). */
28
+ const OVERSIZE_EVENT = 64 * 1024
29
+
30
+ interface Entry {
31
+ page: StoredPage
32
+ tags: string[]
33
+ method: string
34
+ path: string
35
+ entryStamp: number | null
36
+ storedAt: number
37
+ expiresAt: number
38
+ }
39
+
40
+ type Slot = { seq: number; bytes: number; event: InvalidationEvent | null }
41
+
42
+ export interface MemoryAdapterTest {
43
+ /** A new generation (a restarted broadcast): readers see `gen` change. */
44
+ regen(): void
45
+ /** The next `get` settles as a miss after `ms` (default 2000). */
46
+ hangNextGet(ms?: number): void
47
+ /** The next store/broadcast call rejects. */
48
+ failNext(): void
49
+ /** The next `store.invalidate` waits `ms` before it marks and deletes (a slow networked store). */
50
+ delayNextInvalidate(ms: number): void
51
+ publishCount(): number
52
+ /** Stored entries (expired ones included). */
53
+ size(): number
54
+ }
55
+
56
+ export type MemoryAdapter = CacheAdapter & { store: CacheStore; __test: MemoryAdapterTest }
57
+
58
+ const pathName = (method: string | undefined, path: string) => `${method ?? 'GET'} ${path}`
59
+
60
+ export function memoryAdapter(o: MemoryAdapterOptions = {}): MemoryAdapter {
61
+ const mode = o.broadcast ?? 'push'
62
+ const ringCap = o.ring ?? 1024
63
+ const ringBytes = o.ringBytes ?? 1_048_576
64
+ const maxBodyBytes = o.maxBodyBytes ?? 8_388_608
65
+ const now = o.now ?? Date.now
66
+ const entries = new Map<string, Entry>()
67
+ const tagSeq = new Map<string, number>()
68
+ const pathSeq = new Map<string, number>()
69
+ let seq = 0
70
+ // Drawn on first use: a Workers module may not make random values at global scope.
71
+ let genValue: string | null = null
72
+ const gen = () => (genValue ??= randomGen())
73
+ const ring: Slot[] = []
74
+ let ringUsed = 0
75
+ /** The highest seq no longer in the ring (a reader at or below seq-1 of it is behind). */
76
+ let dropped = 0
77
+ let publishes = 0
78
+ let hang: number | null = null
79
+ let fail = false
80
+ let invalidateDelay = 0
81
+ const subscribers = new Set<{ on: (e: InvalidationEvent) => void; onGap: () => void }>()
82
+
83
+ const guarded = mode !== 'none'
84
+ const failing = () => {
85
+ if (!fail) return false
86
+ fail = false
87
+ return true
88
+ }
89
+ const raise = (m: Map<string, number>, k: string, s: number) => m.set(k, Math.max(m.get(k) ?? 0, s))
90
+ const mark = (args: InvalidateArgs, s: number) => {
91
+ for (const t of args.tags ?? []) raise(tagSeq, t, s)
92
+ if (args.path !== undefined) raise(pathSeq, pathName(args.method, args.path), s)
93
+ }
94
+
95
+ const store: CacheStore = {
96
+ maxBodyBytes,
97
+ async get(key: string): Promise<GetResult> {
98
+ if (failing()) throw new Error('memory adapter: injected failure')
99
+ if (hang !== null) {
100
+ const ms = hang
101
+ hang = null
102
+ await new Promise(r => setTimeout(r, ms))
103
+ return { page: null, stamp: guarded ? seq : null, entryStamp: null, storedAt: null }
104
+ }
105
+ const e = entries.get(key)
106
+ const stamp = guarded ? seq : null
107
+ if (!e || e.expiresAt <= now()) {
108
+ if (e) entries.delete(key)
109
+ return { page: null, stamp, entryStamp: null, storedAt: null }
110
+ }
111
+ return { page: copyPage(e.page), stamp, entryStamp: e.entryStamp, storedAt: e.storedAt }
112
+ },
113
+ async set(key: string, page: StoredPage, meta: SetMeta): Promise<boolean> {
114
+ if (failing()) throw new Error('memory adapter: injected failure')
115
+ if (page.body.length > maxBodyBytes) return false
116
+ if (meta.stamp !== null) {
117
+ const s = meta.stamp
118
+ const stale = (m: Map<string, number>, k: string) => (m.get(k) ?? 0) > s
119
+ if (meta.tags.some(t => stale(tagSeq, t)) || stale(pathSeq, pathName(meta.method, meta.path))) return false
120
+ }
121
+ const at = now()
122
+ entries.set(key, {
123
+ page: copyPage(page),
124
+ tags: [...meta.tags],
125
+ method: meta.method,
126
+ path: meta.path,
127
+ entryStamp: meta.stamp,
128
+ storedAt: at,
129
+ expiresAt: at + meta.ttlMs,
130
+ })
131
+ return true
132
+ },
133
+ async invalidate(args: InvalidateArgs): Promise<void> {
134
+ if (failing()) throw new Error('memory adapter: injected failure')
135
+ if (invalidateDelay > 0) {
136
+ const ms = invalidateDelay
137
+ invalidateDelay = 0
138
+ await new Promise(r => setTimeout(r, ms))
139
+ }
140
+ // L7b: the marker first, at the seq the matching publish will take (seq + 1), so a set whose
141
+ // stamp was read before this call is refused from now on; then the delete.
142
+ mark(args, seq + 1)
143
+ const tags = new Set(args.tags ?? [])
144
+ const p = args.path === undefined ? null : pathName(args.method, args.path)
145
+ for (const [k, e] of entries) {
146
+ if (e.tags.some(t => tags.has(t)) || (p !== null && pathName(e.method, e.path) === p)) entries.delete(k)
147
+ }
148
+ },
149
+ }
150
+
151
+ const push = (event: InvalidationEvent) => {
152
+ const bytes = JSON.stringify(event).length
153
+ const slot: Slot = { seq: event.seq, bytes: Math.min(bytes, OVERSIZE_EVENT), event: bytes > OVERSIZE_EVENT ? null : event }
154
+ ring.push(slot)
155
+ ringUsed += slot.bytes
156
+ while (ring.length > ringCap || ringUsed > ringBytes) {
157
+ const old = ring.shift()!
158
+ ringUsed -= old.bytes
159
+ dropped = old.seq
160
+ }
161
+ }
162
+
163
+ const broadcast: CacheBroadcast = {
164
+ async publish(args: InvalidateArgs, origin: string): Promise<number> {
165
+ if (failing()) throw new Error('memory adapter: injected failure')
166
+ seq += 1
167
+ publishes += 1
168
+ mark(args, seq)
169
+ const event: InvalidationEvent = { seq, origin, args: structuredClone(args) }
170
+ push(event)
171
+ for (const s of subscribers) queueMicrotask(() => s.on(structuredClone(event)))
172
+ return seq
173
+ },
174
+ }
175
+ if (mode === 'push') {
176
+ broadcast.subscribe = async (on, onGap) => {
177
+ const sub = { on, onGap }
178
+ subscribers.add(sub)
179
+ return async () => {
180
+ subscribers.delete(sub)
181
+ }
182
+ }
183
+ }
184
+ if (mode === 'pull') {
185
+ broadcast.since = async (from: number): Promise<EpochSince> => {
186
+ if (failing()) throw new Error('memory adapter: injected failure')
187
+ const after = ring.filter(s => s.seq > from)
188
+ const behind = from < dropped || after.some(s => s.event === null)
189
+ return { gen: gen(), seq, behind, events: behind ? [] : after.map(s => structuredClone(s.event!)) }
190
+ }
191
+ }
192
+
193
+ return {
194
+ name: 'memory',
195
+ runtimes: ['bun', 'workers'],
196
+ store,
197
+ ...(mode === 'none' ? {} : { broadcast }),
198
+ __test: {
199
+ regen() {
200
+ genValue = randomGen()
201
+ ring.length = 0
202
+ ringUsed = 0
203
+ dropped = seq
204
+ for (const s of subscribers) queueMicrotask(() => s.onGap())
205
+ },
206
+ hangNextGet(ms = 2000) {
207
+ hang = ms
208
+ },
209
+ failNext() {
210
+ fail = true
211
+ },
212
+ delayNextInvalidate(ms: number) {
213
+ invalidateDelay = ms
214
+ },
215
+ publishCount: () => publishes,
216
+ size: () => entries.size,
217
+ },
218
+ }
219
+ }
220
+
221
+ function copyPage(p: StoredPage): StoredPage {
222
+ return { status: p.status, headers: p.headers.map(([k, v]) => [k, v] as [string, string]), body: p.body.slice() }
223
+ }
224
+
225
+ function randomGen(): string {
226
+ const b = new Uint8Array(8)
227
+ crypto.getRandomValues(b)
228
+ return Array.from(b, x => x.toString(16).padStart(2, '0')).join('')
229
+ }