Simplify: one abstract base class per layer
- 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
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user