@brust/cache-cloudflare 0.0.0-stage → 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.
package/README.md CHANGED
@@ -1,3 +1,77 @@
1
- # Temporary Holding Version
1
+ # @brust/cache-cloudflare
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Cloudflare Workers adapters for brust's shared second cache tier (`[cache]` in `brust.toml`, `brust build --target cloudflare`):
4
+
5
+ - **`cloudflare-kv`**: pages in Workers KV (a store, no broadcast).
6
+ - **`cloudflare-do`**: invalidations through a Durable Object (a pull broadcast), optionally also the page store (`do_store = true`, 16 shards).
7
+ - **`cloudflare-kv` + `broadcast = "cloudflare-do"`**: the recommended pair (`combine`). KV holds the bodies, the Durable Object tells every isolate what was invalidated.
8
+
9
+ Workers only (`runtimes: ['workers']`). No `bun`/`bun:*` import. Peer dependency: `@brust/core`.
10
+
11
+ ```toml
12
+ [cache]
13
+ adapter = "cloudflare-kv"
14
+ broadcast = "cloudflare-do"
15
+ epoch_interval_ms = 5000
16
+
17
+ [cache.cloudflare]
18
+ kv_min_ttl_s = 300
19
+ ```
20
+
21
+ ## Wrangler bindings (you declare them; brust never adds them)
22
+
23
+ `brust build --target cloudflare` checks the top level and every `env.*` block of your wrangler config and fails with this snippet when something is missing:
24
+
25
+ ```jsonc
26
+ {
27
+ "kv_namespaces": [{ "binding": "BRUST_CACHE", "id": "<wrangler kv namespace create brust-cache>" }],
28
+ "durable_objects": { "bindings": [{ "name": "BRUST_CACHE_DO", "class_name": "BrustCacheDO" }] },
29
+ "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]
30
+ }
31
+ ```
32
+
33
+ `BrustCacheDO` must be a SQLite-backed class (`new_sqlite_classes`, or `exports.BrustCacheDO = { "type": "durable-object", "storage": "sqlite" }`). `brust build` writes `dist/cf/worker/main.js`, which re-exports it; do not write your own.
34
+
35
+ ## Options (`[cache.cloudflare]`)
36
+
37
+ | key | used by | default | rule |
38
+ |---|---|---|---|
39
+ | `kv_binding` | KV, pair | `"BRUST_CACHE"` | a binding name in `kv_namespaces` |
40
+ | `kv_min_ttl_s` | KV, pair | `300` | `0..=31536000`; a route with a smaller ttl is not written to KV (KV's own floor is 60 s) |
41
+ | `do_binding` | DO, pair | `"BRUST_CACHE_DO"` | a `durable_objects` binding with `class_name "BrustCacheDO"` |
42
+ | `do_store` | DO | `false` | `true` = bodies in 16 Durable Object shards (ignored with the pair) |
43
+
44
+ Programmatic forms (for `adapter = "custom"`): `kvAdapter({ binding?, min_ttl_s? })`, `doAdapter({ binding?, store? })`, `combine(kv, do)`. An unknown key is a `TypeError` naming it.
45
+
46
+ ## What each configuration guarantees
47
+
48
+ | config | store | other isolates after `cache.invalidate` | stale body re-served? | Free-plan cost driver |
49
+ |---|---|---|---|---|
50
+ | `cloudflare-kv` alone | KV, unguarded | up to the ttl (no broadcast) | **yes**, up to the ttl (at least 300 s) | 1 KV write per MISS |
51
+ | `cloudflare-do`, `do_store = false` | none (tier 1 only) | `epoch_interval_ms` + RTT | no | 1 DO request per poll per isolate |
52
+ | `cloudflare-do`, `do_store = true` | DO shards, exact write guard | `epoch_interval_ms` + RTT | no (rows are deleted) | + 1 DO request per tier-1 miss, + 18 per invalidation |
53
+ | `cloudflare-kv` + `broadcast = "cloudflare-do"` | KV, floor-guarded | `epoch_interval_ms` + RTT | no once the floor is known; KV lag is hidden by each entry's `entryStamp` | KV write per MISS + DO polls |
54
+
55
+ **KV alone never invalidates.** An L2 key hashes prefix, method, path and query, so a tag or a path cannot be enumerated in KV without extra index writes. Pair it with the Durable Object broadcast unless a stale page for up to its ttl is fine.
56
+
57
+ With the pair, each isolate keeps invalidation floors in JS (seeded from the Durable Object on first contact and after a generation change), so a freshly started isolate never serves a KV body that was invalidated before it started.
58
+
59
+ ## Free-plan numbers
60
+
61
+ | driver | per day | Free quota | note |
62
+ |---|---|---|---|
63
+ | epoch polls, 1 busy isolate, `epoch_interval_ms` 1000 | 86,400 DO requests | 100,000 DO requests | 86 % for ONE isolate |
64
+ | same at 5000 ms | 17,280 | 100,000 | about 5 busy isolates |
65
+ | same at 60000 ms | 1,440 | 100,000 | about 69 isolates; staleness about 60 s |
66
+ | `do_store` tier-1 miss | 1 DO request each | 100,000 | plus rows read |
67
+ | invalidation | 1 DO request (18 with `do_store`) + at most 5 rows written | 100,000 rows written | |
68
+ | KV write-through | 1 KV write per MISS of a ttl >= 300 s page | 1,000 writes | the binding constraint on Free |
69
+ | KV read-through | 1 KV read per tier-1 miss | 100,000 reads | |
70
+
71
+ On Free, set `epoch_interval_ms >= 5000` and prefer KV + DO over `do_store`. A `do_store` invalidation issues about 18 subrequests inside the action's invocation (Free allows 50); an action that already makes more than 30 will see shard invalidations fail.
72
+
73
+ ## Behaviour notes
74
+
75
+ - The Durable Object persists its `seq` and the name-to-seq floors in its SQLite storage, so seqs never regress across a restart. The event ring is in memory and `gen` is new on every construction, so every isolate clears tier 1 once after a restart.
76
+ - A KV write is skipped when this isolate wrote an identical page less than 60 s ago (KV allows one write per second per key).
77
+ - KV is eventually consistent (up to about 60 s across locations). Local `wrangler dev` KV is strongly consistent, so propagation lag is not covered by the test suite.
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
1
  {
2
2
  "name": "@brust/cache-cloudflare",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2.0.0-alpha.3",
4
+ "description": "brust cache adapters for Cloudflare Workers: KV store, Durable Object broadcast/store, and their combination",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/AssetsArt/brust.git"
9
+ },
10
+ "type": "module",
11
+ "exports": {
12
+ ".": "./src/index.ts",
13
+ "./kv": "./src/kv.ts",
14
+ "./do": "./src/do.ts"
15
+ },
16
+ "files": [
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "test": "bun test",
22
+ "typecheck": "bun check"
23
+ },
24
+ "peerDependencies": {
25
+ "@brust/core": "2.0.0-alpha.3"
26
+ },
27
+ "devDependencies": {
28
+ "@brust/core": "2.0.0-alpha.3",
29
+ "@types/bun": "^1.4.0",
30
+ "typescript": "^7.0.2"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }
@@ -0,0 +1,34 @@
1
+ // The Cloudflare subset this package uses, declared ambiently: @cloudflare/workers-types' global Request/Response clash with
2
+ // lib DOM under `bun check` (same precedent as examples/pokedex/lib/cloudflare.d.ts).
3
+ interface KVNamespaceGetOptions { type?: 'text' | 'arrayBuffer'; cacheTtl?: number }
4
+ interface KVNamespace {
5
+ get(key: string, options: { type: 'arrayBuffer'; cacheTtl?: number }): Promise<ArrayBuffer | null>
6
+ put(key: string, value: ArrayBuffer | ArrayBufferView | string, options?: { expirationTtl?: number }): Promise<void>
7
+ delete(key: string): Promise<void>
8
+ }
9
+ interface SqlStorageCursor<T> extends Iterable<T> {
10
+ toArray(): T[]
11
+ one(): T
12
+ readonly rowsWritten: number
13
+ }
14
+ interface SqlStorage {
15
+ exec<T = Record<string, unknown>>(query: string, ...bindings: unknown[]): SqlStorageCursor<T>
16
+ }
17
+ interface DurableObjectStorage {
18
+ sql: SqlStorage
19
+ transactionSync<T>(fn: () => T): T
20
+ setAlarm(time: number): Promise<void>
21
+ getAlarm(): Promise<number | null>
22
+ }
23
+ interface DurableObjectState {
24
+ storage: DurableObjectStorage
25
+ blockConcurrencyWhile<T>(fn: () => Promise<T>): Promise<T>
26
+ }
27
+ declare module 'cloudflare:workers' {
28
+ export const env: Record<string, unknown>
29
+ export class DurableObject<E = Record<string, unknown>> {
30
+ constructor(ctx: DurableObjectState, env: E)
31
+ protected ctx: DurableObjectState
32
+ protected env: E
33
+ }
34
+ }
package/src/combine.ts ADDED
@@ -0,0 +1,84 @@
1
+ // combine(kv, do): the KV store + the DO broadcast, with correct invalidation floors kept in JS (R6). The Rust cursor alone is not enough: an isolate that
2
+ // first-contacts the DO adopts without replay, so it would serve a KV body invalidated before it started, for up to ttl. Floors are seeded from the DO
3
+ // (persisted names -> seq), raised by every replayed event / own publish / local invalidate, and judge each KV hit by its `entryStamp`.
4
+ import type { CacheAdapter, CacheStore, GetResult, InvalidateArgs, SetMeta, StoredPage } from '@brust/core/cache-adapter'
5
+ import type { DoAdapter } from './do-adapter'
6
+ import { Floors } from './floors'
7
+ import type { KvAdapter } from './kv-adapter'
8
+
9
+ export function combine(kvIn: CacheAdapter, d: DoAdapter): CacheAdapter {
10
+ const kv = kvIn as KvAdapter
11
+ if (!kv.store || typeof kv.store.getWithMeta !== 'function')
12
+ throw new TypeError(`cloudflare-do broadcast can only be composed with the "cloudflare-kv" store (got "${kvIn.name}")`)
13
+ const floors = new Floors()
14
+ let gen: string | null = null
15
+ let lastSeq = 0
16
+ let seeded = false
17
+ let seeding: Promise<void> | null = null
18
+ const seed = () =>
19
+ (seeding ??= (async () => {
20
+ try {
21
+ const f = await d.floors()
22
+ floors.seed({ names: f.names, evicted: f.evicted })
23
+ gen = f.gen
24
+ lastSeq = Math.max(lastSeq, f.seq)
25
+ seeded = true
26
+ } finally {
27
+ seeding = null
28
+ }
29
+ })())
30
+
31
+ const store: CacheStore = {
32
+ maxBodyBytes: kv.store.maxBodyBytes,
33
+ async get(key): Promise<GetResult> {
34
+ if (!seeded) await seed()
35
+ const stamp = lastSeq
36
+ const { result, meta } = await kv.store.getWithMeta(key)
37
+ if (!result.page || !meta) return { page: null, stamp, entryStamp: null, storedAt: null }
38
+ // A hit written under a stamp below its floor (or one with no stamp at all) is judged invalid.
39
+ if (result.entryStamp === null || result.entryStamp < floors.floorOf(meta.tags, meta.method, meta.path)) return { page: null, stamp, entryStamp: null, storedAt: null }
40
+ return { ...result, stamp }
41
+ },
42
+ async set(key: string, page: StoredPage, meta: SetMeta) {
43
+ if (!seeded) await seed()
44
+ // `stamp: null` = an unguarded write: it is stamped with the seq known now, so floors judge it from here on.
45
+ const stamp = meta.stamp ?? lastSeq
46
+ if (meta.stamp !== null && stamp < floors.floorOf(meta.tags, meta.method, meta.path)) return false
47
+ return kv.store.set(key, page, { ...meta, stamp })
48
+ },
49
+ async invalidate(args: InvalidateArgs) {
50
+ // Hide the pages at once on this isolate; the matching publish returns the real seq (>= lastSeq + 1) and raises it.
51
+ floors.raiseArgs(args, lastSeq + 1)
52
+ },
53
+ }
54
+
55
+ return {
56
+ name: 'cloudflare-kv+do',
57
+ runtimes: ['workers'],
58
+ store,
59
+ broadcast: {
60
+ async publish(args, origin) {
61
+ const seq = await d.broadcast.publish(args, origin)
62
+ floors.raiseArgs(args, seq)
63
+ lastSeq = Math.max(lastSeq, seq)
64
+ return seq
65
+ },
66
+ async since(seq) {
67
+ const r = await d.broadcast.since(seq)
68
+ if (!seeded || r.gen !== gen || r.behind) await seed()
69
+ else {
70
+ for (const e of r.events) floors.raiseArgs(e.args, e.seq)
71
+ lastSeq = Math.max(lastSeq, r.seq)
72
+ }
73
+ return r
74
+ },
75
+ },
76
+ async init(ctx) {
77
+ await kv.init?.(ctx)
78
+ await d.init?.(ctx)
79
+ },
80
+ async close() {
81
+ await Promise.allSettled([kv.close?.(), d.close?.()])
82
+ },
83
+ }
84
+ }
@@ -0,0 +1,146 @@
1
+ // The Durable Object adapter: a pull BROADCAST (one `epoch:<ns>` instance) and, with `store: true`, an exact-guarded body store over 16 shards.
2
+ // No class import here (it needs `cloudflare:workers` at load): `do.ts` re-exports BrustCacheDO beside this.
3
+ import type { CacheAdapter, CacheAdapterFactory, CacheBroadcast, CacheStore, EpochSince, GetResult, InvalidateArgs, SetMeta, StoredPage } from '@brust/core/cache-adapter'
4
+ import { combine } from './combine'
5
+ import { binding, type GetEnv, workersEnv } from './env'
6
+ import { decodeEntry, encodeEntry } from './frame'
7
+ import type { FloorsPayload, PageMeta } from './do-class'
8
+ import { type DoOptions, fromTable, parseDo } from './options'
9
+
10
+ export const SHARDS = 16
11
+ /** A DO SQLite row is capped at 2 MB; the frame (headers + meta) shares it. */
12
+ export const MAX_DO_BODY_BYTES = 1_900_000
13
+
14
+ /** The RPC surface of a BrustCacheDO stub (types only: do-class.ts is not imported at runtime). */
15
+ export interface EpochStub {
16
+ head(): Promise<{ gen: string; seq: number }>
17
+ since(seq: number): Promise<EpochSince>
18
+ publish(args: InvalidateArgs, origin: string): Promise<{ gen: string; seq: number }>
19
+ floors(): Promise<FloorsPayload>
20
+ }
21
+ export interface ShardStub {
22
+ get(key: string): Promise<Uint8Array | null>
23
+ set(key: string, frame: Uint8Array, meta: PageMeta, stamp: number | null): Promise<boolean>
24
+ invalidate(args: InvalidateArgs, seq: number): Promise<number>
25
+ }
26
+ interface Namespace { getByName(name: string): unknown }
27
+
28
+ export interface DoDeps { getEnv?: GetEnv; now?: () => number; retryMs?: number }
29
+ export interface DoAdapter extends CacheAdapter {
30
+ broadcast: CacheBroadcast & { since(seq: number): Promise<EpochSince> }
31
+ /** The persisted floors + seq + gen of the epoch instance (combine seeds from it). */
32
+ floors(): Promise<FloorsPayload>
33
+ /** The lane-A composer hook: `[cache] adapter = "cloudflare-kv"` + `broadcast = "cloudflare-do"` is exactly `combine`. */
34
+ composeWith(store: CacheAdapter): CacheAdapter
35
+ }
36
+
37
+ export function shardIndex(key: string): number {
38
+ const n = Number.parseInt(key.at(-1) ?? '', 16)
39
+ if (!Number.isNaN(n)) return n
40
+ let h = 0x811c9dc5
41
+ for (let i = 0; i < key.length; i++) h = Math.imul(h ^ key.charCodeAt(i), 0x01000193) >>> 0
42
+ return h % SHARDS
43
+ }
44
+
45
+ export function doAdapter(options: DoOptions = {}, deps: DoDeps = {}): DoAdapter {
46
+ const { binding: name, store: withStore } = parseDo(options)
47
+ const getEnv = deps.getEnv ?? workersEnv
48
+ const now = deps.now ?? Date.now
49
+ const retryMs = deps.retryMs ?? 250
50
+ let namespace = 'brust'
51
+ let lastSeq: number | null = null
52
+ const seen = (s: number) => {
53
+ lastSeq = Math.max(lastSeq ?? 0, s)
54
+ }
55
+ const ns = async () => binding<Namespace>(await getEnv(), name, 'Durable Object')
56
+ const epoch = async () => (await ns()).getByName(`epoch:${namespace}`) as EpochStub
57
+ const shard = async (i: number) => (await ns()).getByName(`store:${namespace}:${i}`) as ShardStub
58
+
59
+ // Lane A calls store.invalidate FIRST (delete, then publish: L7a), so the store side cannot publish (it does not know the runtime's origin).
60
+ // It writes its shard markers at the seq the publish is about to take (head + 1, L7b), and remembers that; publish then only re-marks when the
61
+ // real seq turned out different. Exactly ONE epoch publish happens per invalidation.
62
+ const pending = new WeakMap<object, number>()
63
+ const fanout = async (args: InvalidateArgs, seq: number): Promise<Error | null> => {
64
+ const out = await Promise.allSettled(Array.from({ length: SHARDS }, async (_, i) => (await shard(i)).invalidate(args, seq)))
65
+ const bad = out.filter((o) => o.status === 'rejected').length
66
+ return bad > 0 ? new Error(`cloudflare-do: ${bad}/${SHARDS} shard invalidations failed`) : null
67
+ }
68
+
69
+ const broadcast: DoAdapter['broadcast'] = {
70
+ async publish(args, origin) {
71
+ const ep = await epoch()
72
+ let res: { gen: string; seq: number }
73
+ try {
74
+ res = await ep.publish(args, origin)
75
+ } catch {
76
+ await new Promise((r) => setTimeout(r, retryMs))
77
+ res = await ep.publish(args, origin)
78
+ }
79
+ seen(res.seq)
80
+ if (withStore && pending.get(args) !== res.seq) {
81
+ const err = await fanout(args, res.seq)
82
+ if (err) console.error(`[brust] ${err.message}`)
83
+ }
84
+ return res.seq
85
+ },
86
+ async since(seq) {
87
+ const r = await (await epoch()).since(seq)
88
+ seen(r.seq)
89
+ return r
90
+ },
91
+ }
92
+
93
+ let store: CacheStore | undefined
94
+ if (withStore) {
95
+ const ensureSeq = async () => {
96
+ if (lastSeq === null) seen((await (await epoch()).head()).seq)
97
+ }
98
+ store = {
99
+ maxBodyBytes: MAX_DO_BODY_BYTES,
100
+ async get(key): Promise<GetResult> {
101
+ await ensureSeq()
102
+ const stamp = lastSeq
103
+ const raw = await (await shard(shardIndex(key))).get(key)
104
+ const e = raw ? decodeEntry(raw) : null
105
+ if (!e) return { page: null, stamp, entryStamp: null, storedAt: null }
106
+ return { page: e.page, stamp, entryStamp: e.meta.entryStamp, storedAt: e.meta.storedAt }
107
+ },
108
+ async set(key: string, page: StoredPage, meta: SetMeta) {
109
+ if (page.body.length > MAX_DO_BODY_BYTES) return false
110
+ const at = now()
111
+ const expiresAt = at + meta.ttlMs
112
+ const frame = encodeEntry(page, { tags: meta.tags, method: meta.method, path: meta.path, entryStamp: meta.stamp, storedAt: at, expiresAt })
113
+ return (await shard(shardIndex(key))).set(key, frame, { tags: meta.tags, method: meta.method, path: meta.path, expiresAt }, meta.stamp)
114
+ },
115
+ async invalidate(args: InvalidateArgs) {
116
+ const head = await (await epoch()).head()
117
+ seen(head.seq)
118
+ pending.set(args, head.seq + 1)
119
+ const err = await fanout(args, head.seq + 1)
120
+ if (err) throw err
121
+ },
122
+ }
123
+ }
124
+
125
+ const adapter: DoAdapter = {
126
+ name: 'cloudflare-do',
127
+ runtimes: ['workers'],
128
+ ...(store ? { store } : {}),
129
+ broadcast,
130
+ async init(ctx) {
131
+ namespace = ctx.namespace
132
+ },
133
+ async floors() {
134
+ const f = await (await epoch()).floors()
135
+ seen(f.seq)
136
+ return f
137
+ },
138
+ composeWith(base) {
139
+ return combine(base, adapter)
140
+ },
141
+ }
142
+ return adapter
143
+ }
144
+
145
+ /** The `[cache.cloudflare]` table (`do_binding`, `do_store`) -> the adapter (the default export of `/do`). */
146
+ export const doFactory: CacheAdapterFactory = (table) => doAdapter(fromTable(table, 'cloudflare-do').do)
@@ -0,0 +1,165 @@
1
+ // BrustCacheDO: ONE class for both roles. The instance named `epoch:<ns>` is the broadcast (ring + persisted seq and floors); instances named
2
+ // `store:<ns>:<0..f>` are body shards (exact write guard). SQLite-backed (migrations `new_sqlite_classes`). seq and the name -> seq floors
3
+ // persist (R7) so seqs never regress across a restart; the ring stays in memory and `gen` is new on every construction, so isolates clear once.
4
+ import { DurableObject } from 'cloudflare:workers'
5
+ import type { EpochSince, InvalidateArgs } from '@brust/core/cache-adapter'
6
+ import { namesOf, pathName } from './floors'
7
+ import { Ring } from './ring'
8
+
9
+ export const INVAL_CAP = 4096
10
+ const SWEEP_MS = 60_000
11
+
12
+ export interface PageMeta { tags: string[]; method: string; path: string; expiresAt: number }
13
+ export interface FloorsPayload { gen: string; seq: number; names: [string, number][]; evicted: number }
14
+
15
+ export class BrustCacheDO extends DurableObject {
16
+ private sql: SqlStorage
17
+ private gen = crypto.randomUUID()
18
+ private ring = new Ring(this.gen, 0)
19
+ private evicted = 0
20
+ private invalCount = 0
21
+ private publishes = 0
22
+ private failNext = false
23
+
24
+ constructor(ctx: DurableObjectState, env: Record<string, unknown>) {
25
+ super(ctx, env)
26
+ this.sql = ctx.storage.sql
27
+ void ctx.blockConcurrencyWhile(async () => {
28
+ const sql = this.sql
29
+ sql.exec('CREATE TABLE IF NOT EXISTS meta(k TEXT PRIMARY KEY, v INTEGER)')
30
+ sql.exec('CREATE TABLE IF NOT EXISTS inval(name TEXT PRIMARY KEY, seq INTEGER NOT NULL)')
31
+ sql.exec('CREATE INDEX IF NOT EXISTS inval_seq ON inval(seq)')
32
+ sql.exec('CREATE TABLE IF NOT EXISTS pages(key TEXT PRIMARY KEY, meta TEXT NOT NULL, body BLOB NOT NULL, mpath TEXT NOT NULL, expires_at INTEGER NOT NULL)')
33
+ sql.exec('CREATE TABLE IF NOT EXISTS tags(tag TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY(tag, key))')
34
+ const m = (k: string) => (sql.exec<{ v: number }>('SELECT v FROM meta WHERE k = ?', k).toArray()[0]?.v ?? 0)
35
+ this.evicted = m('evicted')
36
+ this.invalCount = sql.exec<{ c: number }>('SELECT count(*) AS c FROM inval').one().c
37
+ this.ring = new Ring(this.gen, m('seq'))
38
+ })
39
+ }
40
+
41
+ private testOnly(): void {
42
+ if ((this.env as Record<string, unknown>).BRUST_CACHE_TEST !== '1') throw new Error('BrustCacheDO: test hooks are disabled')
43
+ }
44
+ private maybeFail(): void {
45
+ if (this.failNext) {
46
+ this.failNext = false
47
+ throw new Error('BrustCacheDO: injected failure')
48
+ }
49
+ }
50
+ private setMeta(k: string, v: number): void {
51
+ this.sql.exec('INSERT INTO meta(k, v) VALUES(?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v', k, v)
52
+ }
53
+
54
+ /** Raise the floors of `names` to at least `seq`; keep at most INVAL_CAP rows, raising `evicted` by what is dropped. */
55
+ private bump(names: string[], seq: number): void {
56
+ for (const n of names) {
57
+ if (this.sql.exec('SELECT 1 FROM inval WHERE name = ?', n).toArray().length === 0) this.invalCount += 1
58
+ this.sql.exec('INSERT INTO inval(name, seq) VALUES(?, ?) ON CONFLICT(name) DO UPDATE SET seq = max(seq, excluded.seq)', n, seq)
59
+ }
60
+ const over = this.invalCount - INVAL_CAP
61
+ if (over > 0) {
62
+ const old = this.sql.exec<{ name: string; seq: number }>('SELECT name, seq FROM inval ORDER BY seq ASC LIMIT ?', over).toArray()
63
+ for (const o of old) {
64
+ this.sql.exec('DELETE FROM inval WHERE name = ?', o.name)
65
+ this.evicted = Math.max(this.evicted, o.seq)
66
+ }
67
+ this.invalCount -= old.length
68
+ this.setMeta('evicted', this.evicted)
69
+ }
70
+ }
71
+
72
+ // ---- epoch role ----
73
+ head(): { gen: string; seq: number } {
74
+ return { gen: this.gen, seq: this.ring.seq }
75
+ }
76
+ since(seq: number): EpochSince {
77
+ this.maybeFail()
78
+ return this.ring.since(seq)
79
+ }
80
+ publish(args: InvalidateArgs, origin: string): { gen: string; seq: number } {
81
+ this.maybeFail()
82
+ this.publishes += 1
83
+ return this.ctx.storage.transactionSync(() => {
84
+ const seq = this.ring.publish(args, origin)
85
+ this.setMeta('seq', seq)
86
+ this.bump(namesOf(args), seq)
87
+ return { gen: this.gen, seq }
88
+ })
89
+ }
90
+ floors(): FloorsPayload {
91
+ const rows = this.sql.exec<{ name: string; seq: number }>('SELECT name, seq FROM inval ORDER BY seq DESC LIMIT ?', INVAL_CAP).toArray()
92
+ return { gen: this.gen, seq: this.ring.seq, names: rows.map((r) => [r.name, r.seq]), evicted: this.evicted }
93
+ }
94
+
95
+ // ---- shard role ----
96
+ get(key: string): Uint8Array | null {
97
+ const row = this.sql.exec<{ body: ArrayBuffer; expires_at: number }>('SELECT body, expires_at FROM pages WHERE key = ?', key).toArray()[0]
98
+ if (!row) return null
99
+ if (row.expires_at <= Date.now()) {
100
+ this.dropKeys([key])
101
+ return null
102
+ }
103
+ return new Uint8Array(row.body)
104
+ }
105
+ /** `stamp === null` = an unguarded write (the same rule as every store); otherwise refused when an invalidation of its names is newer. */
106
+ async set(key: string, frame: Uint8Array, meta: PageMeta, stamp: number | null): Promise<boolean> {
107
+ const ok = this.ctx.storage.transactionSync(() => {
108
+ if (stamp !== null) {
109
+ if (this.evicted > stamp) return false
110
+ for (const n of [...meta.tags.map((t) => `t:${t}`), pathName(meta.method, meta.path)]) {
111
+ const r = this.sql.exec<{ seq: number }>('SELECT seq FROM inval WHERE name = ?', n).toArray()[0]
112
+ if (r && r.seq > stamp) return false
113
+ }
114
+ }
115
+ this.dropKeys([key])
116
+ this.sql.exec('INSERT INTO pages(key, meta, body, mpath, expires_at) VALUES(?, ?, ?, ?, ?)', key, JSON.stringify({ tags: meta.tags, method: meta.method, path: meta.path }), frame, pathName(meta.method, meta.path), meta.expiresAt)
117
+ for (const t of new Set(meta.tags)) this.sql.exec('INSERT OR IGNORE INTO tags(tag, key) VALUES(?, ?)', t, key)
118
+ return true
119
+ })
120
+ if (ok && (await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(Date.now() + SWEEP_MS)
121
+ return ok
122
+ }
123
+ invalidate(args: InvalidateArgs, seq: number): number {
124
+ return this.ctx.storage.transactionSync(() => {
125
+ this.bump(namesOf(args), seq)
126
+ const keys = new Set<string>()
127
+ for (const t of args.tags ?? []) for (const r of this.sql.exec<{ key: string }>('SELECT key FROM tags WHERE tag = ?', t)) keys.add(r.key)
128
+ if (args.path !== undefined) for (const r of this.sql.exec<{ key: string }>('SELECT key FROM pages WHERE mpath = ?', pathName(args.method, args.path))) keys.add(r.key)
129
+ this.dropKeys([...keys])
130
+ return keys.size
131
+ })
132
+ }
133
+ private dropKeys(keys: string[]): void {
134
+ for (const k of keys) {
135
+ this.sql.exec('DELETE FROM pages WHERE key = ?', k)
136
+ this.sql.exec('DELETE FROM tags WHERE key = ?', k)
137
+ }
138
+ }
139
+ async alarm(): Promise<void> {
140
+ const dead = this.sql.exec<{ key: string }>('SELECT key FROM pages WHERE expires_at <= ?', Date.now()).toArray()
141
+ this.dropKeys(dead.map((r) => r.key))
142
+ if (this.sql.exec('SELECT 1 FROM pages LIMIT 1').toArray().length > 0) await this.ctx.storage.setAlarm(Date.now() + SWEEP_MS)
143
+ }
144
+
145
+ // ---- test hooks (R12): only when env.BRUST_CACHE_TEST === "1" ----
146
+ __regen(): { gen: string; seq: number } {
147
+ this.testOnly()
148
+ this.gen = crypto.randomUUID()
149
+ this.ring = new Ring(this.gen, this.ring.seq)
150
+ return { gen: this.gen, seq: this.ring.seq }
151
+ }
152
+ __failNext(): void {
153
+ this.testOnly()
154
+ this.failNext = true
155
+ }
156
+ async __sweep(): Promise<void> {
157
+ this.testOnly()
158
+ await this.alarm()
159
+ }
160
+ __state(): { publishes: number; seq: number; gen: string; pages: number; inval: number; evicted: number } {
161
+ this.testOnly()
162
+ const pages = this.sql.exec<{ c: number }>('SELECT count(*) AS c FROM pages').one().c
163
+ return { publishes: this.publishes, seq: this.ring.seq, gen: this.gen, pages, inval: this.invalCount, evicted: this.evicted }
164
+ }
165
+ }
package/src/do.ts ADDED
@@ -0,0 +1,3 @@
1
+ export { BrustCacheDO } from './do-class'
2
+ export { type DoAdapter, doAdapter, doFactory as default } from './do-adapter'
3
+ export type { DoOptions } from './options'
package/src/env.ts ADDED
@@ -0,0 +1,14 @@
1
+ // The Worker env, read per call: a Worker may not touch bindings at module scope, and the bundle's smoke import runs with env = {}.
2
+ export type GetEnv = () => Promise<Record<string, unknown>>
3
+ export const workersEnv: GetEnv = async () => (await import('cloudflare:workers')).env
4
+
5
+ export function binding<T>(env: Record<string, unknown>, name: string, what: string): T {
6
+ const b = env[name]
7
+ if (b === undefined || b === null) throw new Error(`${what} binding "${name}" is not bound (add it to wrangler.jsonc)`)
8
+ return b as T
9
+ }
10
+
11
+ /** Only the listed keys; anything else is a TypeError naming it (it surfaces at the build smoke). */
12
+ export function onlyKeys(o: Record<string, unknown>, allowed: readonly string[], ignored: readonly string[], what: string): void {
13
+ for (const k of Object.keys(o)) if (!allowed.includes(k) && !ignored.includes(k)) throw new TypeError(`${what}: unknown option "${k}" (known: ${allowed.join(', ')})`)
14
+ }
package/src/floors.ts ADDED
@@ -0,0 +1,41 @@
1
+ // Per-name invalidation floors (names `t:<tag>` and `p:<METHOD> <path>`): an entry written under a stamp below its floor is invalid.
2
+ import type { InvalidateArgs } from '@brust/core/cache-adapter'
3
+
4
+ export const pathName = (method: string | undefined, path: string) => `p:${method ?? 'GET'} ${path}`
5
+
6
+ /** The floor names an invalidation touches. */
7
+ export function namesOf(args: InvalidateArgs): string[] {
8
+ return [...(args.tags ?? []).map((t) => `t:${t}`), ...(args.path !== undefined ? [pathName(args.method, args.path)] : [])]
9
+ }
10
+
11
+ export interface FloorsSnapshot { names: [string, number][]; evicted: number }
12
+
13
+ export class Floors {
14
+ private m = new Map<string, number>()
15
+ evicted = 0
16
+ constructor(private cap = 4096) {}
17
+ seed(s: FloorsSnapshot): void {
18
+ this.m.clear()
19
+ this.evicted = s.evicted
20
+ for (const [n, q] of s.names) this.raise(n, q)
21
+ }
22
+ raise(name: string, seq: number): void {
23
+ const next = Math.max(this.m.get(name) ?? 0, seq)
24
+ this.m.delete(name)
25
+ this.m.set(name, next)
26
+ while (this.m.size > this.cap) {
27
+ const [oldest, q] = this.m.entries().next().value as [string, number]
28
+ this.m.delete(oldest)
29
+ this.evicted = Math.max(this.evicted, q)
30
+ }
31
+ }
32
+ raiseArgs(args: InvalidateArgs, seq: number): void {
33
+ for (const n of namesOf(args)) this.raise(n, seq)
34
+ }
35
+ /** The highest floor over an entry's names; a name never seen is only as safe as `evicted`. */
36
+ floorOf(tags: string[], method: string, path: string): number {
37
+ let f = this.evicted
38
+ for (const n of [...tags.map((t) => `t:${t}`), pathName(method, path)]) f = Math.max(f, this.m.get(n) ?? 0)
39
+ return f
40
+ }
41
+ }
package/src/frame.ts ADDED
@@ -0,0 +1,38 @@
1
+ // KV / DO-row value: [u32 LE meta_len][meta JSON][body] (spec §5.1, the Redis framing). No KV metadata: headers + tags would pass its 1024-byte cliff.
2
+ import type { StoredPage } from '@brust/core/cache-adapter'
3
+
4
+ export interface EntryMeta {
5
+ v: 1
6
+ status: number
7
+ headers: [string, string][]
8
+ tags: string[]
9
+ method: string
10
+ path: string
11
+ entryStamp: number | null
12
+ storedAt: number
13
+ expiresAt: number
14
+ }
15
+
16
+ export function encodeEntry(page: StoredPage, meta: Omit<EntryMeta, 'v' | 'status' | 'headers'>): Uint8Array {
17
+ const m: EntryMeta = { v: 1, status: page.status, headers: page.headers, ...meta }
18
+ const head = new TextEncoder().encode(JSON.stringify(m))
19
+ const out = new Uint8Array(4 + head.length + page.body.length)
20
+ new DataView(out.buffer).setUint32(0, head.length, true)
21
+ out.set(head, 4)
22
+ out.set(page.body, 4 + head.length)
23
+ return out
24
+ }
25
+
26
+ export function decodeEntry(u8: Uint8Array): { page: StoredPage; meta: EntryMeta } | null {
27
+ if (u8.length < 4) return null
28
+ const n = new DataView(u8.buffer, u8.byteOffset, u8.byteLength).getUint32(0, true)
29
+ if (n > u8.length - 4) return null
30
+ let meta: EntryMeta
31
+ try {
32
+ meta = JSON.parse(new TextDecoder().decode(u8.subarray(4, 4 + n)))
33
+ } catch {
34
+ return null
35
+ }
36
+ if (!meta || meta.v !== 1 || !Array.isArray(meta.headers)) return null
37
+ return { page: { status: meta.status, headers: meta.headers, body: u8.slice(4 + n) }, meta }
38
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export { combine } from './combine'
2
+ export { BrustCacheDO } from './do-class'
3
+ export { type DoAdapter, doAdapter } from './do-adapter'
4
+ export { type KvAdapter, kvAdapter } from './kv-adapter'
5
+ export type { DoOptions, KvOptions } from './options'
@@ -0,0 +1,73 @@
1
+ // Cloudflare KV as a cache STORE (no broadcast). Body and meta share one KV value (R4); KV alone never invalidates (R5): a stale body
2
+ // lives until its ttl, so pair it with `broadcast = "cloudflare-do"` (combine) for correct invalidation.
3
+ import type { CacheAdapter, CacheStore, GetResult, InvalidateArgs, SetMeta, StoredPage } from '@brust/core/cache-adapter'
4
+ import { binding, type GetEnv, workersEnv } from './env'
5
+ import { decodeEntry, encodeEntry, type EntryMeta } from './frame'
6
+ import { type KvOptions, parseKv } from './options'
7
+
8
+ /** KV's 60 s floor on `expirationTtl`, and its 1 write/s/key limit: an identical frame written < 60 s ago is not rewritten. */
9
+ const MIN_EXPIRATION_S = 60
10
+ const MEMO_CAP = 1024
11
+
12
+ export interface KvStore extends CacheStore {
13
+ /** `get` plus the entry's stored meta (tags, method, path): combine needs them to judge an entry against its floors. */
14
+ getWithMeta(key: string): Promise<{ result: GetResult; meta: EntryMeta | null }>
15
+ }
16
+ export interface KvAdapter extends CacheAdapter {
17
+ store: KvStore
18
+ }
19
+
20
+ export interface KvDeps { getEnv?: GetEnv; now?: () => number }
21
+
22
+ function fingerprint(u8: Uint8Array): string {
23
+ let h = 0x811c9dc5
24
+ for (let i = 0; i < u8.length; i++) h = Math.imul(h ^ u8[i]!, 0x01000193) >>> 0
25
+ return `${u8.length}:${h}`
26
+ }
27
+
28
+ export function kvAdapter(options: KvOptions = {}, deps: KvDeps = {}): KvAdapter {
29
+ const { binding: name, minTtlS } = parseKv(options)
30
+ const getEnv = deps.getEnv ?? workersEnv
31
+ const now = deps.now ?? Date.now
32
+ const memo = new Map<string, { at: number; fp: string }>()
33
+ const kv = async () => binding<KVNamespace>(await getEnv(), name, 'KV')
34
+
35
+ const store: KvStore = {
36
+ // The key is a 512-byte limit away from lane A's L2Key; bodies are bounded by the core's max_body_bytes (KV allows 25 MiB).
37
+ maxBodyBytes: 8 * 1024 * 1024,
38
+ async getWithMeta(key) {
39
+ const none = { result: { page: null, stamp: null, entryStamp: null, storedAt: null } as GetResult, meta: null }
40
+ const raw = await (await kv()).get(key, { type: 'arrayBuffer', cacheTtl: 60 })
41
+ if (!raw) return none
42
+ const e = decodeEntry(new Uint8Array(raw))
43
+ if (!e || e.meta.expiresAt <= now()) return none
44
+ return { result: { page: e.page, stamp: null, entryStamp: e.meta.entryStamp, storedAt: e.meta.storedAt }, meta: e.meta }
45
+ },
46
+ async get(key) {
47
+ return (await store.getWithMeta(key)).result
48
+ },
49
+ async set(key: string, page: StoredPage, meta: SetMeta) {
50
+ if (meta.ttlMs < minTtlS * 1000) return false
51
+ if (page.body.length > store.maxBodyBytes) return false
52
+ const at = now()
53
+ const frame = encodeEntry(page, { tags: meta.tags, method: meta.method, path: meta.path, entryStamp: meta.stamp, storedAt: at, expiresAt: at + meta.ttlMs })
54
+ const fp = `${fingerprint(page.body)}:${JSON.stringify([page.status, page.headers, meta.tags, meta.method, meta.path, meta.stamp])}`
55
+ const prev = memo.get(key)
56
+ if (prev && prev.fp === fp && at - prev.at < 60_000) return true
57
+ try {
58
+ await (await kv()).put(key, frame, { expirationTtl: Math.max(MIN_EXPIRATION_S, Math.ceil(meta.ttlMs / 1000)) })
59
+ } catch (e) {
60
+ if (/\b429\b|too many requests|rate limit/i.test(String((e as Error)?.message ?? e))) return false
61
+ throw e
62
+ }
63
+ memo.delete(key)
64
+ memo.set(key, { at, fp })
65
+ if (memo.size > MEMO_CAP) memo.delete(memo.keys().next().value as string)
66
+ return true
67
+ },
68
+ async invalidate(_args: InvalidateArgs) {
69
+ // R5: an L2Key hashes prefix+method+path+query, so a tag or path cannot be enumerated without index writes; KV alone is "stale <= ttl".
70
+ },
71
+ }
72
+ return { name: 'cloudflare-kv', runtimes: ['workers'], store }
73
+ }
package/src/kv.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { CacheAdapterFactory } from '@brust/core/cache-adapter'
2
+ import { fromTable } from './options'
3
+ import { type KvAdapter, kvAdapter } from './kv-adapter'
4
+
5
+ export { type KvAdapter, kvAdapter }
6
+ export type { KvOptions } from './options'
7
+
8
+ /** The `[cache.cloudflare]` table (`kv_binding`, `kv_min_ttl_s`) → the adapter. */
9
+ const factory: CacheAdapterFactory = (table) => kvAdapter(fromTable(table, 'cloudflare-kv').kv)
10
+ export default factory
package/src/options.ts ADDED
@@ -0,0 +1,34 @@
1
+ import { onlyKeys } from './env'
2
+
3
+ export interface KvOptions { binding?: string; min_ttl_s?: number }
4
+ export interface DoOptions { binding?: string; store?: boolean }
5
+
6
+ const str = (v: unknown, what: string) => {
7
+ if (typeof v !== 'string' || !v) throw new TypeError(`${what} must be a non-empty string`)
8
+ return v
9
+ }
10
+
11
+ /** `kvAdapter({ binding, min_ttl_s })`. */
12
+ export function parseKv(o: KvOptions): { binding: string; minTtlS: number } {
13
+ onlyKeys(o as Record<string, unknown>, ['binding', 'min_ttl_s'], [], 'cloudflare-kv')
14
+ const minTtlS = o.min_ttl_s ?? 300
15
+ if (typeof minTtlS !== 'number' || !Number.isInteger(minTtlS) || minTtlS < 0 || minTtlS > 31_536_000) throw new TypeError('cloudflare-kv: min_ttl_s must be an integer in 0..31536000')
16
+ return { binding: o.binding === undefined ? 'BRUST_CACHE' : str(o.binding, 'cloudflare-kv: binding'), minTtlS }
17
+ }
18
+
19
+ /** `doAdapter({ binding, store })`. */
20
+ export function parseDo(o: DoOptions): { binding: string; store: boolean } {
21
+ onlyKeys(o as Record<string, unknown>, ['binding', 'store'], [], 'cloudflare-do')
22
+ if (o.store !== undefined && typeof o.store !== 'boolean') throw new TypeError('cloudflare-do: store must be a boolean')
23
+ return { binding: o.binding === undefined ? 'BRUST_CACHE_DO' : str(o.binding, 'cloudflare-do: binding'), store: o.store ?? false }
24
+ }
25
+
26
+ /** The shared `[cache.cloudflare]` table (spec §6 keys) → each adapter's programmatic form; unknown keys are TypeErrors. */
27
+ const TABLE = ['kv_binding', 'kv_min_ttl_s', 'do_binding', 'do_store']
28
+ export function fromTable(t: Record<string, unknown>, what: string): { kv: KvOptions; do: DoOptions } {
29
+ onlyKeys(t, TABLE, [], what)
30
+ return {
31
+ kv: { ...(t.kv_binding !== undefined ? { binding: t.kv_binding as string } : {}), ...(t.kv_min_ttl_s !== undefined ? { min_ttl_s: t.kv_min_ttl_s as number } : {}) },
32
+ do: { ...(t.do_binding !== undefined ? { binding: t.do_binding as string } : {}), ...(t.do_store !== undefined ? { store: t.do_store as boolean } : {}) },
33
+ }
34
+ }
package/src/ring.ts ADDED
@@ -0,0 +1,36 @@
1
+ // The epoch ring: events with strictly increasing seqs, bounded by count and bytes. Mirrors the memory adapter's pull mode.
2
+ import type { EpochSince, InvalidateArgs, InvalidationEvent } from '@brust/core/cache-adapter'
3
+
4
+ /** An event whose JSON is larger is kept as a "clear" marker: whoever is before it gets `behind`. */
5
+ export const OVERSIZE_EVENT = 64 * 1024
6
+
7
+ interface Slot { seq: number; bytes: number; event: InvalidationEvent | null }
8
+
9
+ export class Ring {
10
+ seq: number
11
+ private slots: Slot[] = []
12
+ private used = 0
13
+ /** The highest seq no longer retained. */
14
+ private dropped = 0
15
+ constructor(readonly gen: string, startSeq = 0, private capEntries = 1024, private capBytes = 1 << 20) {
16
+ this.seq = startSeq
17
+ }
18
+ publish(args: InvalidateArgs, origin: string): number {
19
+ this.seq += 1
20
+ const event: InvalidationEvent = { seq: this.seq, origin, args: structuredClone(args) }
21
+ const bytes = JSON.stringify(event).length
22
+ this.slots.push({ seq: this.seq, bytes: Math.min(bytes, OVERSIZE_EVENT), event: bytes > OVERSIZE_EVENT ? null : event })
23
+ this.used += this.slots[this.slots.length - 1]!.bytes
24
+ while (this.slots.length > this.capEntries || this.used > this.capBytes) {
25
+ const old = this.slots.shift()!
26
+ this.used -= old.bytes
27
+ this.dropped = old.seq
28
+ }
29
+ return this.seq
30
+ }
31
+ since(from: number): EpochSince {
32
+ const after = this.slots.filter((s) => s.seq > from)
33
+ const behind = from < this.dropped || from > this.seq || after.some((s) => s.event === null)
34
+ return { gen: this.gen, seq: this.seq, behind, events: behind ? [] : after.map((s) => structuredClone(s.event!)) }
35
+ }
36
+ }