- BaseKVStorage / BaseEventStorage / BaseDocumentStorage define the API once; implementations provide only raw primitives (client and server share an API) - Drop KV indexes, query planner, unique indexes; document stores take index fields - Escape every char <= '/' so child keys stay contiguous; skip-scan is now simple - Event-sourced documents = event storage + any document store for projections - New folder layout (kv-storage, event-storage, document-storage, utils); tests only in test/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
101 lines
3.9 KiB
TypeScript
101 lines
3.9 KiB
TypeScript
import { Keys } from '../utils/keys.js'
|
|
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueAt, ValueOf } from '../utils/schema.js'
|
|
|
|
export interface AppendOptions {
|
|
/** Version the stream must be at (0 = empty). Default `'any'`. */
|
|
expectedVersion?: number | 'any'
|
|
}
|
|
|
|
export interface AppendResult {
|
|
/** Stream version after the append. */
|
|
version: number
|
|
}
|
|
|
|
export interface ReadOptions {
|
|
/** First version to read (inclusive). Default 1. */
|
|
from?: number
|
|
/** Last version to read (inclusive). Default: the head. */
|
|
to?: number
|
|
limit?: number
|
|
}
|
|
|
|
export interface StoredEvent<E> {
|
|
version: number
|
|
/** Append timestamp (ms since epoch). */
|
|
at: number
|
|
data: E
|
|
}
|
|
|
|
export interface StreamKeysOptions {
|
|
limit?: number
|
|
after?: string | number
|
|
reverse?: boolean
|
|
}
|
|
|
|
/**
|
|
* Append-only event streams. Every node is a stream (its own events) and a parent of child
|
|
* streams. Implementations provide four primitives over stream paths; the user-facing,
|
|
* self-or-child API is defined here once.
|
|
*/
|
|
export abstract class BaseEventStorage<S extends Spec = AnySpec> {
|
|
/** Unescaped path of this node (`[]` for the root, which is not a stream). */
|
|
path: readonly string[] = []
|
|
|
|
abstract appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult>
|
|
abstract readFrom(stream: readonly string[], options: ReadOptions): AsyncIterable<StoredEvent<unknown>>
|
|
abstract versionOf(stream: readonly string[]): Promise<number>
|
|
abstract streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncIterable<string>
|
|
|
|
child<K extends KeyOf<S>>(key: K): BaseEventStorage<ChildOf<S, K>> {
|
|
const node = Object.create(this) as BaseEventStorage<ChildOf<S, K>>
|
|
node.path = [...this.path, Keys.segment(key)]
|
|
return node
|
|
}
|
|
|
|
as<S2 extends Spec>(): BaseEventStorage<S2> {
|
|
return this as unknown as BaseEventStorage<S2>
|
|
}
|
|
|
|
/** `append(events, options?)` on this stream; `append(key, events, options?)` on a child stream. */
|
|
append<K extends KeyOf<S>>(
|
|
...args: [events: ValueOf<S>[], options?: AppendOptions] | [key: K, events: ValueOf<ChildOf<S, K>>[], options?: AppendOptions]
|
|
): Promise<AppendResult> {
|
|
const hasKey = !Array.isArray(args[0])
|
|
const events = args[hasKey ? 1 : 0]
|
|
const options = (args[hasKey ? 2 : 1] ?? {}) as AppendOptions
|
|
if (!Array.isArray(events)) return Promise.reject(new TypeError('append() expects an array of events'))
|
|
return this.withStream(hasKey, args[0], (stream) => this.appendTo(stream, events, options.expectedVersion ?? 'any'))
|
|
}
|
|
|
|
/** `read(options?)` this stream; `read(key, options?)` a child stream. */
|
|
async *read<K extends KeyOf<S>>(...args: [options?: ReadOptions] | [key: K, options?: ReadOptions]): AsyncGenerator<StoredEvent<ValueAt<S, K>>> {
|
|
const hasKey = typeof args[0] !== 'object' && args.length > 0
|
|
const stream = this.streamPath(hasKey, args[0])
|
|
yield* this.readFrom(stream, ((hasKey ? args[1] : args[0]) ?? {}) as ReadOptions) as AsyncIterable<StoredEvent<ValueAt<S, K>>>
|
|
}
|
|
|
|
/** Current version of this stream (or a child's); 0 when empty. */
|
|
version(key?: KeyOf<S>): Promise<number> {
|
|
return this.withStream(arguments.length > 0, key, (stream) => this.versionOf(stream))
|
|
}
|
|
|
|
/** Keys of child streams. */
|
|
keys(options: StreamKeysOptions = {}): AsyncIterable<KeyString<S>> {
|
|
return this.streamsOf(this.path, options) as AsyncIterable<KeyString<S>>
|
|
}
|
|
|
|
private withStream<T>(hasKey: boolean, key: unknown, fn: (stream: readonly string[]) => Promise<T>): Promise<T> {
|
|
try {
|
|
return fn(this.streamPath(hasKey, key))
|
|
} catch (error) {
|
|
return Promise.reject(error)
|
|
}
|
|
}
|
|
|
|
private streamPath(hasKey: boolean, key: unknown): readonly string[] {
|
|
if (hasKey) return [...this.path, Keys.segment(key)]
|
|
if (this.path.length === 0) throw new TypeError('The root node is not a stream')
|
|
return this.path
|
|
}
|
|
}
|