@brust/cache-cloudflare 0.0.0-stage → 2.0.0-alpha.4
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 +76 -2
- package/package.json +33 -4
- package/src/cloudflare.d.ts +34 -0
- package/src/combine.ts +84 -0
- package/src/do-adapter.ts +146 -0
- package/src/do-class.ts +165 -0
- package/src/do.ts +3 -0
- package/src/env.ts +14 -0
- package/src/floors.ts +41 -0
- package/src/frame.ts +38 -0
- package/src/index.ts +5 -0
- package/src/kv-adapter.ts +73 -0
- package/src/kv.ts +10 -0
- package/src/options.ts +34 -0
- package/src/ring.ts +36 -0
package/README.md
CHANGED
|
@@ -1,3 +1,77 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @brust/cache-cloudflare
|
|
2
2
|
|
|
3
|
-
|
|
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": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "2.0.0-alpha.4",
|
|
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.4"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@brust/core": "2.0.0-alpha.4",
|
|
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)
|
package/src/do-class.ts
ADDED
|
@@ -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
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,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
|
+
}
|