brust 2.0.0-alpha.5
Cache adapters / Write your own

Write your own

An adapter is a plain object with a store, a broadcast, or both. Import the types from @brust/core/cache-adapter.

That module has no bun import, so it bundles into a Worker. Set adapter = "custom" and module = "lib/cache.ts" in [cache] (see the overview).

1The adapter shape

@brust/core/cache-adapter
export interface CacheAdapter {
  readonly name: string
  readonly runtimes: ReadonlyArray<'bun' | 'workers'>
  store?: CacheStore
  broadcast?: CacheBroadcast
  init?(ctx: { namespace: string; buildId: string; runtime: 'bun' | 'workers'; workerId: number }): Promise<void>
  close?(): Promise<void>
}

An adapter needs a store, a broadcast, or both.

2Store: shared page bodies

@brust/core/cache-adapter
interface CacheStore {
  get(key: string): Promise<{ page: StoredPage | null; stamp: number | null; entryStamp: number | null; storedAt: number | null }>
  set(key: string, page: StoredPage, meta: { ttlMs: number; tags: string[]; method: string; path: string; stamp: number | null }): Promise<boolean>
  invalidate(args: InvalidateArgs): Promise<void>
  readonly maxBodyBytes: number
}
interface StoredPage { status: number; headers: [string, string][]; body: Uint8Array }   // identity body
  • get returns the page or null. stamp is the broadcast sequence number now; set later passes it back as meta.stamp.
  • set returns false to refuse a write. A store that can do atomic writes must refuse when meta.stamp is older than an invalidation of one of meta.tags or of meta.method meta.path. This stops a page rendered before an invalidation from being stored after it. A store that cannot (a plain key-value service) returns null stamps and its writes are unguarded: stale entries then live up to their TTL.
  • invalidate deletes by tag or by path. It ignores key. Record the invalidation before deleting, so a racing stale set is refused even before the broadcast.
  • Bodies are binary. Preserve every byte value, and the order and repeats of headers.

3Broadcast: invalidation events

Provide exactly one of subscribe or since:

@brust/core/cache-adapter
interface CacheBroadcast {
  publish(args: InvalidateArgs, origin: string): Promise<number>   // resolves to the sequence number assigned
  subscribe?(on: (e: InvalidationEvent) => void, onGap: () => void): Promise<() => Promise<void>>   // push (native)
  since?(seq: number): Promise<{ gen: string; seq: number; behind: boolean; events: InvalidationEvent[] }>   // pull (Workers)
}
interface InvalidationEvent { seq: number; origin: string; args: InvalidateArgs }
  • Every stamp, entryStamp and event seq is a number in one sequence space, the broadcast’s.
  • Push: call on in order, and call onGap whenever delivery may have lost an event; brust then clears tier 1.
  • Pull: gen identifies the broadcast’s history. If it changes, or behind is true, brust clears tier 1.
  • brust skips events whose origin is its own: it already cleared its tier 1.

4A minimal adapter

A store-only adapter over an HTTP key-value service. It works on both runtimes, has no broadcast, and so does not guard writes:

lib/cache.ts
import { defineCacheAdapter, type StoredPage } from '@brust/core/cache-adapter'

// Chunked, so a large page body does not overflow the argument limit of String.fromCharCode.
const toBase64 = (bytes: Uint8Array): string => {
  let s = ''
  for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
  return btoa(s)
}

export default defineCacheAdapter((opts) => {
  const base = String(opts.baseUrl)
  const token = String(opts.token)
  const call = (path: string, init: RequestInit = {}) =>
    fetch(base + path, { ...init, headers: { authorization: `Bearer ${token}`, ...init.headers } })
  return {
    name: 'http-kv',
    runtimes: ['bun', 'workers'],
    store: {
      maxBodyBytes: 1_048_576,
      async get(key) {
        const r = await call(`/get/${encodeURIComponent(key)}`)
        if (r.status === 404) return { page: null, stamp: null, entryStamp: null, storedAt: null }
        const j = (await r.json()) as { status: number; headers: [string, string][]; body: string; storedAt: number }
        const page: StoredPage = { status: j.status, headers: j.headers, body: Uint8Array.from(atob(j.body), (c) => c.charCodeAt(0)) }
        return { page, stamp: null, entryStamp: null, storedAt: j.storedAt }
      },
      async set(key, page, meta) {
        const r = await call(`/set/${encodeURIComponent(key)}?ttl=${Math.ceil(meta.ttlMs / 1000)}`, {
          method: 'POST',
          body: JSON.stringify({ status: page.status, headers: page.headers, body: toBase64(page.body), storedAt: Date.now() }),
        })
        return r.ok
      },
      async invalidate() {
        // A service with no tag index cannot delete by tag. Entries then expire by their TTL.
      },
    },
  }
})
brust.toml
[cache]
adapter = "custom"
module = "lib/cache.ts"

[cache.options]
baseUrl = "https://kv.example.com"
token = "set in the environment and read there, not written in brust.toml"
Note

This sketch skips error handling: a thrown error is counted and treated as a miss. It is a shape to copy from, not a tested client for any particular service.

5Test it

Test your adapter with the conformance suite brust uses for its own adapters:

test/cache.test.ts
import { runConformance } from '@brust/core/cache-adapter/conformance'
// bun:test cases: binary-safe bodies, header order, TTL expiry, tag and path invalidation,
// the stale-write guard, push/pull delivery, gaps. Pass the capabilities your adapter claims.
runConformance(() => myAdapter(), { push: true, guard: true })

@brust/core/cache-adapter also exports memoryAdapter, the reference implementation. It shares nothing across processes, so it is for tests, not production.