Break KVStorageComposite into focused classes
CI / Node 22 (pull_request) Successful in 11s
CI / Node 24 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 12s

- KVReplicaSet: seed, apply, local scan, diff against the source, sync meta
- KVOutbox: persisted offline queue with in-order replay (load() resumes it)
- utils: KeyRange (also used for server authorization), KeyCounter,
  SerialQueue (also used by KVStorageHttp and the event-sourced store),
  RetryTimer (separate timers for sync and replay; previously one shared
  timer could cancel the other's retry)

The composite now only orchestrates. Behaviour unchanged; new unit tests for
the building blocks and the outbox.

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 12:43:49 +00:00
parent a8c9539ad9
commit 1f82ec9110
14 changed files with 616 additions and 317 deletions
+16 -21
View File
@@ -1,6 +1,6 @@
import { AuthError } from '../utils/errors.js'
import { ExtendedJson } from '../utils/extended-json.js'
import { Keys } from '../utils/keys.js'
import { KeyRange } from '../utils/key-range.js'
import { RequestAuth, type RequestAuthOptions, type SignatureVerifier } from '../utils/request-auth.js'
import type { BaseKVStorage, KVChange, ScanOptions } from './base-kv-storage.js'
@@ -25,8 +25,8 @@ interface HistoryEntry {
}
interface Subscriber {
/** Keys whose own value and subtree this subscriber receives (`''` = everything). */
paths: string[]
/** Keys (with their subtrees) this subscriber receives; `''` = everything. */
ranges: KeyRange[]
send: (frame: string) => void
}
@@ -121,21 +121,15 @@ export class KVStorageHttpServer {
/** 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
const allowed = new KeyRange(owner)
for (const key of keys) {
if (typeof key !== 'string' || !KVStorageHttpServer.within(owner, key)) throw new AuthError(403, `No access to "${key}"`)
if (typeof key !== 'string' || !allowed.contains(key)) throw new AuthError(403, `No access to "${key}"`)
}
}
/** A scan must stay inside `[owner, owner + '0')`: the owner's key and its subtree. */
/** A scan must stay inside the owner's key and its subtree. */
private authorizeRange(owner: string | undefined, options: ScanOptions): void {
if (owner === undefined) return
const { gte, lt } = options
if (gte === undefined || lt === undefined || gte < owner || lt > owner + Keys.afterSeparator) throw new AuthError(403, 'Scan outside your keys')
}
/** Whether `key` is `path` or below it (`''` covers everything). */
private static within(path: string, key: string): boolean {
return path === '' || key === path || key.startsWith(path + Keys.separator)
if (owner !== undefined && !new KeyRange(owner).covers(options.gte, options.lt)) throw new AuthError(403, 'Scan outside your keys')
}
// ------------------------------------------------------------------ change stream
@@ -145,7 +139,7 @@ export class KVStorageHttpServer {
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.paths)
const frame = this.changeFrame(entry, subscriber.ranges)
if (frame) subscriber.send(frame)
}
}
@@ -156,8 +150,9 @@ export class KVStorageHttpServer {
let keepAlive: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream<Uint8Array>({
start: (controller) => {
subscriber = { paths, send: (frame) => controller.enqueue(encoder.encode(frame)) }
subscriber.send(this.catchUpFrames(paths, lastEventId))
const ranges = paths.map((path) => new KeyRange(path))
subscriber = { ranges, send: (frame) => controller.enqueue(encoder.encode(frame)) }
subscriber.send(this.catchUpFrames(ranges, lastEventId))
this.subscribers.add(subscriber)
keepAlive = setInterval(() => subscriber!.send(': keep-alive\n\n'), this.keepAliveMs)
;(keepAlive as { unref?: () => void }).unref?.()
@@ -174,7 +169,7 @@ export class KVStorageHttpServer {
* 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(paths: string[], lastEventId: string | null): string {
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('-')
@@ -185,14 +180,14 @@ export class KVStorageHttpServer {
return (
this.history
.filter((entry) => entry.seq > seq)
.map((entry) => this.changeFrame(entry, paths))
.map((entry) => this.changeFrame(entry, ranges))
.join('') + ready
)
}
/** A `change` frame with only the changes under `paths`, or `''` if none match. */
private changeFrame(entry: HistoryEntry, paths: string[]): string {
const changes = entry.changes.filter(({ key }) => paths.some((path) => KVStorageHttpServer.within(path, key)))
/** 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) : ''
}