@brust/cache-cloudflare 2.0.0-alpha.34 → 2.0.0-alpha.35

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brust/cache-cloudflare",
3
- "version": "2.0.0-alpha.34",
3
+ "version": "2.0.0-alpha.35",
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.34"
30
+ "@brust/core": "2.0.0-alpha.35"
31
31
  },
32
32
  "devDependencies": {
33
- "@brust/core": "2.0.0-alpha.34",
33
+ "@brust/core": "2.0.0-alpha.35",
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 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)
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 [...meta.tags.map((t) => `t:${t}`), pathName(meta.method, meta.path)]) {
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
- 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)
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
- 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)
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>`): an entry written under a stamp below its floor is invalid.
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
- return [...(args.tags ?? []).map((t) => `t:${t}`), ...(args.path !== undefined ? [pathName(args.method, args.path)] : [])]
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 [...tags.map((t) => `t:${t}`), pathName(method, path)]) f = Math.max(f, this.m.get(n) ?? 0)
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
@@ -8,6 +8,8 @@ export interface EntryMeta {
8
8
  tags: string[]
9
9
  method: string
10
10
  path: string
11
+ /** The route-cache key prefix (F493); absent in frames written before it = `''`. */
12
+ prefix?: string
11
13
  entryStamp: number | null
12
14
  storedAt: number
13
15
  expiresAt: number
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 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])}`
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 path cannot be enumerated without index writes; KV alone is "stale <= ttl".
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 }