Break KVStorageComposite into focused classes
- 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:
@@ -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) : ''
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user