Authenticated, scoped sync: signed requests and per-path subscriptions
- 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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user