Plain operations: last write wins, hash-keyed events, simpler composite
CI / Node 22 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Successful in 14s

- KV: one write primitive, write(changes) with { key, value? }; writeIfAbsent removed.
  The server executes exactly what clients send and never reads values, so they can be
  encrypted end to end. Adds setFields/getFields on the base class.
- HTTP: no server history or event ids; SSE relays every change (own writes included) and
  the client emits resync when a dropped stream reconnects.
- Composite: writes apply locally, then go to the source (undone locally if refused);
  pushed changes are applied and announced only where they differ from local values;
  offline writes go to a queue store (any KV storage); sync() = replay, diff, replay.
  Removes KVReplicaSet, KVOutbox, KeyCounter, SerialQueue and RetryTimer.
- Events: id = <time>_<sha256(time + canonical data)>, so concurrent appends never
  collide and retries can't duplicate; no versions or ConcurrencyError.
- Event-sourced documents: read, resolve, append, apply; catchUp folds in foreign events;
  getAt takes an event id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 13:33:07 +00:00
parent 1f82ec9110
commit 631c75428b
34 changed files with 814 additions and 1387 deletions
+26 -105
View File
@@ -13,50 +13,33 @@ export interface KVStorageHttpServerOptions {
auth: 'none' | ({ verifiers: SignatureVerifier[] } & RequestAuthOptions)
/** Path prefix to strip, e.g. `/kv`. Default `''`. */
basePath?: string
/** Recent change batches kept for clients resuming with `Last-Event-ID`. Default 10 000. */
historySize?: number
/** Interval of SSE keep-alive comments. Default 15 000 ms. */
keepAliveMs?: number
}
interface HistoryEntry {
seq: number
changes: KVChange[]
}
interface Subscriber {
/** Keys (with their subtrees) this subscriber receives; `''` = everything. */
ranges: KeyRange[]
send: (frame: string) => void
}
type RequestBody = { keys: string[]; entries: [string, unknown][]; paths: string[] } & ScanOptions
type RequestBody = { keys: string[]; changes: KVChange[]; paths: string[] } & ScanOptions
/**
* Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)`
* returns a `Response`. Every route is `POST` with an Extended JSON body:
* returns a `Response`. It executes exactly the operations clients send; it doesn't know about
* versions or conflicts. Every route is `POST` with an Extended JSON body:
*
* - `/readMany`, `/writeMany`, `/removeMany`, `/writeIfAbsent`, `/scan` (streams NDJSON).
* - `/events` with `{ paths }`: Server-Sent Events of changes to those keys and their subtrees.
* Event ids are `<epoch>-<seq>` (epoch = this server instance). A client sending
* `Last-Event-ID` gets the matching changes it missed if they are still in the history,
* otherwise a `resync` event; either way a `ready` event ends the catch-up.
* - `/readMany { keys }`, `/write { changes }`, `/scan { gte, lt, limit, reverse, values }` (NDJSON).
* - `/events { paths }`: Server-Sent Events relaying each change to those keys (and their
* subtrees) as it happens, including the subscriber's own writes. A `ready` event follows the
* connection. Nothing is replayed after a reconnect; clients catch up by reading.
*
* With authentication, each request is checked first (401), then every key, scan range and
* subscription path must be the caller's public key or under it (403).
*
* Only changes made through the wrapped storage instance are seen; several server processes over
* one database each see only their own writes.
* Only changes made through the wrapped storage instance are relayed; several server processes
* over one database each relay only their own writes.
*/
export class KVStorageHttpServer {
private readonly auth: RequestAuth | undefined
private readonly basePath: string
private readonly historySize: number
private readonly keepAliveMs: number
private readonly epoch = Math.random().toString(36).slice(2, 10)
private seq = 0
private readonly history: HistoryEntry[] = []
private readonly subscribers = new Set<Subscriber>()
private readonly subscribers = new Set<{ ranges: KeyRange[]; send: (frame: string) => void }>()
constructor(
private readonly storage: BaseKVStorage,
@@ -64,9 +47,13 @@ export class KVStorageHttpServer {
) {
this.auth = options.auth === 'none' ? undefined : new RequestAuth(options.auth.verifiers, options.auth)
this.basePath = options.basePath ?? ''
this.historySize = options.historySize ?? 10_000
this.keepAliveMs = options.keepAliveMs ?? 15_000
storage.on('change', ({ changes }) => this.publish(changes))
storage.on('change', ({ changes }) => {
for (const subscriber of this.subscribers) {
const matching = changes.filter(({ key }) => subscriber.ranges.some((range) => range.contains(key)))
if (matching.length) subscriber.send(`event: change\ndata: ${ExtendedJson.stringify(matching)}\n\n`)
}
})
}
async handle(request: Request): Promise<Response> {
@@ -81,32 +68,22 @@ export class KVStorageHttpServer {
this.authorize(owner, body.keys)
const values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
return KVStorageHttpServer.json({ values: values.map((v) => v ?? null), missing })
return new Response(ExtendedJson.stringify({ values: values.map((v) => v ?? null), missing }), { headers: { 'content-type': 'application/json' } })
}
case '/writeMany':
case '/write':
this.authorize(
owner,
body.entries.map(([key]) => key),
body.changes.map(({ key }) => key),
)
await this.storage.writeMany(body.entries)
await this.storage.writeMany(body.changes)
return new Response(null, { status: 204 })
case '/removeMany':
this.authorize(owner, body.keys)
await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 })
case '/writeIfAbsent':
this.authorize(
owner,
body.entries.map(([key]) => key),
)
return KVStorageHttpServer.json(await this.storage.writeIfAbsent(body.entries))
case '/scan':
this.authorizeRange(owner, body)
if (owner !== undefined && !new KeyRange(owner).covers(body.gte, body.lt)) throw new AuthError(403, 'Scan outside your keys')
return this.scan(body)
case '/events':
if (!Array.isArray(body.paths) || body.paths.length === 0) return KVStorageHttpServer.error(400, 'Subscribe to at least one path')
this.authorize(owner, body.paths)
return this.events(body.paths, request.headers.get('last-event-id'))
return this.events(body.paths)
default:
return KVStorageHttpServer.error(404, 'Not found')
}
@@ -116,8 +93,6 @@ export class KVStorageHttpServer {
}
}
// ------------------------------------------------------------------ authorization
/** Every key must be the owner's key or under it (no-op without authentication). */
private authorize(owner: string | undefined, keys: string[]): void {
if (owner === undefined) return
@@ -127,33 +102,15 @@ export class KVStorageHttpServer {
}
}
/** A scan must stay inside the owner's key and its subtree. */
private authorizeRange(owner: string | undefined, options: ScanOptions): void {
if (owner !== undefined && !new KeyRange(owner).covers(options.gte, options.lt)) throw new AuthError(403, 'Scan outside your keys')
}
// ------------------------------------------------------------------ change stream
private publish(changes: KVChange[]): void {
const entry = { seq: ++this.seq, changes }
this.history.push(entry)
if (this.history.length > this.historySize) this.history.splice(0, this.history.length - this.historySize)
for (const subscriber of this.subscribers) {
const frame = this.changeFrame(entry, subscriber.ranges)
if (frame) subscriber.send(frame)
}
}
private events(paths: string[], lastEventId: string | null): Response {
private events(paths: string[]): Response {
const encoder = new TextEncoder()
let subscriber: Subscriber | undefined
let subscriber: { ranges: KeyRange[]; send: (frame: string) => void } | undefined
let keepAlive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
const ranges = paths.map((path) => new KeyRange(path))
subscriber = { ranges, send: (frame) => controller.enqueue(encoder.encode(frame)) }
subscriber.send(this.catchUpFrames(ranges, lastEventId))
subscriber = { ranges: paths.map((path) => new KeyRange(path)), send: (frame) => controller.enqueue(encoder.encode(frame)) }
this.subscribers.add(subscriber)
subscriber.send('event: ready\ndata: null\n\n')
keepAlive = setInterval(() => subscriber!.send(': keep-alive\n\n'), this.keepAliveMs)
;(keepAlive as { unref?: () => void }).unref?.()
},
@@ -165,38 +122,6 @@ export class KVStorageHttpServer {
return new Response(stream, { headers: { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' } })
}
/**
* What a (re)connecting client gets before live changes: the matching changes it missed, or
* `resync` when they can't be replayed; then always `ready` (caught up as of that id).
*/
private catchUpFrames(ranges: KeyRange[], lastEventId: string | null): string {
const ready = this.frame('ready', this.seq, null)
if (lastEventId === null) return ready
const [epoch, seqText] = lastEventId.split('-')
const seq = Number(seqText)
const oldest = this.history[0]?.seq ?? this.seq + 1
// Resumable only if it's our epoch and nothing after `seq` has been dropped from the history.
if (epoch !== this.epoch || !Number.isInteger(seq) || seq > this.seq || seq < oldest - 1) return this.frame('resync', this.seq, null) + ready
return (
this.history
.filter((entry) => entry.seq > seq)
.map((entry) => this.changeFrame(entry, ranges))
.join('') + ready
)
}
/** A `change` frame with only the changes inside `ranges`, or `''` if none match. */
private changeFrame(entry: HistoryEntry, ranges: KeyRange[]): string {
const changes = entry.changes.filter(({ key }) => ranges.some((range) => range.contains(key)))
return changes.length ? this.frame('change', entry.seq, changes) : ''
}
private frame(event: string, seq: number, data: unknown): string {
return `id: ${this.epoch}-${seq}\nevent: ${event}\ndata: ${ExtendedJson.stringify(data)}\n\n`
}
// ------------------------------------------------------------------ scan
private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder()
@@ -221,10 +146,6 @@ export class KVStorageHttpServer {
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
}
private static json(body: unknown): Response {
return new Response(ExtendedJson.stringify(body), { headers: { 'content-type': 'application/json' } })
}
private static error(status: number, message: string): Response {
return Response.json({ error: message }, { status })
}