Authenticated, scoped sync: signed requests and per-path subscriptions
CI / Node 24 (pull_request) Successful in 12s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 26 (pull_request) Successful in 13s

- RequestAuth: clients sign `${timestamp}:${path}:${body}` (as in
  sync-server-example); headers carry public key, timestamp, signature and
  algorithm. Pluggable signers/verifiers; Ed25519 via WebCrypto built in.
  Timestamp window and replay protection.
- KVStorageHttpServer requires auth ('none' must be explicit). With auth, every
  key, scan range and subscription path must be under the caller's public key.
- POST /events with { paths }: subscribers only receive (and on resume only
  replay) changes under their authorized paths.
- KVStorageHttp: signer option; subscribe(path)/unsubscribe(path)/close()
  replace watch(); 401/403 reject instead of retrying.
- KVStorageComposite: prefix option scopes sync, cache and subscription.

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 10:01:06 +00:00
parent df7de27e20
commit a8c9539ad9
14 changed files with 696 additions and 129 deletions
+53 -14
View File
@@ -1,13 +1,20 @@
import { StorageUnavailableError } from '../utils/errors.js'
import type { OffCallback } from '../utils/event-emitter.js'
import { Json } from '../utils/json.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, KeyOf, Spec } from '../utils/schema.js'
import { ExponentialBackoff } from '../utils/sse-session/exponential-backoff.js'
import { BaseKVStorage, type KVChange, type KVChangeEvent, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageCompositeOptions {
/** The source of truth (e.g. `KVStorageHttp`). */
source: BaseKVStorage
/**
* Only this key and its subtree are synced, cached and subscribed to (e.g. the user's public
* key). Keys outside it are rejected. Default `''`: the whole source (only for sources the
* client may read entirely).
*/
prefix?: string
/**
* Local full copies, fastest first (e.g. `[inMemory, indexedDb]`). The last one should be
* persistent: it also stores the offline outbox and sync metadata, and seeds the others on start.
@@ -19,8 +26,8 @@ export interface KVStorageCompositeOptions {
/** Implemented by sources that can push remote changes (e.g. `KVStorageHttp`). */
interface WatchableSource {
watch(): Promise<void>
unwatch(): Promise<void>
subscribe(path: string): Promise<void>
unsubscribe(path: string): Promise<void>
lastEventId: string | undefined
}
@@ -38,16 +45,22 @@ const OUTBOX_PREFIX = '~q/o/'
const OUTBOX_END = '~q/o0'
const SYNC_BATCH = 500
// Types only: children of a KVStorageComposite are KVStorageComposite nodes too (child() creates them with
// Object.create, so they share this class's prototype).
export interface KVStorageComposite<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): KVStorageComposite<ChildOf<S, K>>
}
/**
* Keeps local replicas of a source in sync, so reads (including `keys()`, `entries()` and document
* queries) are served locally, and the app keeps working offline.
*
* - **Start** (lazily, on first use): the persistent replica seeds the faster ones. If it holds a
* previous sync, reads are served from it straight away (possibly stale until caught up).
* - **Staying in sync:** remote changes pushed by the source (`watch()`, e.g. SSE) are applied to
* - **Staying in sync:** remote changes pushed by the source (`subscribe(prefix)`, e.g. SSE) are applied to
* every replica. On restart the source resumes from the saved `lastEventId`; a full sync (diffing
* the source against the replicas) only runs the first time, or when the source says it can't
* resume (`resync`). Without `watch()`, a full sync runs on start.
* resume (`resync`). Without `subscribe()`, a full sync runs on start.
* - **Writes** go to the source, then the replicas. If the source is unreachable
* (`StorageUnavailableError`), they are applied locally and queued in a persisted outbox, then
* replayed in order when it's reachable again (last writer wins on conflict). While any write is
@@ -59,6 +72,8 @@ const SYNC_BATCH = 500
*/
export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly source: BaseKVStorage
/** Synced key range: `[prefix, prefix + '0')` is the prefix's own key and its subtree. */
private readonly scope: { prefix: string; gte?: string; lt?: string }
private readonly replicas: BaseKVStorage[]
/** Persistent replica: seeds the others and holds the outbox and metadata. */
private readonly persistent: BaseKVStorage
@@ -102,6 +117,8 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
super()
if (options.replicas.length === 0) throw new TypeError('KVStorageComposite needs at least one replica')
this.source = options.source
const prefix = options.prefix ?? ''
this.scope = prefix === '' ? { prefix } : { prefix, gte: prefix, lt: prefix + Keys.afterSeparator }
this.replicas = options.replicas
this.persistent = options.replicas.at(-1)!
this.backoff = { baseDelay: 1000, maxDelay: 30_000, ...options.retry }
@@ -134,21 +151,27 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
this.state.closed = true
clearTimeout(this.state.retryTimer)
for (const off of this.offs.splice(0)) off()
if (this.watchable) await (this.source as unknown as WatchableSource).unwatch()
if (this.watchable) await (this.source as unknown as WatchableSource).unsubscribe(this.scope.prefix)
}
async readMany(keys: string[]): Promise<unknown[]> {
this.checkScope(keys)
await this.start()
return (this.state.hasSnapshot ? this.replicas[0]! : this.source).readMany(keys)
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
await this.start()
if (!this.state.hasSnapshot) return yield* this.source.scan(options)
// Narrow the range to the synced scope (and, against an authenticated server, to what it allows).
const gte = this.scope.gte !== undefined && (options.gte === undefined || options.gte < this.scope.gte) ? this.scope.gte : options.gte
const lt = this.scope.lt !== undefined && (options.lt === undefined || options.lt > this.scope.lt) ? this.scope.lt : options.lt
if (gte !== undefined && lt !== undefined && gte >= lt) return
const scoped = { ...options, gte, lt }
if (!this.state.hasSnapshot) return yield* this.source.scan(scoped)
// Skip internal keys before applying the limit.
let remaining = options.limit ?? Infinity
if (remaining <= 0) return
for await (const entry of this.replicas[0]!.scan({ ...options, limit: undefined })) {
for await (const entry of this.replicas[0]!.scan({ ...scoped, limit: undefined })) {
if (entry[0] >= INTERNAL_PREFIX && entry[0] < INTERNAL_END) continue
yield entry
if (--remaining <= 0) return
@@ -156,16 +179,19 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
}
protected async applyWrite(entries: [string, unknown][]): Promise<void> {
this.checkScope(entries.map(([key]) => key))
await this.start()
await this.writeThrough({ type: 'write', entries })
}
protected async applyRemove(keys: string[]): Promise<void> {
this.checkScope(keys)
await this.start()
await this.writeThrough({ type: 'remove', keys })
}
protected async applyWriteIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
this.checkScope(entries.map(([key]) => key))
await this.start()
const written = await this.source.writeIfAbsent(entries)
const changes = entries.filter((_, i) => written[i]).map(([key, value]) => ({ key, value }))
@@ -176,8 +202,17 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
// ------------------------------------------------------------------ start
private inScope(key: string): boolean {
const { prefix } = this.scope
return prefix === '' || key === prefix || key.startsWith(prefix + Keys.separator)
}
private checkScope(keys: string[]): void {
for (const key of keys) if (!this.inScope(key)) throw new RangeError(`"${key}" is outside this composite's prefix "${this.scope.prefix}"`)
}
private get watchable(): boolean {
return typeof (this.source as unknown as Partial<WatchableSource>).watch === 'function'
return typeof (this.source as unknown as Partial<WatchableSource>).subscribe === 'function'
}
private async open(): Promise<void> {
@@ -201,8 +236,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
const source = this.source as unknown as WatchableSource
source.lastEventId = meta?.lastEventId
// Without a resume point, catch up on the stream first so no change after the snapshot is missed.
const watching = source.watch()
if (!meta?.lastEventId) void watching.then(() => this.enqueueApply(() => this.fullSync()))
const watching = source.subscribe(this.scope.prefix)
if (!meta?.lastEventId) void watching.then(() => this.enqueueApply(() => this.fullSync())).catch((error) => console.error(error))
else void watching.catch((error) => console.error(error))
} else {
this.enqueueApply(() => this.fullSync())
}
@@ -238,7 +274,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
}
private async applyRemote(event: KVChangeEvent): Promise<void> {
const candidates = event.changes.filter(({ key }) => !this.consumeEcho(key) && !this.pending.has(key) && !key.startsWith(INTERNAL_PREFIX))
const candidates = event.changes.filter(
({ key }) => this.inScope(key) && !this.consumeEcho(key) && !this.pending.has(key) && !key.startsWith(INTERNAL_PREFIX),
)
// Skip changes the replicas already reflect.
const current = await this.replicas[0]!.readMany(candidates.map(({ key }) => key))
const changes = candidates.filter(({ value }, i) => !Json.equal(value, current[i]))
@@ -276,8 +314,9 @@ export class KVStorageComposite<S extends Spec = AnySpec> extends BaseKVStorage<
/** Changes that turn the persistent replica into the source (both scanned in key order). */
private async *diff(): AsyncGenerator<KVChange> {
const remote = this.source.scan({ values: true })[Symbol.asyncIterator]()
const local = this.persistent.scan({ values: true })[Symbol.asyncIterator]()
const range = { gte: this.scope.gte, lt: this.scope.lt, values: true }
const remote = this.source.scan(range)[Symbol.asyncIterator]()
const local = this.persistent.scan(range)[Symbol.asyncIterator]()
const nextLocal = async () => {
for (let next = await local.next(); ; next = await local.next()) if (next.done || !next.value[0].startsWith(INTERNAL_PREFIX)) return next
}