Plain operations: last write wins, hash-keyed events, simpler composite
- 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:
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user