@brust/cache-cloudflare 2.0.0-alpha.34 → 2.0.0-alpha.36
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/package.json +3 -3
- package/src/combine.ts +2 -2
- package/src/do-adapter.ts +3 -2
- package/src/do-class.ts +24 -6
- package/src/floors.ts +24 -5
- package/src/frame.ts +2 -0
- package/src/kv-adapter.ts +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@brust/cache-cloudflare",
|
|
3
|
-
"version": "2.0.0-alpha.
|
|
3
|
+
"version": "2.0.0-alpha.36",
|
|
4
4
|
"description": "brust cache adapters for Cloudflare Workers: KV store, Durable Object broadcast/store, and their combination",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://brust.dev",
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"typecheck": "bun check"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@brust/core": "2.0.0-alpha.
|
|
30
|
+
"@brust/core": "2.0.0-alpha.36"
|
|
31
31
|
},
|
|
32
32
|
"devDependencies": {
|
|
33
|
-
"@brust/core": "2.0.0-alpha.
|
|
33
|
+
"@brust/core": "2.0.0-alpha.36",
|
|
34
34
|
"@types/bun": "^1.4.4",
|
|
35
35
|
"typescript": "^7.0.2"
|
|
36
36
|
},
|
package/src/combine.ts
CHANGED
|
@@ -36,14 +36,14 @@ export function combine(kvIn: CacheAdapter, d: DoAdapter): CacheAdapter {
|
|
|
36
36
|
const { result, meta } = await kv.store.getWithMeta(key)
|
|
37
37
|
if (!result.page || !meta) return { page: null, stamp, entryStamp: null, storedAt: null }
|
|
38
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 }
|
|
39
|
+
if (result.entryStamp === null || result.entryStamp < floors.floorOf(meta.tags, meta.method, meta.path, meta.prefix)) return { page: null, stamp, entryStamp: null, storedAt: null }
|
|
40
40
|
return { ...result, stamp }
|
|
41
41
|
},
|
|
42
42
|
async set(key: string, page: StoredPage, meta: SetMeta) {
|
|
43
43
|
if (!seeded) await seed()
|
|
44
44
|
// `stamp: null` = an unguarded write: it is stamped with the seq known now, so floors judge it from here on.
|
|
45
45
|
const stamp = meta.stamp ?? lastSeq
|
|
46
|
-
if (meta.stamp !== null && stamp < floors.floorOf(meta.tags, meta.method, meta.path)) return false
|
|
46
|
+
if (meta.stamp !== null && stamp < floors.floorOf(meta.tags, meta.method, meta.path, meta.prefix)) return false
|
|
47
47
|
return kv.store.set(key, page, { ...meta, stamp })
|
|
48
48
|
},
|
|
49
49
|
async invalidate(args: InvalidateArgs) {
|
package/src/do-adapter.ts
CHANGED
|
@@ -109,8 +109,9 @@ export function doAdapter(options: DoOptions = {}, deps: DoDeps = {}): DoAdapter
|
|
|
109
109
|
if (page.body.length > MAX_DO_BODY_BYTES) return false
|
|
110
110
|
const at = now()
|
|
111
111
|
const expiresAt = at + meta.ttlMs
|
|
112
|
-
const
|
|
113
|
-
|
|
112
|
+
const prefix = meta.prefix ?? ''
|
|
113
|
+
const frame = encodeEntry(page, { tags: meta.tags, method: meta.method, path: meta.path, prefix, entryStamp: meta.stamp, storedAt: at, expiresAt })
|
|
114
|
+
return (await shard(shardIndex(key))).set(key, frame, { tags: meta.tags, method: meta.method, path: meta.path, prefix, expiresAt }, meta.stamp)
|
|
114
115
|
},
|
|
115
116
|
async invalidate(args: InvalidateArgs) {
|
|
116
117
|
const head = await (await epoch()).head()
|
package/src/do-class.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
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
4
|
import { DurableObject } from 'cloudflare:workers'
|
|
5
5
|
import type { EpochSince, InvalidateArgs } from '@brust/core/cache-adapter'
|
|
6
|
-
import { namesOf, pathName } from './floors'
|
|
6
|
+
import { entryNames, namesOf, pathName } from './floors'
|
|
7
7
|
import { Ring } from './ring'
|
|
8
8
|
|
|
9
9
|
export const INVAL_CAP = 4096
|
|
10
10
|
const SWEEP_MS = 60_000
|
|
11
11
|
|
|
12
|
-
export interface PageMeta { tags: string[]; method: string; path: string; expiresAt: number }
|
|
12
|
+
export interface PageMeta { tags: string[]; method: string; path: string; /** F493; absent from older callers = `''`. */ prefix?: string; expiresAt: number }
|
|
13
13
|
export interface FloorsPayload { gen: string; seq: number; names: [string, number][]; evicted: number }
|
|
14
14
|
|
|
15
15
|
export class BrustCacheDO extends DurableObject {
|
|
@@ -31,6 +31,10 @@ export class BrustCacheDO extends DurableObject {
|
|
|
31
31
|
sql.exec('CREATE INDEX IF NOT EXISTS inval_seq ON inval(seq)')
|
|
32
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
33
|
sql.exec('CREATE TABLE IF NOT EXISTS tags(tag TEXT NOT NULL, key TEXT NOT NULL, PRIMARY KEY(tag, key))')
|
|
34
|
+
// F493: the page's route-cache key prefix, for `invalidate({ prefix })`. Added in place to a table made before it.
|
|
35
|
+
if (!sql.exec<{ name: string }>('PRAGMA table_info(pages)').toArray().some((c) => c.name === 'prefix'))
|
|
36
|
+
sql.exec("ALTER TABLE pages ADD COLUMN prefix TEXT NOT NULL DEFAULT ''")
|
|
37
|
+
sql.exec('CREATE INDEX IF NOT EXISTS pages_prefix ON pages(prefix)')
|
|
34
38
|
const m = (k: string) => (sql.exec<{ v: number }>('SELECT v FROM meta WHERE k = ?', k).toArray()[0]?.v ?? 0)
|
|
35
39
|
this.evicted = m('evicted')
|
|
36
40
|
this.invalCount = sql.exec<{ c: number }>('SELECT count(*) AS c FROM inval').one().c
|
|
@@ -107,13 +111,14 @@ export class BrustCacheDO extends DurableObject {
|
|
|
107
111
|
const ok = this.ctx.storage.transactionSync(() => {
|
|
108
112
|
if (stamp !== null) {
|
|
109
113
|
if (this.evicted > stamp) return false
|
|
110
|
-
for (const n of
|
|
114
|
+
for (const n of entryNames(meta.tags, meta.method, meta.path, meta.prefix)) {
|
|
111
115
|
const r = this.sql.exec<{ seq: number }>('SELECT seq FROM inval WHERE name = ?', n).toArray()[0]
|
|
112
116
|
if (r && r.seq > stamp) return false
|
|
113
117
|
}
|
|
114
118
|
}
|
|
115
119
|
this.dropKeys([key])
|
|
116
|
-
|
|
120
|
+
const prefix = meta.prefix ?? ''
|
|
121
|
+
this.sql.exec('INSERT INTO pages(key, meta, body, mpath, expires_at, prefix) VALUES(?, ?, ?, ?, ?, ?)', key, JSON.stringify({ tags: meta.tags, method: meta.method, path: meta.path, prefix }), frame, pathName(meta.method, meta.path), meta.expiresAt, prefix)
|
|
117
122
|
for (const t of new Set(meta.tags)) this.sql.exec('INSERT OR IGNORE INTO tags(tag, key) VALUES(?, ?)', t, key)
|
|
118
123
|
return true
|
|
119
124
|
})
|
|
@@ -124,8 +129,21 @@ export class BrustCacheDO extends DurableObject {
|
|
|
124
129
|
return this.ctx.storage.transactionSync(() => {
|
|
125
130
|
this.bump(namesOf(args), seq)
|
|
126
131
|
const keys = new Set<string>()
|
|
127
|
-
|
|
128
|
-
|
|
132
|
+
const pre = args.prefix
|
|
133
|
+
const tags = args.tags ?? []
|
|
134
|
+
if (pre === undefined) {
|
|
135
|
+
for (const t of tags) for (const r of this.sql.exec<{ key: string }>('SELECT key FROM tags WHERE tag = ?', t)) keys.add(r.key)
|
|
136
|
+
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)
|
|
137
|
+
} else if (tags.length || args.path !== undefined) {
|
|
138
|
+
// F493: only that prefix's pages; the other prefixes' rows and tag rows stay.
|
|
139
|
+
for (const t of tags)
|
|
140
|
+
for (const r of this.sql.exec<{ key: string }>('SELECT t.key AS key FROM tags t JOIN pages p ON p.key = t.key WHERE t.tag = ? AND p.prefix = ?', t, pre)) keys.add(r.key)
|
|
141
|
+
if (args.path !== undefined)
|
|
142
|
+
for (const r of this.sql.exec<{ key: string }>('SELECT key FROM pages WHERE mpath = ? AND prefix = ?', pathName(args.method, args.path), pre)) keys.add(r.key)
|
|
143
|
+
} else if (args.method === undefined || args.method.toUpperCase() === 'GET') {
|
|
144
|
+
// `{ prefix }` alone: every (GET) page of that prefix.
|
|
145
|
+
for (const r of this.sql.exec<{ key: string }>('SELECT key FROM pages WHERE prefix = ?', pre)) keys.add(r.key)
|
|
146
|
+
}
|
|
129
147
|
this.dropKeys([...keys])
|
|
130
148
|
return keys.size
|
|
131
149
|
})
|
package/src/floors.ts
CHANGED
|
@@ -1,11 +1,30 @@
|
|
|
1
|
-
// Per-name invalidation floors (names `t:<tag>` and `p:<METHOD> <path
|
|
1
|
+
// Per-name invalidation floors (names `t:<tag>` and `p:<METHOD> <path>`; F493 adds prefix-scoped `px:<prefix>`,
|
|
2
|
+
// `pt:<prefix><tag>` and `pp:<prefix><METHOD> <path>`, the prefix JSON-quoted so names of different prefixes never meet): an
|
|
3
|
+
// entry written under a stamp below its floor is invalid.
|
|
2
4
|
import type { InvalidateArgs } from '@brust/core/cache-adapter'
|
|
3
5
|
|
|
4
6
|
export const pathName = (method: string | undefined, path: string) => `p:${method ?? 'GET'} ${path}`
|
|
7
|
+
const q = (prefix: string) => JSON.stringify(prefix)
|
|
5
8
|
|
|
6
|
-
/** The floor names an invalidation touches. */
|
|
9
|
+
/** The floor names an invalidation touches. `{ prefix }` alone floors every page of that prefix (GET pages only are stored). */
|
|
7
10
|
export function namesOf(args: InvalidateArgs): string[] {
|
|
8
|
-
|
|
11
|
+
const tags = args.tags ?? []
|
|
12
|
+
const pre = args.prefix
|
|
13
|
+
if (pre === undefined) return [...tags.map((t) => `t:${t}`), ...(args.path !== undefined ? [pathName(args.method, args.path)] : [])]
|
|
14
|
+
if (!tags.length && args.path === undefined) return args.method === undefined || args.method.toUpperCase() === 'GET' ? [`px:${q(pre)}`] : []
|
|
15
|
+
return [...tags.map((t) => `pt:${q(pre)}${t}`), ...(args.path !== undefined ? [`pp:${q(pre)}${pathName(args.method, args.path).slice(2)}`] : [])]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Every floor name that covers an entry (its tags, its path, and the same scoped by its prefix). */
|
|
19
|
+
export function entryNames(tags: string[], method: string, path: string, prefix = ''): string[] {
|
|
20
|
+
const p = q(prefix)
|
|
21
|
+
return [
|
|
22
|
+
...tags.map((t) => `t:${t}`),
|
|
23
|
+
pathName(method, path),
|
|
24
|
+
`px:${p}`,
|
|
25
|
+
...tags.map((t) => `pt:${p}${t}`),
|
|
26
|
+
`pp:${p}${pathName(method, path).slice(2)}`,
|
|
27
|
+
]
|
|
9
28
|
}
|
|
10
29
|
|
|
11
30
|
export interface FloorsSnapshot { names: [string, number][]; evicted: number }
|
|
@@ -33,9 +52,9 @@ export class Floors {
|
|
|
33
52
|
for (const n of namesOf(args)) this.raise(n, seq)
|
|
34
53
|
}
|
|
35
54
|
/** 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 {
|
|
55
|
+
floorOf(tags: string[], method: string, path: string, prefix = ''): number {
|
|
37
56
|
let f = this.evicted
|
|
38
|
-
for (const n of
|
|
57
|
+
for (const n of entryNames(tags, method, path, prefix)) f = Math.max(f, this.m.get(n) ?? 0)
|
|
39
58
|
return f
|
|
40
59
|
}
|
|
41
60
|
}
|
package/src/frame.ts
CHANGED
package/src/kv-adapter.ts
CHANGED
|
@@ -50,8 +50,9 @@ export function kvAdapter(options: KvOptions = {}, deps: KvDeps = {}): KvAdapter
|
|
|
50
50
|
if (meta.ttlMs < minTtlS * 1000) return false
|
|
51
51
|
if (page.body.length > store.maxBodyBytes) return false
|
|
52
52
|
const at = now()
|
|
53
|
-
const
|
|
54
|
-
const
|
|
53
|
+
const prefix = meta.prefix ?? ''
|
|
54
|
+
const frame = encodeEntry(page, { tags: meta.tags, method: meta.method, path: meta.path, prefix, entryStamp: meta.stamp, storedAt: at, expiresAt: at + meta.ttlMs })
|
|
55
|
+
const fp = `${fingerprint(page.body)}:${JSON.stringify([page.status, page.headers, meta.tags, meta.method, meta.path, prefix, meta.stamp])}`
|
|
55
56
|
const prev = memo.get(key)
|
|
56
57
|
if (prev && prev.fp === fp && at - prev.at < 60_000) return true
|
|
57
58
|
try {
|
|
@@ -66,7 +67,8 @@ export function kvAdapter(options: KvOptions = {}, deps: KvDeps = {}): KvAdapter
|
|
|
66
67
|
return true
|
|
67
68
|
},
|
|
68
69
|
async invalidate(_args: InvalidateArgs) {
|
|
69
|
-
// R5: an L2Key hashes prefix+method+path+query, so a tag or
|
|
70
|
+
// R5: an L2Key hashes prefix+method+path+query, so a tag, path or prefix cannot be enumerated without index writes; KV alone is
|
|
71
|
+
// "stale <= ttl". Paired with the DO broadcast (combine), the floors judge each hit by its stored prefix too (F493): no index.
|
|
70
72
|
},
|
|
71
73
|
}
|
|
72
74
|
return { name: 'cloudflare-kv', runtimes: ['workers'], store }
|