Simplify: one abstract base class per layer
CI / Node 20 (pull_request) Successful in 10s
CI / Node 22 (pull_request) Successful in 11s

- 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:
Claude
2026-10-23 08:05:03 +00:00
parent e27541c46a
commit 08fb78721f
80 changed files with 2518 additions and 6337 deletions
-176
View File
@@ -1,176 +0,0 @@
import { DEFAULT_SEPARATOR, succ } from './path.js'
export type IndexValue = string | number | boolean | null
/**
* Order-preserving, separator-safe encoding of index values.
*
* Every character the encoder emits sorts strictly above the separator. That makes encodings
* prefix-safe inside keys: `enc(a) + sep + …` sorts below `enc(b)` whenever `enc(a)` is a proper
* prefix of `enc(b)`, so `a < b ⇔ key(a, …) < key(b, …)` and range scans over index entries are exact.
*
* Layout: one type-tag character, then a body.
* - Type tags order `null < false < true < number < string`.
* - Numbers: IEEE-754 float64, big-endian; positive values have the sign bit flipped, negative
* values have all bits flipped; written as 16 fixed-width "hex" digits. `-0` is normalised to `0`;
* `NaN` is rejected.
* - Strings: each code unit `c` with `c <= E` (where `E = succ(sep)`) is written as `E` followed by
* `chr(E + 1 + c)`; every other code unit is written as-is. This is monotone, so order is kept.
*
* The 16 digits are `0-9a-f` when the separator sorts below `'0'` (e.g. `/`), otherwise the 16
* characters immediately after the separator.
*/
export interface IndexValueCodec {
readonly sep: string
encode(value: IndexValue): string
decode(encoded: string): IndexValue
}
const codecs = new Map<string, IndexValueCodec>()
export function indexValueCodec(sep: string = DEFAULT_SEPARATOR): IndexValueCodec {
let codec = codecs.get(sep)
if (!codec) {
codec = makeCodec(sep)
codecs.set(sep, codec)
}
return codec
}
export function encodeIndexValue(value: IndexValue, sep: string = DEFAULT_SEPARATOR): string {
return indexValueCodec(sep).encode(value)
}
export function decodeIndexValue(encoded: string, sep: string = DEFAULT_SEPARATOR): IndexValue {
return indexValueCodec(sep).decode(encoded)
}
/** Encodes a compound value as `enc(v1) + sep + enc(v2) + …`. */
export function encodeIndexValues(values: readonly IndexValue[], sep: string = DEFAULT_SEPARATOR): string {
const codec = indexValueCodec(sep)
let out = ''
for (let i = 0; i < values.length; i++) {
if (i > 0) out += sep
out += codec.encode(values[i]!)
}
return out
}
export function isIndexValue(v: unknown): v is IndexValue {
return v === null || typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && !Number.isNaN(v))
}
function makeCodec(sep: string): IndexValueCodec {
const sepCode = sep.charCodeAt(0)
const digits = sepCode < 0x30 ? '0123456789abcdef' : Array.from({ length: 16 }, (_, i) => String.fromCharCode(sepCode + 1 + i)).join('')
const digitValue = new Map<string, number>([...digits].map((d, i) => [d, i]))
const TAG_NULL = digits[0]!
const TAG_FALSE = digits[1]!
const TAG_TRUE = digits[2]!
const TAG_NUMBER = digits[3]!
const TAG_STRING = digits[4]!
const esc = succ(sep)
const escCode = esc.charCodeAt(0)
const view = new DataView(new ArrayBuffer(8))
function hex32(n: number): string {
let out = ''
for (let shift = 28; shift >= 0; shift -= 4) out += digits[(n >>> shift) & 0xf]
return out
}
function encodeNumber(n: number): string {
if (Number.isNaN(n)) throw new TypeError('NaN cannot be indexed')
view.setFloat64(0, n === 0 ? 0 : n) // normalise -0
let hi = view.getUint32(0)
let lo = view.getUint32(4)
if (hi & 0x80000000) {
hi = ~hi >>> 0
lo = ~lo >>> 0
} else {
hi = (hi ^ 0x80000000) >>> 0
}
return hex32(hi) + hex32(lo)
}
function decodeNumber(body: string): number {
if (body.length !== 16) throw new TypeError(`Bad encoded number ${JSON.stringify(body)}`)
let hi = 0
let lo = 0
for (let i = 0; i < 8; i++) hi = (hi << 4) | digitValue.get(body[i]!)!
for (let i = 8; i < 16; i++) lo = (lo << 4) | digitValue.get(body[i]!)!
hi >>>= 0
lo >>>= 0
if (hi & 0x80000000) {
hi = (hi ^ 0x80000000) >>> 0
} else {
hi = ~hi >>> 0
lo = ~lo >>> 0
}
view.setUint32(0, hi)
view.setUint32(4, lo)
return view.getFloat64(0)
}
function encodeString(s: string): string {
let out = ''
let start = 0
for (let i = 0; i < s.length; i++) {
const c = s.charCodeAt(i)
if (c <= escCode) {
out += s.slice(start, i) + esc + String.fromCharCode(escCode + 1 + c)
start = i + 1
}
}
return start === 0 ? s : out + s.slice(start)
}
function decodeString(body: string): string {
if (body.indexOf(esc) === -1) return body
let out = ''
for (let i = 0; i < body.length; i++) {
const c = body[i]!
if (c === esc) {
const next = body.charCodeAt(++i)
out += String.fromCharCode(next - escCode - 1)
} else out += c
}
return out
}
return {
sep,
encode(value) {
if (value === null) return TAG_NULL
switch (typeof value) {
case 'boolean':
return value ? TAG_TRUE : TAG_FALSE
case 'number':
return TAG_NUMBER + encodeNumber(value)
case 'string':
return TAG_STRING + encodeString(value)
default:
throw new TypeError(`Cannot index value of type ${typeof value}`)
}
},
decode(encoded) {
const tag = encoded[0]
const body = encoded.slice(1)
switch (tag) {
case TAG_NULL:
return null
case TAG_FALSE:
return false
case TAG_TRUE:
return true
case TAG_NUMBER:
return decodeNumber(body)
case TAG_STRING:
return decodeString(body)
default:
throw new TypeError(`Bad encoded index value ${JSON.stringify(encoded)}`)
}
},
}
}
-30
View File
@@ -1,30 +0,0 @@
/** Minimal bounded LRU over `Map` insertion order. */
export class LRU<K, V> {
readonly #map = new Map<K, V>()
constructor(readonly capacity: number) {}
get(key: K): V | undefined {
const v = this.#map.get(key)
if (v !== undefined) {
this.#map.delete(key)
this.#map.set(key, v)
}
return v
}
set(key: K, value: V): void {
if (this.capacity <= 0) return
this.#map.delete(key)
this.#map.set(key, value)
if (this.#map.size > this.capacity) this.#map.delete(this.#map.keys().next().value as K)
}
delete(key: K): void {
this.#map.delete(key)
}
get size(): number {
return this.#map.size
}
}
-15
View File
@@ -1,15 +0,0 @@
/** Per-key in-process mutex: calls with the same key run one at a time, in order. */
export class KeyedMutex {
readonly #tails = new Map<string, Promise<unknown>>()
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
const prev = this.#tails.get(key) ?? Promise.resolve()
const result = prev.then(fn, fn)
const tail = result.catch(() => {})
this.#tails.set(key, tail)
void tail.then(() => {
if (this.#tails.get(key) === tail) this.#tails.delete(key)
})
return result
}
}
-109
View File
@@ -1,109 +0,0 @@
import { InvalidKeyError } from '../errors.js'
export const DEFAULT_SEPARATOR = '/'
/** Reserved segments. User segments can never start with `~` (a leading `~` is escaped). */
export const RESERVED = {
index: '~i',
unique: '~u',
events: '~e',
projections: '~p',
} as const
export type Key = string | number
/** The character immediately after `c` (by UTF-16 code unit). */
export function succ(c: string): string {
return String.fromCharCode(c.charCodeAt(0) + 1)
}
export function validateSeparator(sep: string): void {
if (sep.length !== 1) throw new TypeError(`Separator must be a single character, got ${JSON.stringify(sep)}`)
const code = sep.charCodeAt(0)
if (code <= 0x20 || code >= 0x7f || sep === '%' || sep === '~') {
throw new TypeError(`Separator must be printable ASCII other than "%" and "~", got ${JSON.stringify(sep)}`)
}
}
function hex2(code: number): string {
return '%' + code.toString(16).toUpperCase().padStart(2, '0')
}
/** Converts a user key to a segment string, rejecting `undefined`/`null`/empty. */
export function toSegment(key: unknown): string {
if (key === undefined || key === null) throw new InvalidKeyError(`Key must not be ${key}`)
if (typeof key === 'number') {
if (!Number.isFinite(key)) throw new InvalidKeyError(`Numeric key must be finite, got ${key}`)
return String(key)
}
if (typeof key !== 'string') throw new InvalidKeyError(`Key must be a string or number, got ${typeof key}`)
if (key === '') throw new InvalidKeyError('Key must not be empty')
return key
}
/**
* Escapes one path segment: percent-encodes `%`, the separator, and a leading `~`.
* The result never contains a raw separator and never starts with `~`.
*/
export function escapeSegment(segment: string, sep: string = DEFAULT_SEPARATOR): string {
// Fast path: nothing to escape (the overwhelmingly common case).
if (segment.indexOf('%') === -1 && segment.indexOf(sep) === -1 && segment.charCodeAt(0) !== 0x7e) return segment
let out = ''
for (let i = 0; i < segment.length; i++) {
const c = segment[i]!
if (c === '%' || c === sep || (i === 0 && c === '~')) out += hex2(c.charCodeAt(0))
else out += c
}
return out
}
export function unescapeSegment(escaped: string): string {
if (escaped.indexOf('%') === -1) return escaped
return escaped.replace(/%([0-9A-Fa-f]{2})/g, (_, h: string) => String.fromCharCode(parseInt(h, 16)))
}
export function isReservedSegment(escaped: string): boolean {
return escaped.charCodeAt(0) === 0x7e // '~'
}
/** Joins unescaped segments into an escaped key. */
export function joinPath(path: readonly string[], sep: string = DEFAULT_SEPARATOR): string {
let out = ''
for (let i = 0; i < path.length; i++) {
if (i > 0) out += sep
out += escapeSegment(path[i]!, sep)
}
return out
}
/** Splits an escaped key into unescaped segments. */
export function splitPath(key: string, sep: string = DEFAULT_SEPARATOR): string[] {
if (key === '') return []
return key.split(sep).map(unescapeSegment)
}
/**
* Precomputed key strings for a node path.
* - `selfKey`: escaped segments joined by the separator (`''` for the root)
* - `prefix`: `selfKey + sep` (`''` for the root) — every descendant key starts with it
* - `end`: exclusive upper bound of the subtree range (`selfKey + succ(sep)`; `undefined` for the root)
*/
export interface PathKeys {
readonly path: readonly string[]
readonly sep: string
readonly selfKey: string
readonly prefix: string
readonly end: string | undefined
}
export function pathKeys(path: readonly string[], sep: string): PathKeys {
const selfKey = joinPath(path, sep)
const root = path.length === 0
return {
path,
sep,
selfKey,
prefix: root ? '' : selfKey + sep,
end: root ? undefined : selfKey + succ(sep),
}
}
-82
View File
@@ -1,82 +0,0 @@
/**
* Segment trie over path patterns. `*` matches exactly one segment.
* A node's matching state is computed incrementally: `step(parentState, segment)`,
* so resolving a path is O(depth) and `child()` is O(1) in the number of patterns.
*/
export interface TrieNode<T> {
readonly literal: Map<string, TrieNode<T>>
star: TrieNode<T> | undefined
values: { pattern: string; value: T }[]
}
export type TrieState<T> = readonly TrieNode<T>[]
export class PatternTrie<T> {
readonly root: TrieNode<T> = newNode()
constructor(readonly sep: string) {}
/** `''` names the root node. */
add(pattern: string, value: T): void {
let node = this.root
for (const seg of splitPattern(pattern, this.sep)) {
if (seg === '*') node = node.star ??= newNode()
else {
let next = node.literal.get(seg)
if (!next) node.literal.set(seg, (next = newNode()))
node = next
}
}
node.values.push({ pattern, value })
}
get rootState(): TrieState<T> {
return [this.root]
}
step(state: TrieState<T>, segment: string): TrieState<T> {
if (state.length === 0) return state
if (state.length === 1) {
const n = state[0]!
const lit = n.literal.get(segment)
if (lit && n.star) return [lit, n.star]
return lit ? [lit] : n.star ? [n.star] : EMPTY
}
const out: TrieNode<T>[] = []
for (const n of state) {
const lit = n.literal.get(segment)
if (lit) out.push(lit)
if (n.star) out.push(n.star)
}
return out
}
resolve(path: readonly string[]): TrieState<T> {
let state = this.rootState
for (const seg of path) state = this.step(state, seg)
return state
}
/** Values attached to every pattern matching the state's path. */
static values<T>(state: TrieState<T>): { pattern: string; value: T }[] {
if (state.length === 1) return state[0]!.values
return state.flatMap((n) => n.values)
}
}
const EMPTY: TrieState<never> = []
function newNode<T>(): TrieNode<T> {
return { literal: new Map(), star: undefined, values: [] }
}
export function splitPattern(pattern: string, sep: string): string[] {
return pattern === '' ? [] : pattern.split(sep)
}
/** Whether two patterns can match the same path. */
export function patternsOverlap(a: string, b: string, sep: string): boolean {
const sa = splitPattern(a, sep)
const sb = splitPattern(b, sep)
return sa.length === sb.length && sa.every((s, i) => s === '*' || sb[i] === '*' || s === sb[i])
}
@@ -0,0 +1,117 @@
import type { Doc } from '../utils/document-path.js'
import { DocumentQuery, type Filter, type FindOptions, type FindResult } from '../utils/document-query.js'
import { DocumentUpdate, type DocChange, type UpdateSpec } from '../utils/document-update.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, KeyOf, Spec, ValueAt, ValueOf } from '../utils/schema.js'
import { Ulid } from '../utils/ulid.js'
/**
* Fields to index, per collection path pattern (`*` matches one segment), e.g.
* `{ users: ['email', 'age'], 'users/*\/posts': ['createdAt'] }`. Each implementation decides
* how (or whether) to use them; a native database maps them onto its own indexes.
*/
export type IndexDeclarations = Record<string, string[]>
export interface UpdateOptions {
/** Treat a missing document as `{}`. */
upsert?: boolean
}
/** Document type of a node's children. */
export type ChildDoc<S> = ValueOf<ChildOf<S, KeyOf<S>>>
/**
* Document storage. Every node is a document (its own) and a collection of child documents.
* Implementations provide three primitives over document paths; the user-facing API is
* defined here once.
*/
export abstract class BaseDocumentStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root, which has no own document). */
path: readonly string[] = []
protected readonly ids = new Ulid()
abstract readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]>
/**
* Maps the current state of one document to a change (`null` = no change), applies it and
* returns the new state. Implementations decide how this is made safe against concurrent writers.
*/
abstract mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined>
/** Children of `parent` matching the query, filtered, sorted and paged. */
abstract query(parent: readonly string[], query: DocumentQuery): AsyncIterable<FindResult>
child<K extends KeyOf<S>>(key: K): BaseDocumentStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseDocumentStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)]
return node
}
as<S2 extends Spec>(): BaseDocumentStorage<S2> {
return this as unknown as BaseDocumentStorage<S2>
}
async get<K extends KeyOf<S> | undefined = undefined>(key?: K): Promise<ValueAt<S, K> | undefined> {
const [doc] = await this.readMany([this.pathOf(arguments.length > 0, key)])
return doc as ValueAt<S, K> | undefined
}
async getMany<K extends KeyOf<S>>(keys: readonly K[]): Promise<(ValueOf<ChildOf<S, K>> | undefined)[]> {
return (await this.readMany(keys.map((k) => this.pathOf(true, k)))) as (ValueOf<ChildOf<S, K>> | undefined)[]
}
/** `set(doc)` replaces this document; `set(key, doc)` a child document. */
async set<K extends KeyOf<S>>(...args: [doc: ValueOf<S>] | [key: K, doc: ValueOf<ChildOf<S, K>>]): Promise<void> {
const doc = args[args.length - 1]
if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) throw new TypeError('A document must be a plain object')
await this.mutate(this.pathOf(args.length > 1, args[0]), () => ({ op: 'set', doc: doc as Doc }))
}
/** Adds a child document under a new time-sortable id and returns the id. */
async insert(doc: ChildDoc<S>): Promise<string> {
const id = this.ids.generate()
await this.set(id as KeyOf<S>, doc as never)
return id
}
/**
* `update(spec, options?)` this document; `update(key, spec, options?)` a child.
* Returns the new state, or `undefined` if the document doesn't exist (unless `upsert`).
*/
async update<K extends KeyOf<S>>(
...args: [spec: UpdateSpec, options?: UpdateOptions] | [key: K, spec: UpdateSpec, options?: UpdateOptions]
): Promise<ValueAt<S, K> | undefined> {
const hasKey = typeof args[0] !== 'object' || args[0] === null
const spec = args[hasKey ? 1 : 0] as UpdateSpec
const options = (args[hasKey ? 2 : 1] ?? {}) as UpdateOptions
const next = await this.mutate(this.pathOf(hasKey, args[0]), (current) => (current || options.upsert ? DocumentUpdate.resolve(current ?? {}, spec) : null))
return next as ValueAt<S, K> | undefined
}
/** Deletes this document (or a child). Returns whether one existed. */
async delete(key?: KeyOf<S>): Promise<boolean> {
let existed = false
await this.mutate(this.pathOf(arguments.length > 0, key), (current) => ((existed = current !== undefined) ? { op: 'delete' } : null))
return existed
}
/** Child documents matching `filter`. */
find(filter: Filter = {}, options: FindOptions = {}): AsyncIterable<FindResult<ChildDoc<S>>> {
return this.query(this.path, new DocumentQuery(filter, options)) as AsyncIterable<FindResult<ChildDoc<S>>>
}
async findOne(filter: Filter = {}, options: FindOptions = {}): Promise<FindResult<ChildDoc<S>> | undefined> {
for await (const result of this.find(filter, { ...options, limit: 1 })) return result
return undefined
}
/** Keys of child documents. */
async *keys(): AsyncGenerator<string> {
for await (const { key } of this.find()) yield key
}
/** Path of a child (`hasKey`) or this node; validates keys. */
protected pathOf(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 has no own document')
return this.path
}
}
@@ -0,0 +1,112 @@
import type { Doc } from '../utils/document-path.js'
import { DocumentPath } from '../utils/document-path.js'
import type { DocumentQuery, FindResult } from '../utils/document-query.js'
import { DocumentUpdate, type DocChange } from '../utils/document-update.js'
import { Json } from '../utils/json.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseDocumentStorage, type IndexDeclarations } from './base-document-storage.js'
export interface DocumentStorageInMemoryOptions {
/** Fields with an equality index, per collection pattern. */
indexes?: IndexDeclarations
}
/** Field → JSON-encoded value → child keys. */
type CollectionIndex = Map<string, Map<string, Set<string>>>
/**
* In-memory document storage. Declared index fields get an equality index per collection, used
* when a filter has `{ field: value }` or `{ field: { $eq: value } }`; everything else is a scan.
*/
export class DocumentStorageInMemory<S extends Spec = AnySpec> extends BaseDocumentStorage<S> {
private readonly collections = new Map<string, Map<string, Doc>>()
private readonly indexes = new Map<string, CollectionIndex>()
private readonly declarations: [pattern: string[], fields: string[]][]
constructor(options: DocumentStorageInMemoryOptions = {}) {
super()
this.declarations = Object.entries(options.indexes ?? {}).map(([pattern, fields]) => [pattern === '' ? [] : pattern.split('/'), fields])
}
async readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
return paths.map((path) => Json.clone(this.collection(path.slice(0, -1)).get(path.at(-1)!)))
}
async mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
const parent = path.slice(0, -1)
const key = path.at(-1)!
const docs = this.collection(parent)
const current = docs.get(key)
const resolved = change(Json.clone(current))
if (!resolved) return Json.clone(current)
const next = DocumentUpdate.apply(current, resolved)
if (next) docs.set(key, next)
else docs.delete(key)
this.reindex(parent, key, current, next)
return Json.clone(next)
}
async *query(parent: readonly string[], query: DocumentQuery): AsyncGenerator<FindResult> {
const docs = this.collection(parent)
const keys = [...(this.lookup(parent, query) ?? docs.keys())].sort()
yield* query.run(
(async function* () {
for (const key of keys) {
const doc = docs.get(key)
if (doc) yield { key, data: Json.clone(doc) }
}
})(),
)
}
private collection(parent: readonly string[]): Map<string, Doc> {
const id = Keys.join(parent)
let docs = this.collections.get(id)
if (!docs) this.collections.set(id, (docs = new Map()))
return docs
}
/** Candidate keys from an equality index, if the filter allows one. */
private lookup(parent: readonly string[], query: DocumentQuery): Iterable<string> | undefined {
const index = this.indexes.get(Keys.join(parent))
for (const field of this.fieldsFor(parent)) {
let value = query.filter[field]
if (Json.isObject(value) && Object.keys(value).length === 1 && '$eq' in value) value = value.$eq
if (value === undefined || typeof value === 'object') continue
return index?.get(field)?.get(JSON.stringify(value)) ?? []
}
return undefined
}
private reindex(parent: readonly string[], key: string, before: Doc | undefined, after: Doc | undefined): void {
const fields = this.fieldsFor(parent)
if (fields.length === 0) return
const id = Keys.join(parent)
let index = this.indexes.get(id)
if (!index) this.indexes.set(id, (index = new Map()))
for (const field of fields) {
let byValue = index.get(field)
if (!byValue) index.set(field, (byValue = new Map()))
for (const value of DocumentStorageInMemory.indexValues(before, field)) byValue.get(value)?.delete(key)
for (const value of DocumentStorageInMemory.indexValues(after, field)) {
let keys = byValue.get(value)
if (!keys) byValue.set(value, (keys = new Set()))
keys.add(key)
}
}
}
private fieldsFor(parent: readonly string[]): string[] {
return this.declarations
.filter(([pattern]) => pattern.length === parent.length && pattern.every((s, i) => s === '*' || s === parent[i]))
.flatMap(([, fields]) => fields)
}
/** Index keys for a field: its value, plus each element of an array (missing indexes as `null`). */
private static indexValues(doc: Doc | undefined, field: string): string[] {
if (!doc) return []
const value = DocumentPath.get(doc, field) ?? null
return Array.isArray(value) ? value.map((v) => JSON.stringify(v)) : [JSON.stringify(value)]
}
}
@@ -0,0 +1,46 @@
import type { BaseKVStorage } from '../kv-storage/base-kv-storage.js'
import type { Doc } from '../utils/document-path.js'
import type { DocumentQuery, FindResult } from '../utils/document-query.js'
import { DocumentUpdate, type DocChange } from '../utils/document-update.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseDocumentStorage } from './base-document-storage.js'
/**
* Document storage on any KV storage: each document is the value at its path. Queries scan the
* collection's direct children (no indexes). Writes are read-modify-write, so concurrent writers
* to the same document are last-writer-wins; use `EventSourcedDocumentStorage` if that matters.
*/
export class DocumentStorageKV<S extends Spec = AnySpec> extends BaseDocumentStorage<S> {
constructor(private readonly kv: BaseKVStorage) {
super()
}
async readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
return (await this.kv.readMany(paths.map((p) => this.keyOf(p)))) as (Doc | undefined)[]
}
async mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
const key = this.keyOf(path)
const [current] = (await this.kv.readMany([key])) as (Doc | undefined)[]
const resolved = change(current)
if (!resolved) return current
const next = DocumentUpdate.apply(current, resolved)
if (next) await this.kv.writeMany([[key, next]])
else await this.kv.removeMany([key])
return next
}
async *query(parent: readonly string[], query: DocumentQuery): AsyncGenerator<FindResult> {
const collection = parent.reduce<BaseKVStorage>((node, segment) => node.child(segment), this.kv)
yield* query.run(
(async function* () {
for await (const [key, data] of collection.entries()) yield { key, data: data as Doc }
})(),
)
}
private keyOf(path: readonly string[]): string {
return this.kv.prefix + Keys.join(path)
}
}
@@ -0,0 +1,125 @@
import type { BaseEventStorage, StoredEvent } from '../event-storage/base-event-storage.js'
import type { Doc } from '../utils/document-path.js'
import type { DocumentQuery, FindResult } from '../utils/document-query.js'
import { DocumentUpdate, type DocChange } from '../utils/document-update.js'
import { ConcurrencyError } from '../utils/errors.js'
import type { AnySpec, ChildOf, KeyOf, Spec, ValueAt } from '../utils/schema.js'
import { BaseDocumentStorage } from './base-document-storage.js'
export interface EventSourcedDocumentStorageOptions {
/** Retries after a write conflict, each preceded by a catch-up. Default 5. */
maxRetries?: number
}
// Types only: children of an event-sourced store are event-sourced stores too (child() creates
// them with Object.create, so they share this class's prototype).
export interface EventSourcedDocumentStorage<S extends Spec = AnySpec> {
child<K extends KeyOf<S>>(key: K): EventSourcedDocumentStorage<ChildOf<S, K>>
}
/**
* Documents as event streams: each document's changes are appended to its own stream, and the
* current state is kept in a projection store (any `BaseDocumentStorage`), which serves all
* reads and queries.
*
* Projections carry a `_version` field (stripped from results) used as `expectedVersion` on the
* next append. If a write crashes between appending and updating the projection, the next
* write's append conflicts, the projection is rebuilt from the stream, and the write retries.
* `_version` is therefore reserved in documents.
*/
export class EventSourcedDocumentStorage<S extends Spec = AnySpec> extends BaseDocumentStorage<S> {
private readonly maxRetries: number
/** Tail of the pending writes per document, so writes in one process don't conflict with each other. */
private readonly locks = new Map<string, Promise<unknown>>()
constructor(
private readonly events: BaseEventStorage,
private readonly projections: BaseDocumentStorage,
options: EventSourcedDocumentStorageOptions = {},
) {
super()
this.maxRetries = options.maxRetries ?? 5
}
async readMany(paths: (readonly string[])[]): Promise<(Doc | undefined)[]> {
return (await this.projections.readMany(paths)).map((doc) => EventSourcedDocumentStorage.strip(doc))
}
async *query(parent: readonly string[], query: DocumentQuery): AsyncGenerator<FindResult> {
for await (const { key, data } of this.projections.query(parent, query)) yield { key, data: EventSourcedDocumentStorage.strip(data)! }
}
mutate(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
const id = JSON.stringify(path)
const result = (this.locks.get(id) ?? Promise.resolve()).then(() => this.write(path, change))
const tail = result.catch(() => {})
this.locks.set(id, tail)
void tail.then(() => this.locks.get(id) === tail && this.locks.delete(id))
return result
}
private async write(path: readonly string[], change: (current: Doc | undefined) => DocChange | null): Promise<Doc | undefined> {
for (let attempt = 0; ; attempt++) {
const [stored] = await this.projections.readMany([path])
const version = (stored?._version as number | undefined) ?? (await this.events.versionOf(path))
if (!stored && version > 0) {
// No projection but the stream has history (deleted, or a crashed first write): rebuild.
if ((await this.rebuild(path)) !== undefined) continue
}
const current = EventSourcedDocumentStorage.strip(stored)
const resolved = change(current)
if (!resolved) return current
const next = DocumentUpdate.apply(current, resolved)
try {
await this.events.appendTo(path, [resolved], version)
} catch (error) {
if (!(error instanceof ConcurrencyError) || attempt >= this.maxRetries) throw error
await this.rebuild(path)
continue
}
await this.project(path, next, version + 1)
return next
}
}
/** Events of this document (or a child). */
history(key?: KeyOf<S>): AsyncIterable<StoredEvent<DocChange>> {
return this.events.readFrom(this.pathOf(arguments.length > 0, key), {}) as AsyncIterable<StoredEvent<DocChange>>
}
/** State as of `version`: `getAt(version)` for this document, `getAt(key, version)` for a child. */
async getAt<K extends KeyOf<S>>(...args: [version: number] | [key: K, version: number]): Promise<ValueAt<S, K> | undefined> {
const path = this.pathOf(args.length > 1, args[0])
let state: Doc | undefined
for await (const event of this.events.readFrom(path, { to: args[args.length - 1] as number })) {
state = DocumentUpdate.apply(state, event.data as DocChange)
}
return state as ValueAt<S, K> | undefined
}
/** Rebuilds the projection of this document (or a child) from its stream. Returns the state. */
catchUp(key?: KeyOf<S>): Promise<Doc | undefined> {
return this.rebuild(this.pathOf(arguments.length > 0, key))
}
private async rebuild(path: readonly string[]): Promise<Doc | undefined> {
let state: Doc | undefined
let version = 0
for await (const event of this.events.readFrom(path, {})) {
state = DocumentUpdate.apply(state, event.data as DocChange)
version = event.version
}
await this.project(path, state, version)
return state
}
private async project(path: readonly string[], state: Doc | undefined, version: number): Promise<void> {
await this.projections.mutate(path, () => (state ? { op: 'set', doc: { ...state, _version: version } } : { op: 'delete' }))
}
private static strip(doc: Doc | undefined): Doc | undefined {
if (!doc) return undefined
const { _version, ...state } = doc
return state
}
}
-606
View File
@@ -1,606 +0,0 @@
import type { IndexValue } from '../core/encoding.js'
import { escapeSegment, RESERVED, succ, toSegment, unescapeSegment } from '../core/path.js'
import type { TrieState } from '../core/trie.js'
import { CapabilityError, ConcurrencyError, UniqueConstraintError } from '../errors.js'
import { KeyedMutex } from '../core/mutex.js'
import { KVEventNode } from '../events/kv.js'
import type { EventNode, ReadOptions, StoredEvent } from '../events/types.js'
import { scanChildren } from '../kv/children.js'
import type { BatchOp, KVDriver } from '../kv/driver.js'
import type { KVNode } from '../kv/node.js'
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueOf } from '../types/schema.js'
import { analyze, matches, type Filter } from './filter.js'
import { DocIndexes, DocIndexRegistry, type DocIndexDeclarations, type Plan } from './indexes.js'
import type { ChildDoc, DocKeysOptions, EventSourcedDocumentNode, FindOptions, FindResult, GetOptions, UpdateOptions } from './types.js'
import { ulid } from './ulid.js'
import { applyEvent, applyPatch, resolveUpdate, toPatchEvent, type DocEvent, type UpdateSpec } from './update.js'
import { clone, compareValues, deepEqual, getPath, isPlainObject, setPath, sortKey, unsetPath, type Doc } from './values.js'
export interface EventSourcedDocumentStorageOptions {
/** Where projections live. Default: the `~p` root of the event store's KV. Required for non-KV event stores. */
projections?: KVNode<AnySpec>
/** Path pattern → indexes over that node's direct children. */
indexes?: DocIndexDeclarations
/** Delete stale index entries found by `find`. Default `true`. */
readRepair?: boolean
/** Retries after a write conflict (each preceded by a catch-up). Default 5. */
maxRetries?: number
/** Warn once per filter shape when `find` falls back to a full scan. Default: `NODE_ENV !== 'production'`. */
warnOnFullScan?: boolean
/** Id generator for `insert`. Default: ULID-like. */
newId?: () => string
/**
* A unique claim whose owner no longer holds the value is only taken over once it is older
* than this, since its owner may be mid-write. Default 30 000 ms.
*/
claimGraceMs?: number
/** Clock for claim timestamps. Default `Date.now`. */
now?: () => number
}
/** Stored projection of one document. */
export type Projection =
{ state: Doc; version: number; commit: number; deleted?: undefined } | { deleted: true; version: number; commit: number; state?: undefined }
interface DocContext {
readonly driver: KVDriver
readonly sep: string
/** Projection-space prefix: projection key of a doc = `projBase + docSelfKey`. */
readonly projBase: string
readonly registry: DocIndexRegistry
readonly readRepair: boolean
readonly maxRetries: number
readonly warnOnFullScan: boolean
readonly warned: Set<string>
readonly newId: () => string
readonly claimGraceMs: number
readonly now: () => number
/** Serialises writes to one document within this process, so retries only arise across processes. */
readonly writeLock: KeyedMutex
}
/** Stored value of a unique claim: owner child segment (escaped) and claim time. */
type Claim = [owner: string, at: number]
interface Target {
ev: EventNode<AnySpec>
parentEv: EventNode<AnySpec>
esc: string
/** Projection key of the document. */
projKey: string
/** Projection-space prefix of its parent (sibling projection key = `parentProj + esc`). */
parentProj: string
idx: DocIndexes | undefined
}
type Hit = { esc: string; state: Doc }
const FIND_CHUNK = 128
/**
* Document node backed by an event stream per document and a projection per document.
* Writes: read projection → resolve delta → append (expectedVersion = projection.version) →
* write projection + index entries. Reads hit the projection only.
*/
export class EventSourcedDocumentNodeImpl<S extends Spec = AnySpec> implements EventSourcedDocumentNode<S> {
readonly path: readonly string[]
/** Document-space key (escaped), relative to the store root. */
readonly selfKey: string
readonly #ctx: DocContext
readonly #ev: EventNode<AnySpec>
readonly #parentEv: EventNode<AnySpec> | undefined
readonly #esc: string
readonly #prefix: string
readonly #state: TrieState<any>
readonly #selfIdx: DocIndexes | undefined
readonly #childIdx: DocIndexes | undefined
/** @internal Use `EventSourcedDocumentStorage` and `child()`. */
constructor(
ctx: DocContext,
ev: EventNode<AnySpec>,
parentEv: EventNode<AnySpec> | undefined,
path: readonly string[],
selfKey: string,
esc: string,
state: TrieState<any>,
selfIdx: DocIndexes | undefined,
) {
this.#ctx = ctx
this.#ev = ev
this.#parentEv = parentEv
this.path = path
this.selfKey = selfKey
this.#esc = esc
this.#prefix = path.length === 0 ? '' : selfKey + ctx.sep
this.#state = state
this.#selfIdx = selfIdx
this.#childIdx = ctx.registry.bind(state, ctx.projBase + this.#prefix)
}
child<K extends KeyOf<S>>(key: K): EventSourcedDocumentNodeImpl<ChildOf<S, K>> {
const seg = toSegment(key)
const esc = escapeSegment(seg, this.#ctx.sep)
return new EventSourcedDocumentNodeImpl(
this.#ctx,
this.#ev.child(seg),
this.#ev,
[...this.path, seg],
this.#prefix + esc,
esc,
this.#ctx.registry.trie.step(this.#state, seg),
this.#childIdx,
)
}
as<S2 extends Spec>(): EventSourcedDocumentNodeImpl<S2> {
return this as unknown as EventSourcedDocumentNodeImpl<S2>
}
// ---------------------------------------------------------------- reads
get(opts?: GetOptions): Promise<ValueOf<S> | undefined>
get<K extends KeyOf<S>>(key: K, opts?: GetOptions): Promise<ValueOf<ChildOf<S, K>> | undefined>
async get(...args: unknown[]): Promise<unknown> {
const [t, rest] = this.#split(args)
const opts = (rest[0] ?? {}) as GetOptions
let proj = await this.#projection(t)
if (opts.consistent && (await t.ev.version()) > (proj?.version ?? 0)) {
await this.#catchUpDoc(t)
proj = await this.#projection(t)
}
return proj?.state
}
history(): AsyncIterable<StoredEvent<DocEvent>>
history<K extends KeyOf<S>>(key: K): AsyncIterable<StoredEvent<DocEvent>>
history(...args: unknown[]): AsyncIterable<StoredEvent<DocEvent>> {
const t = args.length === 0 ? this.#self() : this.#child(args[0])
return t.ev.read() as AsyncIterable<StoredEvent<DocEvent>>
}
getAt(version: number): Promise<ValueOf<S> | undefined>
getAt<K extends KeyOf<S>>(key: K, version: number): Promise<ValueOf<ChildOf<S, K>> | undefined>
async getAt(...args: unknown[]): Promise<unknown> {
const t = args.length <= 1 ? this.#self() : this.#child(args[0])
const version = args[args.length - 1] as number
if (!Number.isInteger(version) || version < 0) throw new TypeError(`Invalid version ${String(version)}`)
let state: Doc | undefined
for await (const e of t.ev.read({ to: version } satisfies ReadOptions)) state = applyEvent(state, e.data as DocEvent)
return state
}
async *find(filter: Filter = {}, opts: FindOptions = {}): AsyncGenerator<FindResult<ChildDoc<S>>> {
const skip = opts.skip ?? 0
const limit = opts.limit ?? Infinity
if (limit <= 0) return
const sort = Object.entries(opts.sort ?? {}) as [string, 1 | -1][]
const plan = this.#childIdx?.plan(analyze(filter), sort)
if (!plan && Object.keys(filter).length > 0) this.#warnFullScan(filter)
const source = plan ? this.#indexScan(plan, skip + limit) : this.#projectionScan()
const project = opts.projection ? projector(opts.projection) : (d: Doc) => d
if (sort.length && !plan?.sorted) {
// In-memory sort, bounded to skip + limit when a limit is given.
const cap = skip + limit
const cmp = comparator(sort)
const top: Hit[] = []
for await (const hit of source) {
if (!matches(hit.state, filter)) continue
if (top.length >= cap && cmp(hit, top[top.length - 1]!) >= 0) continue
let lo = 0
let hi = top.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (cmp(top[mid]!, hit) <= 0) lo = mid + 1
else hi = mid
}
top.splice(lo, 0, hit)
if (top.length > cap) top.pop()
}
for (let i = skip; i < top.length; i++) yield { key: unescapeSegment(top[i]!.esc), data: project(top[i]!.state) as ChildDoc<S> }
return
}
let skipped = 0
let emitted = 0
for await (const hit of source) {
if (!matches(hit.state, filter)) continue
if (skipped < skip) {
skipped++
continue
}
yield { key: unescapeSegment(hit.esc), data: project(hit.state) as ChildDoc<S> }
if (++emitted >= limit) return
}
}
async findOne(filter: Filter = {}, opts: FindOptions = {}): Promise<FindResult<ChildDoc<S>> | undefined> {
for await (const r of this.find(filter, { ...opts, limit: 1 })) return r
return undefined
}
async *keys(opts: DocKeysOptions = {}): AsyncGenerator<KeyString<S>> {
let remaining = opts.limit ?? Infinity
if (remaining <= 0) return
const after = opts.after === undefined ? undefined : toSegment(opts.after)
const { prefix, end } = this.#childRange()
for await (const hit of scanChildren(this.#ctx.driver, prefix, end, this.#ctx.sep, { after, reverse: opts.reverse, values: true })) {
if (!hit.hasOwn || (hit.value as Projection).deleted) continue
yield unescapeSegment(hit.esc) as KeyString<S>
if (--remaining <= 0) return
}
}
// ---------------------------------------------------------------- writes
set(doc: ValueOf<S>): Promise<void>
set<K extends KeyOf<S>>(key: K, doc: ValueOf<ChildOf<S, K>>): Promise<void>
async set(...args: unknown[]): Promise<void> {
const [t, rest] = this.#split(args)
const doc = rest[0]
if (!isPlainObject(doc)) throw new TypeError('A document must be a plain object')
const snapshot = clone(doc)
await this.#write(t, () => ({ op: 'set', doc: snapshot }))
}
async insert(doc: ChildDoc<S>): Promise<string> {
if (!isPlainObject(doc)) throw new TypeError('A document must be a plain object')
const id = this.#ctx.newId()
const t = this.#child(id)
const snapshot = clone(doc)
await this.#write(t, (cur) => {
if (cur) throw new Error(`Document "${id}" already exists`)
return { op: 'set', doc: snapshot }
})
return id
}
update(spec: UpdateSpec, opts?: UpdateOptions): Promise<ValueOf<S> | undefined>
update<K extends KeyOf<S>>(key: K, spec: UpdateSpec, opts?: UpdateOptions): Promise<ValueOf<ChildOf<S, K>> | undefined>
async update(...args: unknown[]): Promise<unknown> {
const [t, rest] = this.#split(args)
const spec = rest[0] as UpdateSpec
const opts = (rest[1] ?? {}) as UpdateOptions
if (!isPlainObject(spec)) throw new TypeError('update() expects an update spec')
const result = await this.#write(t, (cur) => {
if (!cur && !opts.upsert) return null
const base = cur ?? {}
const patch = resolveUpdate(base, spec)
if (cur && deepEqual(applyPatch(base, patch), cur)) return null // no-op: no event
return toPatchEvent(patch)
})
return result
}
delete(): Promise<boolean>
delete<K extends KeyOf<S>>(key: K): Promise<boolean>
async delete(...args: unknown[]): Promise<boolean> {
const t = args.length === 0 ? this.#self() : this.#child(args[0])
let deleted = false
await this.#write(t, (cur) => {
deleted = cur !== undefined
return cur ? { op: 'delete' } : null
})
return deleted
}
// ---------------------------------------------------------------- maintenance
async catchUp(): Promise<number> {
let repaired = 0
if (this.path.length > 0 && (await this.#catchUpDoc(this.#self()))) repaired++
for await (const key of this.#ev.keys()) repaired += await this.child(key as KeyOf<S>).catchUp()
return repaired
}
// ---------------------------------------------------------------- internals
#self(): Target {
if (this.path.length === 0) throw new TypeError('The root node has no own document')
const parentProj = this.#ctx.projBase + this.selfKey.slice(0, this.selfKey.length - this.#esc.length)
return { ev: this.#ev, parentEv: this.#parentEv!, esc: this.#esc, projKey: this.#ctx.projBase + this.selfKey, parentProj, idx: this.#selfIdx }
}
#child(key: unknown): Target {
const seg = toSegment(key)
const esc = escapeSegment(seg, this.#ctx.sep)
const parentProj = this.#ctx.projBase + this.#prefix
return { ev: this.#ev.child(seg), parentEv: this.#ev, esc, projKey: parentProj + esc, parentProj, idx: this.#childIdx }
}
/** Target for a sibling of `t` given its escaped segment. */
#sibling(t: Target, esc: string): Target {
return { ev: t.parentEv.child(unescapeSegment(esc)), parentEv: t.parentEv, esc, projKey: t.parentProj + esc, parentProj: t.parentProj, idx: t.idx }
}
/** Self form when there are no args or the first is a non-null object; otherwise key form. */
#split(args: unknown[]): [Target, unknown[]] {
if (args.length === 0 || (typeof args[0] === 'object' && args[0] !== null)) return [this.#self(), args]
return [this.#child(args[0]), args.slice(1)]
}
#childRange(): { prefix: string; end: string | undefined } {
const prefix = this.#ctx.projBase + this.#prefix
return { prefix, end: prefix === '' ? undefined : prefix.slice(0, -1) + succ(this.#ctx.sep) }
}
async #projection(t: Target): Promise<Projection | undefined> {
return (await this.#ctx.driver.getMany([t.projKey]))[0] as Projection | undefined
}
/**
* The write path. `compute` maps the current state to an event (or `null` for a no-op).
* Returns the resulting state.
*/
#write(t: Target, compute: (cur: Doc | undefined) => DocEvent | null): Promise<Doc | undefined> {
return this.#ctx.writeLock.run(t.projKey, () => this.#writeUnlocked(t, compute))
}
async #writeUnlocked(t: Target, compute: (cur: Doc | undefined) => DocEvent | null): Promise<Doc | undefined> {
const { maxRetries } = this.#ctx
for (let attempt = 0; ; attempt++) {
const proj = await this.#projection(t)
const cur = proj?.state
const event = compute(cur)
if (!event) return cur
const next = applyEvent(cur, event)
const oldClaims = t.idx?.claimsFor(cur)
const newClaims = t.idx?.claimsFor(next)
const acquired: string[] = []
if (newClaims?.size) {
const wanted = new Map([...newClaims].filter(([k]) => !oldClaims!.has(k)))
if (wanted.size) await this.#claim(t, wanted, acquired)
}
let result
try {
result = await t.ev.append([event], { expectedVersion: proj?.version ?? 0, expectedCommit: proj?.commit ?? 0 })
} catch (err) {
if (acquired.length) await this.#ctx.driver.deleteMany(acquired)
if (err instanceof ConcurrencyError && attempt < maxRetries) {
await this.#catchUpDoc(t)
continue
}
throw err
}
const releases = oldClaims ? [...oldClaims.keys()].filter((k) => !newClaims!.has(k)) : []
const projection: Projection = next
? { state: next, version: result.version, commit: result.commit }
: { deleted: true, version: result.version, commit: result.commit }
await this.#writeProjection(t, cur, next, projection, releases)
return next
}
}
/**
* Claims unique values for `t`; records successful claims in `acquired`. Throws
* `UniqueConstraintError` on conflict. A claim held by another document is taken over only if
* that document, caught up to its stream head, no longer holds the value and the claim is older
* than `claimGraceMs` (its owner might otherwise be between claiming and appending).
*/
async #claim(t: Target, claims: Map<string, { index: string; values: IndexValue[] }>, acquired: string[]): Promise<void> {
const { driver, now, claimGraceMs } = this.#ctx
const keys = [...claims.keys()]
const written = await driver.setIfAbsent!(keys.map((k) => [k, [t.esc, now()] satisfies Claim]))
const lost = keys.filter((k, i) => {
if (written[i]) acquired.push(k)
return !written[i]
})
if (!lost.length) return
const holders = (await driver.getMany(lost)) as (Claim | undefined)[]
const fail = async (k: string, owner?: string): Promise<never> => {
if (acquired.length) await driver.deleteMany(acquired.splice(0))
const c = claims.get(k)!
throw new UniqueConstraintError(c.index, c.values, owner === undefined ? undefined : unescapeSegment(owner))
}
for (let i = 0; i < lost.length; i++) {
const k = lost[i]!
const holder = holders[i]
if (holder?.[0] === t.esc) continue // already ours (e.g. left by an interrupted write)
if (holder) {
const owner = this.#sibling(t, holder[0])
await this.#catchUpDoc(owner)
const ownerProj = await this.#projection(owner)
if (t.idx!.claimsFor(ownerProj?.state).has(k)) await fail(k, holder[0])
if (now() - holder[1] < claimGraceMs) await fail(k, holder[0])
await driver.deleteMany([k])
}
const [ok] = await driver.setIfAbsent!([[k, [t.esc, now()] satisfies Claim]])
if (!ok) await fail(k)
acquired.push(k)
}
}
async #writeProjection(t: Target, cur: Doc | undefined, next: Doc | undefined, projection: Projection, releases: string[]): Promise<void> {
const { driver } = this.#ctx
if (!t.idx) {
await driver.setMany([[t.projKey, projection]])
return
}
const before = t.idx.entriesFor(t.esc, cur)
const after = t.idx.entriesFor(t.esc, next)
const adds = [...after].filter((e) => !before.has(e))
const removes = [...before].filter((e) => !after.has(e)).concat(releases)
if (driver.batch) {
const ops: BatchOp[] = adds.map((key) => ({ type: 'set', key, value: true }))
ops.push({ type: 'set', key: t.projKey, value: projection })
for (const key of removes) ops.push({ type: 'delete', key })
await driver.batch(ops)
return
}
if (adds.length) await driver.setMany(adds.map((k) => [k, true]))
await driver.setMany([[t.projKey, projection]])
if (removes.length) await driver.deleteMany(removes)
}
/** Replays events newer than the projection into it. Returns whether anything changed. */
async #catchUpDoc(t: Target): Promise<boolean> {
const proj = await this.#projection(t)
const cur = proj?.state
let state = cur
let version = proj?.version ?? 0
let commit = proj?.commit ?? 0
for await (const e of t.ev.read({ from: version + 1 })) {
state = applyEvent(state, e.data as DocEvent)
version = e.version
commit = e.commit
}
if (version === (proj?.version ?? 0)) return false
const oldClaims = t.idx?.claimsFor(cur)
const newClaims = t.idx?.claimsFor(state)
if (newClaims?.size) {
// The log is the source of truth: (re)assert claims for the caught-up state.
const missing = [...newClaims.keys()].filter((k) => !oldClaims!.has(k))
if (missing.length) await this.#ctx.driver.setMany(missing.map((k) => [k, [t.esc, this.#ctx.now()] satisfies Claim]))
}
const releases = oldClaims ? [...oldClaims.keys()].filter((k) => !newClaims!.has(k)) : []
const projection: Projection = state ? { state, version, commit } : { deleted: true, version, commit }
await this.#writeProjection(t, cur, state, projection, releases)
return true
}
async *#projectionScan(): AsyncGenerator<Hit> {
const { prefix, end } = this.#childRange()
for await (const hit of scanChildren(this.#ctx.driver, prefix, end, this.#ctx.sep, { values: true })) {
const p = hit.value as Projection | undefined
if (hit.hasOwn && p?.state) yield { esc: hit.esc, state: p.state }
}
}
async *#indexScan(plan: Plan, want: number): AsyncGenerator<Hit> {
const idx = this.#childIdx!
const { driver, readRepair } = this.#ctx
const parentProj = this.#ctx.projBase + this.#prefix
const seen = new Set<string>()
let chunk: string[] = []
let chunkSize = Math.min(FIND_CHUNK, Math.max(1, want))
const it = driver.scan({ gte: plan.gte, lt: plan.lt, reverse: plan.reverse })[Symbol.asyncIterator]()
async function* flush(): AsyncGenerator<Hit> {
const entries = chunk
chunk = []
chunkSize = FIND_CHUNK
const escs = entries.map((e) => idx.childOf(plan.index, e))
const projs = (await driver.getMany(escs.map((e) => parentProj + e))) as (Projection | undefined)[]
const stale: string[] = []
try {
for (let i = 0; i < entries.length; i++) {
const esc = escs[i]!
const state = projs[i]?.state
if (!idx.entriesFor(esc, state).has(entries[i]!)) {
stale.push(entries[i]!)
continue
}
if (seen.has(esc)) continue
seen.add(esc)
yield { esc, state: state! }
}
} finally {
if (readRepair && stale.length) await driver.deleteMany(stale)
}
}
try {
for (;;) {
const r = await it.next()
if (r.done) break
chunk.push(r.value[0])
if (chunk.length >= chunkSize) yield* flush()
}
if (chunk.length) yield* flush()
} finally {
await it.return?.()
}
}
#warnFullScan(filter: Filter): void {
if (!this.#ctx.warnOnFullScan) return
const shape = JSON.stringify(filterShape(filter))
if (this.#ctx.warned.has(shape)) return
this.#ctx.warned.add(shape)
console.warn(`[potential-storage] find() on "${this.selfKey || '(root)'}" has no usable index; full scan for filter ${shape}`)
}
}
/** Filter with values replaced by `?`, for de-duplicating warnings. */
function filterShape(f: unknown): unknown {
if (Array.isArray(f)) return f.map(filterShape)
if (isPlainObject(f))
return Object.fromEntries(
Object.entries(f).map(([k, v]) => [
k,
k.startsWith('$') && (k === '$and' || k === '$or' || k === '$not') ? filterShape(v) : isPlainObject(v) ? filterShape(v) : '?',
]),
)
return '?'
}
function comparator(sort: [string, 1 | -1][]): (a: Hit, b: Hit) => number {
const tieDir = sort[0]?.[1] ?? 1
return (a, b) => {
for (const [field, dir] of sort) {
const d = compareValues(sortKey(a.state, field, dir), sortKey(b.state, field, dir))
if (d !== 0) return d * dir
}
return a.esc === b.esc ? 0 : (a.esc < b.esc ? -1 : 1) * tieDir
}
}
function projector(spec: Record<string, 0 | 1 | boolean>): (d: Doc) => Doc {
const fields = Object.entries(spec)
const include = fields.some(([, v]) => v === 1 || v === true)
if (include && fields.some(([, v]) => v === 0 || v === false)) throw new TypeError('Projection cannot mix inclusion and exclusion')
return (d) => {
if (include) {
const out: Doc = {}
for (const [f] of fields) {
const v = getPath(d, f)
if (v !== undefined) setPath(out, f, clone(v))
}
return out
}
const out = clone(d)
for (const [f] of fields) unsetPath(out, f)
return out
}
}
/** Event-sourced document storage: the root document node. */
export class EventSourcedDocumentStorage<S extends Spec = AnySpec> extends EventSourcedDocumentNodeImpl<S> {
constructor(events: EventNode<AnySpec>, opts: EventSourcedDocumentStorageOptions = {}) {
let driver: KVDriver
let sep: string
let projBase: string
if (opts.projections) {
driver = opts.projections.driver
sep = opts.projections.separator
projBase = opts.projections.prefix
} else if (events instanceof KVEventNode) {
const kv = events.kv
driver = kv.driver
sep = kv.separator
projBase = kv.prefix + RESERVED.projections + sep
} else {
throw new TypeError('EventSourcedDocumentStorage needs `projections` when the event store is not KV-backed')
}
const registry = new DocIndexRegistry(opts.indexes ?? {}, sep)
if (registry.hasUnique && !driver.setIfAbsent) throw new CapabilityError('setIfAbsent', 'unique document indexes require it')
const ctx: DocContext = {
driver,
sep,
projBase,
registry,
readRepair: opts.readRepair ?? true,
maxRetries: opts.maxRetries ?? 5,
warnOnFullScan: opts.warnOnFullScan ?? (typeof process === 'undefined' || process.env?.NODE_ENV !== 'production'),
warned: new Set(),
newId: opts.newId ?? ulid,
claimGraceMs: opts.claimGraceMs ?? 30_000,
now: opts.now ?? Date.now,
writeLock: new KeyedMutex(),
}
super(ctx, events, undefined, [], '', '', registry.trie.rootState, undefined)
}
}
-155
View File
@@ -1,155 +0,0 @@
import type { IndexValue } from '../core/encoding.js'
import { compareValues, deepEqual, isPlainObject, isScalar, resolvePath, typeRank, type Doc } from './values.js'
/**
* Query filter (Mongo subset): field conditions with implicit equality and dot paths,
* `$eq $ne $gt $gte $lt $lte $in $nin $exists`, and `$and $or $not`.
*
* Semantics differences from MongoDB (see DESIGN.md §12, M6):
* - Comparisons are type-bracketed: `$gt: 5` only matches numbers.
* - On array fields, all positive operators in one condition (`$eq $gt $gte $lt $lte $in`) must be
* satisfied by the same element (like `$elemMatch`), so index range scans are exact.
*/
export type Filter = { [field: string]: unknown; $and?: Filter[]; $or?: Filter[]; $not?: Filter }
const POSITIVE = new Set(['$eq', '$gt', '$gte', '$lt', '$lte', '$in'])
const FIELD_LEVEL = new Set(['$ne', '$nin', '$exists', '$not'])
export function isOperatorObject(v: unknown): v is Record<string, unknown> {
if (!isPlainObject(v)) return false
const keys = Object.keys(v)
return keys.length > 0 && keys.every((k) => k.startsWith('$'))
}
export function matches(doc: unknown, filter: Filter): boolean {
for (const key of Object.keys(filter)) {
const cond = filter[key]
switch (key) {
case '$and':
if (!(cond as Filter[]).every((f) => matches(doc, f))) return false
break
case '$or':
if (!(cond as Filter[]).some((f) => matches(doc, f))) return false
break
case '$not':
if (matches(doc, cond as Filter)) return false
break
default:
if (key.startsWith('$')) throw new TypeError(`Unknown top-level operator ${key}`)
if (!matchesField(resolvePath(doc, key), cond)) return false
}
}
return true
}
/** Whether the values found at a path satisfy a field condition. */
export function matchesField(values: unknown[], cond: unknown): boolean {
if (!isOperatorObject(cond)) return someCandidate(values, (c) => eq(c, cond))
const positive: [string, unknown][] = []
for (const [op, arg] of Object.entries(cond)) {
if (POSITIVE.has(op)) positive.push([op, arg])
else if (!FIELD_LEVEL.has(op)) throw new TypeError(`Unknown operator ${op}`)
}
for (const [op, arg] of Object.entries(cond)) {
switch (op) {
case '$ne':
if (someCandidate(values, (c) => eq(c, arg))) return false
break
case '$nin':
if (someCandidate(values, (c) => (arg as unknown[]).some((x) => eq(c, x)))) return false
break
case '$exists':
if (values.length > 0 !== Boolean(arg)) return false
break
case '$not':
if (matchesField(values, arg)) return false
break
}
}
if (positive.length === 0) return true
return someCandidate(values, (c) => positive.every(([op, arg]) => applyPositive(op, c, arg)))
}
/** Tests each value and, for arrays, each element. Missing paths test `undefined` once. */
function someCandidate(values: unknown[], test: (c: unknown) => boolean): boolean {
if (values.length === 0) return test(undefined)
for (const v of values) {
if (test(v)) return true
if (Array.isArray(v) && v.some(test)) return true
}
return false
}
function eq(c: unknown, x: unknown): boolean {
if (x === null) return c === null || c === undefined
return deepEqual(c, x)
}
function applyPositive(op: string, c: unknown, arg: unknown): boolean {
switch (op) {
case '$eq':
return eq(c, arg)
case '$in':
if (!Array.isArray(arg)) throw new TypeError('$in expects an array')
return arg.some((x) => eq(c, x))
default: {
const rc = typeRank(c)
if (rc !== typeRank(arg) || rc === 4) return false
const d = compareValues(c, arg)
return op === '$gt' ? d > 0 : op === '$gte' ? d >= 0 : op === '$lt' ? d < 0 : d <= 0
}
}
}
// ------------------------------------------------------------------ planner analysis
export interface RangePred {
gt?: IndexValue
gte?: IndexValue
lt?: IndexValue
lte?: IndexValue
}
export interface FieldPred {
eq?: IndexValue
range?: RangePred
}
/**
* Index-usable predicates per field, from the top level and top-level `$and`.
* Only scalar `$eq`/implicit equality and same-typed scalar range bounds qualify.
*/
export function analyze(filter: Filter, out = new Map<string, FieldPred>()): Map<string, FieldPred> {
for (const key of Object.keys(filter)) {
const cond = filter[key]
if (key === '$and') {
for (const f of cond as Filter[]) analyze(f, out)
continue
}
if (key.startsWith('$')) continue
const pred = out.get(key) ?? {}
if (!isOperatorObject(cond)) {
if (isScalar(cond)) pred.eq ??= cond
} else {
if ('$eq' in cond && isScalar(cond.$eq)) pred.eq ??= cond.$eq
const range: RangePred = {}
let rank = -1
let ok = false
for (const op of ['$gt', '$gte', '$lt', '$lte'] as const) {
if (!(op in cond)) continue
const v = cond[op]
if (!isScalar(v)) continue
const r = typeRank(v)
if (rank !== -1 && r !== rank) continue
rank = r
range[op.slice(1) as keyof RangePred] = v
ok = true
}
if (ok && !pred.range) pred.range = range
}
if (pred.eq !== undefined || pred.range) out.set(key, pred)
}
return out
}
export type { Doc }
-7
View File
@@ -1,7 +0,0 @@
export * from './types.js'
export * from './event-sourced.js'
export * from './filter.js'
export * from './update.js'
export * from './indexes.js'
export * from './ulid.js'
export { compareValues, deepEqual, type Doc } from './values.js'
-220
View File
@@ -1,220 +0,0 @@
import { encodeIndexValues, indexValueCodec, type IndexValue } from '../core/encoding.js'
import { escapeSegment, RESERVED, succ } from '../core/path.js'
import { PatternTrie, patternsOverlap, type TrieState } from '../core/trie.js'
import type { FieldPred, RangePred } from './filter.js'
import { indexScalars, resolvePath, typeRank, type Doc } from './values.js'
export interface DocIndexSpec {
/** Default: the fields joined with `,`. */
name?: string
fields: string[]
/** Claims `~u/<name>/<values>` per value tuple. Documents where every field is missing/null are not claimed. */
unique?: boolean
}
/** Path pattern (`*` = one segment, `''` = root) → indexes over that node's direct children. */
export type DocIndexDeclarations = Record<string, DocIndexSpec[]>
export interface SortSpec {
[field: string]: 1 | -1
}
interface CompiledDocIndex {
name: string
fields: string[]
segs: string[][]
unique: boolean
/** `escapeSegment(name) + sep` */
head: string
}
export interface Plan {
index: string
gte: string
lt: string
reverse: boolean
/** Index order satisfies the requested sort. */
sorted: boolean
}
/** Document indexes over the children of one node. */
export class DocIndexes {
readonly #list: CompiledDocIndex[]
readonly #byName: Map<string, CompiledDocIndex>
readonly #enc: (v: IndexValue) => string
constructor(
list: CompiledDocIndex[],
/** `<projBase><nodePrefix>~i<sep>` */
readonly base: string,
/** `<projBase><nodePrefix>~u<sep>` */
readonly uniqueBase: string,
readonly sep: string,
) {
this.#list = list
this.#byName = new Map(list.map((i) => [i.name, i]))
const codec = indexValueCodec(sep)
this.#enc = (v) => codec.encode(v)
}
get hasUnique(): boolean {
return this.#list.some((i) => i.unique)
}
/** Value tuples a state produces for one index (cartesian over multikey fields). */
#tuples(idx: CompiledDocIndex, state: Doc): IndexValue[][] {
let tuples: IndexValue[][] = [[]]
let multi = 0
for (const segs of idx.segs) {
const found = resolvePath(state, segs)
const scalars =
found.length === 0 ? [null] : found.length === 1 && !Array.isArray(found[0]) ? indexScalars(found[0]) : [...new Set(found.flatMap(indexScalars))]
if (tuples.length === 1 && scalars.length === 1) {
tuples[0]!.push(scalars[0]!)
continue
}
if (scalars.length > 1 && ++multi > 1) throw new TypeError(`Index "${idx.name}": cannot index more than one array field per document`)
tuples = tuples.flatMap((t) => scalars.map((s) => [...t, s]))
}
return tuples
}
entriesFor(escChild: string, state: Doc | undefined, out: Set<string> = new Set()): Set<string> {
if (!state) return out
for (const idx of this.#list) {
const stem = this.base + idx.head
for (const t of this.#tuples(idx, state)) out.add(stem + encodeIndexValues(t, this.sep) + this.sep + escChild)
}
return out
}
/** Unique claim key → index name and values. */
claimsFor(state: Doc | undefined): Map<string, { index: string; values: IndexValue[] }> {
const out = new Map<string, { index: string; values: IndexValue[] }>()
if (!state) return out
for (const idx of this.#list) {
if (!idx.unique) continue
for (const t of this.#tuples(idx, state)) {
if (t.every((v) => v === null)) continue
out.set(this.uniqueBase + idx.head + encodeIndexValues(t, this.sep), { index: idx.name, values: t })
}
}
return out
}
/** Escaped child segment of an entry key of index `name`. */
childOf(name: string, entryKey: string): string {
const idx = this.#byName.get(name)!
let pos = this.base.length + idx.head.length
for (let i = 0; i < idx.fields.length; i++) pos = entryKey.indexOf(this.sep, pos) + 1
return entryKey.slice(pos)
}
/**
* Chooses the index covering the most leading equality fields, then at most one range field.
* Ties prefer an index whose order satisfies `sort`. With no usable predicate, an index whose
* order satisfies `sort` is still used (a full, ordered index scan). Returns `undefined` for a
* full projection scan.
*/
plan(preds: Map<string, FieldPred>, sort: [string, 1 | -1][]): Plan | undefined {
let best: { idx: CompiledDocIndex; k: number; range: RangePred | undefined; sorted: boolean; dir: 1 | -1; score: number } | undefined
for (const idx of this.#list) {
let k = 0
while (k < idx.fields.length && preds.get(idx.fields[k]!)?.eq !== undefined) k++
const range = k < idx.fields.length ? preds.get(idx.fields[k]!)?.range : undefined
// Sort fields constrained by equality are constant; drop them before matching.
const eqFields = new Set(idx.fields.slice(0, k))
const rest = sort.filter(([f]) => !eqFields.has(f))
// Ties are broken by child key in the direction of the first sort field.
const dir = rest[0]?.[1] ?? sort[0]?.[1] ?? 1
const sorted = rest.length === 0 || (rest.every(([f, d], i) => idx.fields[k + i] === f && d === dir) && rest.length <= idx.fields.length - k)
const score = k * 2 + (range ? 1 : 0)
if (score === 0 && !(sorted && rest.length > 0)) continue
if (!best || score > best.score || (score === best.score && sorted && !best.sorted)) best = { idx, k, range, sorted, dir, score }
}
if (!best) return undefined
const { idx, k, range, sorted, dir } = best
const succSep = succ(this.sep)
let prefix = this.base + idx.head
for (let i = 0; i < k; i++) prefix += this.#enc(preds.get(idx.fields[i]!)!.eq!) + this.sep
// prefix ends with sep: the range of everything under it is [prefix, prefix-without-sep + succ(sep)).
const all = { gte: prefix, lt: prefix.slice(0, -1) + succSep }
let { gte, lt } = all
if (range) {
const sample = (range.gt ?? range.gte ?? range.lt ?? range.lte) as IndexValue
const [lo, hi] = typeBounds(typeRank(sample), this.#enc)
gte = prefix + lo
lt = hi === undefined ? all.lt : prefix + hi
if (range.gte !== undefined) gte = max(gte, prefix + this.#enc(range.gte))
if (range.gt !== undefined) gte = max(gte, prefix + this.#enc(range.gt) + succSep)
if (range.lt !== undefined) lt = min(lt, prefix + this.#enc(range.lt))
if (range.lte !== undefined) lt = min(lt, prefix + this.#enc(range.lte) + succSep)
}
return { index: idx.name, gte, lt, reverse: sorted && dir === -1, sorted }
}
}
/** Encoded `[lower, upper)` of one type class; `undefined` upper = end of index. */
function typeBounds(rank: number, enc: (v: IndexValue) => string): [string, string | undefined] {
switch (rank) {
case 0:
return [enc(null), enc(false)]
case 1:
return [enc(false), enc(-Infinity)]
case 2:
return [enc(-Infinity), enc('')]
default:
return [enc(''), undefined]
}
}
const max = (a: string, b: string) => (a > b ? a : b)
const min = (a: string, b: string) => (a < b ? a : b)
export class DocIndexRegistry {
readonly trie: PatternTrie<CompiledDocIndex[]>
readonly empty: boolean
readonly hasUnique: boolean
constructor(
decls: DocIndexDeclarations,
readonly sep: string,
) {
this.trie = new PatternTrie(sep)
const patterns = Object.keys(decls)
const compiled = new Map<string, CompiledDocIndex[]>()
for (const p of patterns) {
const list = decls[p]!.map((spec): CompiledDocIndex => {
if (!spec.fields.length) throw new TypeError(`Index on "${p}" has no fields`)
const name = spec.name ?? spec.fields.join(',')
return { name, fields: spec.fields, segs: spec.fields.map((f) => f.split('.')), unique: spec.unique === true, head: escapeSegment(name, sep) + sep }
})
const names = list.map((i) => i.name)
const dup = names.find((n, i) => names.indexOf(n) !== i)
if (dup) throw new TypeError(`Duplicate index name "${dup}" on "${p}"`)
compiled.set(p, list)
}
for (let i = 0; i < patterns.length; i++) {
for (let j = i + 1; j < patterns.length; j++) {
const a = patterns[i]!
const b = patterns[j]!
if (!patternsOverlap(a, b, sep)) continue
const bn = new Set(compiled.get(b)!.map((x) => x.name))
const clash = compiled.get(a)!.filter((x) => bn.has(x.name))
if (clash.length) throw new TypeError(`Index name(s) ${clash.map((c) => c.name).join(', ')} declared on overlapping patterns "${a}" and "${b}"`)
}
}
for (const p of patterns) this.trie.add(p, compiled.get(p)!)
this.empty = patterns.length === 0
this.hasUnique = [...compiled.values()].some((l) => l.some((i) => i.unique))
}
/** `projPrefix` is the projection-space prefix of the node (`<projBase><nodePrefix>`). */
bind(state: TrieState<CompiledDocIndex[]>, projPrefix: string): DocIndexes | undefined {
if (this.empty) return undefined
const found = PatternTrie.values(state)
if (found.length === 0) return undefined
const list = found.flatMap((f) => f.value)
return new DocIndexes(list, projPrefix + RESERVED.index + this.sep, projPrefix + RESERVED.unique + this.sep, this.sep)
}
}
-90
View File
@@ -1,90 +0,0 @@
import type { StoredEvent } from '../events/types.js'
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueOf } from '../types/schema.js'
import type { Filter } from './filter.js'
import type { SortSpec } from './indexes.js'
import type { DocEvent, UpdateSpec } from './update.js'
export interface FindOptions {
sort?: SortSpec
limit?: number
skip?: number
/** Inclusion (`{ a: 1 }`) or exclusion (`{ a: 0 }`) of dot-path fields. */
projection?: Record<string, 0 | 1 | boolean>
}
export interface FindResult<T> {
key: string
data: T
}
export interface GetOptions {
/** Check the stream head and catch the projection up first (one extra scan). */
consistent?: boolean
}
export interface UpdateOptions {
/** Treat a missing document as `{}`. */
upsert?: boolean
}
export interface DocKeysOptions {
limit?: number
after?: string | number
reverse?: boolean
}
/** Document type of a node's children. */
export type ChildDoc<S> = ValueOf<ChildOf<S, KeyOf<S>>>
/**
* One node of a document store: a document (its own) and a collection of child documents.
* Same navigation and self/child arity rules as `KVNode`; in forms with an optional leading key,
* a non-null object first argument selects the self form.
*/
export interface DocumentNode<S extends Spec = AnySpec> {
readonly path: readonly string[]
child<K extends KeyOf<S>>(key: K): DocumentNode<ChildOf<S, K>>
as<S2 extends Spec>(): DocumentNode<S2>
get(opts?: GetOptions): Promise<ValueOf<S> | undefined>
get<K extends KeyOf<S>>(key: K, opts?: GetOptions): Promise<ValueOf<ChildOf<S, K>> | undefined>
set(doc: ValueOf<S>): Promise<void>
set<K extends KeyOf<S>>(key: K, doc: ValueOf<ChildOf<S, K>>): Promise<void>
/** Inserts a child with a generated time-sortable id; returns the id. */
insert(doc: ChildDoc<S>): Promise<string>
/** Returns the new state, or `undefined` if the document doesn't exist (and no `upsert`). */
update(spec: UpdateSpec, opts?: UpdateOptions): Promise<ValueOf<S> | undefined>
update<K extends KeyOf<S>>(key: K, spec: UpdateSpec, opts?: UpdateOptions): Promise<ValueOf<ChildOf<S, K>> | undefined>
/** Returns whether a document was deleted. */
delete(): Promise<boolean>
delete<K extends KeyOf<S>>(key: K): Promise<boolean>
/** Queries direct children. */
find(filter?: Filter, opts?: FindOptions): AsyncIterable<FindResult<ChildDoc<S>>>
findOne(filter?: Filter, opts?: FindOptions): Promise<FindResult<ChildDoc<S>> | undefined>
/** Keys of direct children that hold a (non-deleted) document. */
keys(opts?: DocKeysOptions): AsyncIterable<KeyString<S>>
}
/** Document store backed by an event log: adds history and point-in-time reads. */
export interface EventSourcedDocumentNode<S extends Spec = AnySpec> extends DocumentNode<S> {
child<K extends KeyOf<S>>(key: K): EventSourcedDocumentNode<ChildOf<S, K>>
as<S2 extends Spec>(): EventSourcedDocumentNode<S2>
history(): AsyncIterable<StoredEvent<DocEvent>>
history<K extends KeyOf<S>>(key: K): AsyncIterable<StoredEvent<DocEvent>>
/** State as of `version` (`undefined` if missing or deleted then). */
getAt(version: number): Promise<ValueOf<S> | undefined>
getAt<K extends KeyOf<S>>(key: K, version: number): Promise<ValueOf<ChildOf<S, K>> | undefined>
/** Rebuilds lagging projections for this node and all descendants; returns how many were repaired. */
catchUp(): Promise<number>
}
export type DocumentStorage<S extends Spec = AnySpec> = DocumentNode<S>
-37
View File
@@ -1,37 +0,0 @@
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford base32
/**
* ULID-like id generator: 10 chars of millisecond timestamp + 16 chars of randomness,
* lexicographically sortable by creation time. Ids generated within the same millisecond
* increment the random part, so they stay strictly increasing per generator.
*/
export function createIdGenerator(now: () => number = Date.now): () => string {
let lastTime = -1
const rand = new Uint8Array(16) // one base32 digit (0–31) per byte
return () => {
let t = now()
if (t <= lastTime) {
t = lastTime
// Increment the random part (base 32, right to left).
let i = 15
while (i >= 0 && rand[i] === 31) rand[i--] = 0
if (i < 0) throw new Error('ULID random overflow within one millisecond')
rand[i]!++
} else {
crypto.getRandomValues(rand)
for (let i = 0; i < 16; i++) rand[i]! &= 31
// Leave headroom so increments rarely overflow.
rand[0]! &= 15
lastTime = t
}
let out = ''
for (let i = 9; i >= 0; i--) {
out = ALPHABET[t % 32]! + out
t = Math.floor(t / 32)
}
for (let i = 0; i < 16; i++) out += ALPHABET[rand[i]!]
return out
}
}
export const ulid = createIdGenerator()
-109
View File
@@ -1,109 +0,0 @@
import { isOperatorObject, matches, matchesField, type Filter } from './filter.js'
import { clone, deepEqual, getPath, isPlainObject, setPath, unsetPath, type Doc } from './values.js'
/** Update operators supported in v1. Paths may use dots. */
export interface UpdateSpec {
$set?: Record<string, unknown>
$unset?: Record<string, unknown>
$inc?: Record<string, number>
$push?: Record<string, unknown>
$pull?: Record<string, unknown>
$addToSet?: Record<string, unknown>
}
/** Stored document events: resolved deltas, never operators, so replay is deterministic. */
export type DocEvent = { op: 'set'; doc: Doc } | { op: 'patch'; set?: Record<string, unknown>; unset?: string[] } | { op: 'delete' }
export interface Patch {
set: Record<string, unknown>
unset: string[]
}
const OPS = new Set(['$set', '$unset', '$inc', '$push', '$pull', '$addToSet'])
/** Resolves operators against `state` into a patch of final values. */
export function resolveUpdate(state: Doc, spec: UpdateSpec): Patch {
const keys = Object.keys(spec)
if (keys.length === 0) throw new TypeError('Empty update')
for (const k of keys) if (!OPS.has(k)) throw new TypeError(`Unsupported update operator ${k}`)
const patch: Patch = { set: {}, unset: [] }
const touched: string[] = []
const touch = (path: string) => {
for (const t of touched) {
if (t === path || t.startsWith(path + '.') || path.startsWith(t + '.')) throw new TypeError(`Update paths conflict: "${t}" and "${path}"`)
}
touched.push(path)
}
const arrayAt = (path: string, op: string): unknown[] => {
const cur = getPath(state, path)
if (cur === undefined || cur === null) return []
if (!Array.isArray(cur)) throw new TypeError(`${op} on non-array field "${path}"`)
return cur
}
const eachOf = (v: unknown): unknown[] => (isPlainObject(v) && Object.keys(v).length === 1 && Array.isArray(v.$each) ? (v.$each as unknown[]) : [v])
for (const [path, v] of Object.entries(spec.$set ?? {})) {
touch(path)
patch.set[path] = clone(v)
}
for (const path of Object.keys(spec.$unset ?? {})) {
touch(path)
patch.unset.push(path)
}
for (const [path, by] of Object.entries(spec.$inc ?? {})) {
touch(path)
if (typeof by !== 'number') throw new TypeError(`$inc by non-number at "${path}"`)
const cur = getPath(state, path)
if (cur !== undefined && cur !== null && typeof cur !== 'number') throw new TypeError(`$inc on non-number field "${path}"`)
patch.set[path] = ((cur as number | undefined) ?? 0) + by
}
for (const [path, v] of Object.entries(spec.$push ?? {})) {
touch(path)
patch.set[path] = [...clone(arrayAt(path, '$push')), ...clone(eachOf(v))]
}
for (const [path, v] of Object.entries(spec.$addToSet ?? {})) {
touch(path)
const next = clone(arrayAt(path, '$addToSet'))
for (const item of eachOf(v)) if (!next.some((x) => deepEqual(x, item))) next.push(clone(item))
patch.set[path] = next
}
for (const [path, cond] of Object.entries(spec.$pull ?? {})) {
touch(path)
patch.set[path] = clone(arrayAt(path, '$pull')).filter((el) => !pullMatches(el, cond))
}
return patch
}
function pullMatches(el: unknown, cond: unknown): boolean {
if (isOperatorObject(cond)) return matchesField([el], cond)
if (isPlainObject(cond) && isPlainObject(el)) return matches(el, cond as Filter)
return deepEqual(el, cond)
}
/** Applies a resolved patch to a copy of `state`. */
export function applyPatch(state: Doc, patch: { set?: Record<string, unknown>; unset?: string[] }): Doc {
const next = clone(state)
for (const [path, v] of Object.entries(patch.set ?? {})) setPath(next, path, clone(v))
for (const path of patch.unset ?? []) unsetPath(next, path)
return next
}
/** Folds one event into a state (`undefined` = no document). */
export function applyEvent(state: Doc | undefined, e: DocEvent): Doc | undefined {
switch (e.op) {
case 'set':
return clone(e.doc)
case 'patch':
return applyPatch(state ?? {}, e)
case 'delete':
return undefined
}
}
export function toPatchEvent(p: Patch): DocEvent {
const e: { op: 'patch'; set?: Record<string, unknown>; unset?: string[] } = { op: 'patch' }
if (Object.keys(p.set).length) e.set = p.set
if (p.unset.length) e.unset = p.unset
return e
}
-163
View File
@@ -1,163 +0,0 @@
import type { IndexValue } from '../core/encoding.js'
/** Plain JSON object. */
export type Doc = Record<string, unknown>
export function isPlainObject(v: unknown): v is Doc {
return typeof v === 'object' && v !== null && !Array.isArray(v)
}
export function isScalar(v: unknown): v is IndexValue {
return v === null || typeof v === 'string' || typeof v === 'boolean' || (typeof v === 'number' && !Number.isNaN(v))
}
export function splitPath(path: string): string[] {
return path.split('.')
}
/**
* Values reachable at a dot path. Arrays are traversed: a non-numeric segment applied to an
* array maps over its elements. Missing paths yield no values.
*/
export function resolvePath(doc: unknown, path: string | readonly string[]): unknown[] {
const segs = typeof path === 'string' ? splitPath(path) : path
const out: unknown[] = []
walk(doc, segs, 0, out)
return out
}
function walk(v: unknown, segs: readonly string[], i: number, out: unknown[]): void {
if (i === segs.length) {
if (v !== undefined) out.push(v)
return
}
const seg = segs[i]!
if (Array.isArray(v)) {
if (/^\d+$/.test(seg)) walk(v[Number(seg)], segs, i + 1, out)
else for (const el of v) if (isPlainObject(el) || Array.isArray(el)) walk(el, segs, i, out)
return
}
if (isPlainObject(v) && Object.hasOwn(v, seg)) walk(v[seg], segs, i + 1, out)
}
/** Value at a dot path without array traversal (numeric segments index arrays). */
export function getPath(doc: unknown, path: string): unknown {
let v = doc
for (const seg of splitPath(path)) {
if (Array.isArray(v) && /^\d+$/.test(seg)) v = v[Number(seg)]
else if (isPlainObject(v) && Object.hasOwn(v, seg)) v = v[seg]
else return undefined
}
return v
}
/** Sets a dot path in place, creating intermediate objects. */
export function setPath(doc: Doc, path: string, value: unknown): void {
const segs = splitPath(path)
let v: unknown = doc
for (let i = 0; i < segs.length - 1; i++) {
const seg = segs[i]!
const container = v as Record<string, unknown>
let next = Array.isArray(container) && /^\d+$/.test(seg) ? container[Number(seg)] : container[seg]
if (next === undefined || next === null) {
next = {}
if (Array.isArray(container)) container[Number(seg)] = next
else container[seg] = next
} else if (typeof next !== 'object') {
throw new TypeError(`Cannot set "${path}": "${segs.slice(0, i + 1).join('.')}" is not an object`)
}
v = next
}
const last = segs.at(-1)!
if (Array.isArray(v) && /^\d+$/.test(last)) v[Number(last)] = value
else (v as Doc)[last] = value
}
export function unsetPath(doc: Doc, path: string): void {
const segs = splitPath(path)
let v: unknown = doc
for (let i = 0; i < segs.length - 1; i++) {
v = isPlainObject(v) ? v[segs[i]!] : Array.isArray(v) && /^\d+$/.test(segs[i]!) ? v[Number(segs[i])] : undefined
if (typeof v !== 'object' || v === null) return
}
const last = segs.at(-1)!
if (Array.isArray(v) && /^\d+$/.test(last))
v[Number(last)] = null // Mongo semantics: keep positions
else if (isPlainObject(v)) delete v[last]
}
export function deepEqual(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) !== Array.isArray(b)) return false
if (Array.isArray(a)) {
const bb = b as unknown[]
return a.length === bb.length && a.every((x, i) => deepEqual(x, bb[i]))
}
const ka = Object.keys(a)
const kb = Object.keys(b)
return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual((a as Doc)[k], (b as Doc)[k]))
}
/** Deep copy of a JSON-compatible value (documents are JSON). */
export function clone<T>(v: T): T {
if (v === null || typeof v !== 'object') return v
if (Array.isArray(v)) return v.map(clone) as T
const out: Doc = {}
for (const k in v) out[k] = clone((v as Doc)[k])
return out as T
}
/** Type class used for bracketing comparisons, in index-encoding order. */
export function typeRank(v: unknown): number {
if (v === null || v === undefined) return 0
if (typeof v === 'boolean') return 1
if (typeof v === 'number') return 2
if (typeof v === 'string') return 3
return 4
}
/**
* Total order matching the index encoding for scalars:
* `null/missing < false < true < numbers < strings < anything else`.
*/
export function compareValues(a: unknown, b: unknown): number {
const ra = typeRank(a)
const rb = typeRank(b)
if (ra !== rb) return ra < rb ? -1 : 1
if (ra === 1 || ra === 2 || ra === 3) return a === b ? 0 : (a as number) < (b as number) ? -1 : 1
if (ra === 4) {
const sa = JSON.stringify(a)
const sb = JSON.stringify(b)
return sa === sb ? 0 : sa < sb ? -1 : 1
}
return 0
}
/**
* The scalar(s) an index stores for a field value: scalars as-is, arrays as their scalar
* elements (multikey; empty → null), and missing or non-scalar values as `null`.
*/
export function indexScalars(v: unknown): IndexValue[] {
if (Array.isArray(v)) {
const out: IndexValue[] = []
for (const el of v) out.push(isScalar(el) ? el : null)
return out.length ? out : [null]
}
return [isScalar(v) ? v : null]
}
/** Sort key for a field under a direction; arrays sort by their min (asc) or max (desc) element. */
export function sortKey(doc: unknown, path: string, dir: 1 | -1): unknown {
const vals = resolvePath(doc, path)
if (vals.length === 0) return null
let best: unknown
let first = true
for (const v of vals) {
for (const s of indexScalars(v)) {
if (first || compareValues(s, best) * dir < 0) best = s
first = false
}
}
return best
}
-50
View File
@@ -1,50 +0,0 @@
/** Base class for all PotentialStorage errors. */
export class PotentialStorageError extends Error {
constructor(message: string) {
super(message)
this.name = new.target.name
}
}
/** An optimistic-concurrency check failed (stale `expectedVersion` or a lost commit race). */
export class ConcurrencyError extends PotentialStorageError {
constructor(
readonly stream: string,
readonly expectedVersion: number | 'any',
readonly actualVersion: number,
) {
super(`Concurrency conflict on "${stream}": expected version ${expectedVersion}, actual ${actualVersion}`)
}
}
/** A unique index already holds the given value for another key. */
export class UniqueConstraintError extends PotentialStorageError {
constructor(
readonly index: string,
readonly values: readonly unknown[],
readonly existingKey?: string,
) {
super(`Unique index "${index}" already contains ${JSON.stringify(values)}${existingKey === undefined ? '' : ` (key "${existingKey}")`}`)
}
}
/** The driver lacks an optional capability required by the requested feature. */
export class CapabilityError extends PotentialStorageError {
constructor(
readonly capability: string,
detail?: string,
) {
super(`Driver lacks capability "${capability}"${detail ? `: ${detail}` : ''}`)
}
}
/**
* A key argument is invalid: passed but `undefined`/`null`, empty, or otherwise unusable.
* Extends `TypeError` so callers can catch either.
*/
export class InvalidKeyError extends TypeError {
constructor(message: string) {
super(message)
this.name = 'InvalidKeyError'
}
}
+100
View File
@@ -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
}
}
+151
View File
@@ -0,0 +1,151 @@
import type { BaseKVStorage } from '../kv-storage/base-kv-storage.js'
import { ConcurrencyError } from '../utils/errors.js'
import { Keys } from '../utils/keys.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseEventStorage, type AppendResult, type ReadOptions, type StoredEvent, type StreamKeysOptions } from './base-event-storage.js'
/** Stored value of one commit key. */
interface Commit {
firstVersion: number
at: number
events: unknown[]
}
interface Head {
commit: number
version: number
}
export interface EventStorageKVOptions {
/** Retries after losing a commit race when `expectedVersion` is `'any'`. Default 5. */
maxRetries?: number
/** Max streams whose head is cached. Default 10 000. */
cacheSize?: number
now?: () => number
}
/**
* Event storage on any KV storage. Each append is one commit key, densely numbered:
* `<stream>/~e/<commit:12>` → `{ firstVersion, at, events }`. Commits are claimed with
* `writeIfAbsent`, so a writer with a stale view always collides with the existing key.
*/
export class EventStorageKV<S extends Spec = AnySpec> extends BaseEventStorage<S> {
private readonly heads = new Map<string, Head>()
private readonly maxRetries: number
private readonly cacheSize: number
private readonly now: () => number
constructor(
private readonly kv: BaseKVStorage,
options: EventStorageKVOptions = {},
) {
super()
this.maxRetries = options.maxRetries ?? 5
this.cacheSize = options.cacheSize ?? 10_000
this.now = options.now ?? Date.now
}
async appendTo(stream: readonly string[], events: unknown[], expectedVersion: number | 'any'): Promise<AppendResult> {
const base = this.commitPrefix(stream)
for (let attempt = 0; ; attempt++) {
let head = this.heads.get(base) ?? (await this.loadHead(base))
// The cache may be stale; only trust a mismatch after re-reading.
if (expectedVersion !== 'any' && head.version !== expectedVersion) head = await this.loadHead(base)
if (expectedVersion !== 'any' && head.version !== expectedVersion) throw new ConcurrencyError(base, expectedVersion, head.version)
if (events.length === 0) return { version: head.version }
const commit: Commit = { firstVersion: head.version + 1, at: this.now(), events }
const [written] = await this.kv.writeIfAbsent([[base + EventStorageKV.pad(head.commit + 1), commit]])
if (written) {
const next = { commit: head.commit + 1, version: head.version + events.length }
this.cacheHead(base, next)
return { version: next.version }
}
this.heads.delete(base)
if (expectedVersion !== 'any' || attempt >= this.maxRetries) {
throw new ConcurrencyError(base, expectedVersion, (await this.loadHead(base)).version)
}
}
}
async *readFrom(stream: readonly string[], options: ReadOptions): AsyncGenerator<StoredEvent<unknown>> {
const from = Math.max(1, options.from ?? 1)
const to = options.to ?? Infinity
let remaining = options.limit ?? Infinity
if (to < from || remaining <= 0) return
const base = this.commitPrefix(stream)
const start = from === 1 ? 1 : await this.findCommit(base, from)
if (start === 0) return
for await (const [, value] of this.kv.scan({ gte: base + EventStorageKV.pad(start), lt: this.commitEnd(base), values: true })) {
const commit = value as Commit
for (let i = 0; i < commit.events.length; i++) {
const version = commit.firstVersion + i
if (version < from) continue
if (version > to || remaining-- <= 0) return
yield { version, at: commit.at, data: commit.events[i] }
}
}
}
async versionOf(stream: readonly string[]): Promise<number> {
return (await this.loadHead(this.commitPrefix(stream))).version
}
async *streamsOf(parent: readonly string[], options: StreamKeysOptions): AsyncGenerator<string> {
yield* parent.reduce<BaseKVStorage>((node, segment) => node.child(segment), this.kv).keys(options)
}
/** Last commit of a stream: one reverse scan with limit 1. Refreshes the cache. */
private async loadHead(base: string): Promise<Head> {
for await (const [key, value] of this.kv.scan({ gte: base, lt: this.commitEnd(base), reverse: true, limit: 1, values: true })) {
const commit = value as Commit
const head = { commit: Number(key.slice(base.length)), version: commit.firstVersion + commit.events.length - 1 }
this.cacheHead(base, head)
return head
}
return { commit: 0, version: 0 }
}
/**
* Commit containing `version`, or 0 if the stream is shorter. Commit `n` starts at a version
* >= n, so it's at most `version`: try that first (exact when commits hold one event each),
* then binary-search the commits below it.
*/
private async findCommit(base: string, version: number): Promise<number> {
const firstVersion = async (commit: number) => ((await this.kv.readMany([base + EventStorageKV.pad(commit)]))[0] as Commit | undefined)?.firstVersion
const guess = await firstVersion(version)
if (guess !== undefined && guess <= version) return version
let hi = version - 1
if (guess === undefined) {
const head = await this.loadHead(base)
if (head.version < version) return 0
hi = head.commit
}
let lo = 1
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1
if ((await firstVersion(mid))! <= version) lo = mid
else hi = mid - 1
}
return lo
}
private cacheHead(base: string, head: Head): void {
this.heads.delete(base)
this.heads.set(base, head)
if (this.heads.size > this.cacheSize) this.heads.delete(this.heads.keys().next().value!)
}
private commitPrefix(stream: readonly string[]): string {
const prefix = this.kv.prefix + Keys.join(stream)
return `${prefix}/~e/`
}
private commitEnd(base: string): string {
return base.slice(0, -1) + Keys.afterSeparator
}
private static pad(commit: number): string {
return String(commit).padStart(12, '0')
}
}
-2
View File
@@ -1,2 +0,0 @@
export * from './types.js'
export * from './kv.js'
-289
View File
@@ -1,289 +0,0 @@
import { LRU } from '../core/lru.js'
import { KeyedMutex } from '../core/mutex.js'
import { escapeSegment, RESERVED, succ, toSegment } from '../core/path.js'
import { ConcurrencyError } from '../errors.js'
import type { KVDriver } from '../kv/driver.js'
import type { KVNode } from '../kv/node.js'
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueOf } from '../types/schema.js'
import type { AppendOptions, AppendResult, EventNode, ReadOptions, StoredEvent, StreamKeysOptions } from './types.js'
export interface KVEventStorageOptions {
/** Max streams whose head is cached. Default 10 000. */
headCacheSize?: number
/** Retries after a commit collision when `expectedVersion` is `'any'`. Default 5. */
maxRetries?: number
/** Clock for commit timestamps. Default `Date.now`. */
now?: () => number
}
/** Stored value of one commit key. */
export interface CommitRecord<E = unknown> {
firstVersion: number
at: number
events: E[]
}
interface Head {
commit: number
version: number
}
interface Stream {
/** Stream's own key (escaped); identifies it in the cache and in errors. */
key: string
/** `<key>/~e/` — commit keys are `base + pad12(seq)`. */
base: string
/** Exclusive upper bound of commit keys. */
end: string
}
/** @internal */
interface EventContext {
readonly driver: KVDriver
readonly sep: string
readonly heads: LRU<string, Head>
readonly mutex: KeyedMutex
readonly maxRetries: number
readonly now: () => number
}
const SEQ_WIDTH = 12
const SEARCH_FANOUT = 8
const EMPTY_HEAD: Head = { commit: 0, version: 0 }
const pad = (seq: number) => String(seq).padStart(SEQ_WIDTH, '0')
/**
* Event node over a `KVNode`. Each append is one commit key, densely numbered:
* `<stream>/~e/<commitSeq:12>` → `{ firstVersion, at, events }`. Commits are claimed with
* `setIfAbsent`, so any stale writer collides with the existing key. Without `setIfAbsent`,
* appends are serialised by an in-process per-stream mutex (single-writer only).
*/
export class KVEventNode<S extends Spec = AnySpec> implements EventNode<S> {
readonly #ctx: EventContext
readonly #kv: KVNode<AnySpec>
/** @internal Use `KVEventStorage` and `child()`. */
constructor(ctx: EventContext, kv: KVNode<AnySpec>) {
this.#ctx = ctx
this.#kv = kv
}
get path(): readonly string[] {
return this.#kv.path
}
/** The underlying KV node. */
get kv(): KVNode<AnySpec> {
return this.#kv
}
child<K extends KeyOf<S>>(key: K): KVEventNode<ChildOf<S, K>> {
return new KVEventNode(this.#ctx, this.#kv.child(key as string).as<AnySpec>())
}
as<S2 extends Spec>(): KVEventNode<S2> {
return this as unknown as KVEventNode<S2>
}
append(events: readonly ValueOf<S>[], opts?: AppendOptions): Promise<AppendResult>
append<K extends KeyOf<S>>(key: K, events: readonly ValueOf<ChildOf<S, K>>[], opts?: AppendOptions): Promise<AppendResult>
async append(...args: unknown[]): Promise<AppendResult> {
const self = Array.isArray(args[0])
const stream = self ? this.#self() : this.#child(args[0])
const events = args[self ? 0 : 1]
const opts = (args[self ? 1 : 2] ?? {}) as AppendOptions
if (!Array.isArray(events)) throw new TypeError('append() expects an array of events')
const expected = opts.expectedVersion ?? 'any'
if (expected !== 'any' && !(Number.isInteger(expected) && expected >= 0)) throw new TypeError(`Invalid expectedVersion ${String(expected)}`)
const hint = expected !== 'any' && Number.isInteger(opts.expectedCommit) ? { commit: opts.expectedCommit!, version: expected } : undefined
return this.#append(stream, events, expected, hint)
}
read(opts?: ReadOptions): AsyncIterable<StoredEvent<ValueOf<S>>>
read<K extends KeyOf<S>>(key: K, opts?: ReadOptions): AsyncIterable<StoredEvent<ValueOf<ChildOf<S, K>>>>
async *read(...args: unknown[]): AsyncGenerator<StoredEvent<any>> {
const self = args.length === 0 || (typeof args[0] === 'object' && args[0] !== null)
const stream = self ? this.#self() : this.#child(args[0])
yield* this.#read(stream, ((self ? args[0] : args[1]) ?? {}) as ReadOptions)
}
version(): Promise<number>
version<K extends KeyOf<S>>(key: K): Promise<number>
async version(...args: unknown[]): Promise<number> {
const stream = args.length === 0 ? this.#self() : this.#child(args[0])
return (await this.#loadHead(stream)).version
}
keys(opts: StreamKeysOptions = {}): AsyncIterable<KeyString<S>> {
return this.#kv.keys(opts) as AsyncIterable<KeyString<S>>
}
// ---------------------------------------------------------------- internals
#self(): Stream {
if (this.#kv.isRoot) throw new TypeError('The root node is not a stream')
return this.#streamAt(this.#kv.selfKey)
}
#child(key: unknown): Stream {
return this.#streamAt(this.#kv.prefix + escapeSegment(toSegment(key), this.#ctx.sep))
}
#streamAt(key: string): Stream {
const sep = this.#ctx.sep
const head = key + sep + RESERVED.events
return { key, base: head + sep, end: head + succ(sep) }
}
async #loadHead(s: Stream): Promise<Head> {
for await (const [key, value] of this.#ctx.driver.scan({ gte: s.base, lt: s.end, reverse: true, limit: 1, values: true })) {
const c = value as CommitRecord
const head = { commit: Number(key.slice(s.base.length)), version: c.firstVersion + c.events.length - 1 }
this.#ctx.heads.set(s.key, head)
return head
}
return EMPTY_HEAD
}
async #append(s: Stream, events: readonly unknown[], expected: number | 'any', hint?: Head): Promise<AppendResult> {
const { driver, heads, maxRetries } = this.#ctx
if (events.length === 0) {
const head = await this.#loadHead(s)
if (expected !== 'any' && head.version !== expected) throw new ConcurrencyError(s.key, expected, head.version)
return { version: head.version, commit: head.commit }
}
if (!driver.setIfAbsent) return this.#ctx.mutex.run(s.key, () => this.#appendSingleWriter(s, events, expected))
for (let attempt = 0; ; attempt++) {
let head = heads.get(s.key) ?? (attempt === 0 ? hint : undefined)
if (!head) head = await this.#loadHead(s)
else if (expected !== 'any' && head.version !== expected) head = await this.#loadHead(s) // cache may be stale
if (expected !== 'any' && head.version !== expected) throw new ConcurrencyError(s.key, expected, head.version)
const seq = head.commit + 1
const record: CommitRecord = { firstVersion: head.version + 1, at: this.#ctx.now(), events: [...events] }
const [written] = await driver.setIfAbsent([[s.base + pad(seq), record]])
if (written) {
const next = { commit: seq, version: head.version + events.length }
heads.set(s.key, next)
return next
}
// Lost the race for this commit number.
heads.delete(s.key)
if (expected !== 'any' || attempt >= maxRetries) {
throw new ConcurrencyError(s.key, expected, (await this.#loadHead(s)).version)
}
}
}
async #appendSingleWriter(s: Stream, events: readonly unknown[], expected: number | 'any'): Promise<AppendResult> {
const head = this.#ctx.heads.get(s.key) ?? (await this.#loadHead(s))
if (expected !== 'any' && head.version !== expected) throw new ConcurrencyError(s.key, expected, head.version)
const seq = head.commit + 1
const record: CommitRecord = { firstVersion: head.version + 1, at: this.#ctx.now(), events: [...events] }
await this.#ctx.driver.setMany([[s.base + pad(seq), record]])
const next = { commit: seq, version: head.version + events.length }
this.#ctx.heads.set(s.key, next)
return next
}
/**
* Sequence number of the commit containing `version`, or 0 if the stream hasn't reached it.
* Commit `n` has `firstVersion >= n`, so the answer is at most `version`: probe that first
* (one round trip when commits hold single events), then k-ary search with `getMany` probes.
*/
async #locate(s: Stream, version: number): Promise<number> {
const { driver } = this.#ctx
const [guess] = (await driver.getMany([s.base + pad(version)])) as (CommitRecord | undefined)[]
if (guess && guess.firstVersion <= version) return version
let hi: number
if (guess) hi = version - 1
else {
const head = await this.#loadHead(s)
if (head.version < version) return 0
hi = head.commit
}
let lo = 1 // commit 1 starts at version 1
while (lo < hi) {
const probes: number[] = []
for (let i = 1; i <= SEARCH_FANOUT; i++) {
const p = lo + Math.ceil(((hi - lo) * i) / SEARCH_FANOUT)
if (p !== probes.at(-1)) probes.push(p)
}
const found = (await driver.getMany(probes.map((p) => s.base + pad(p)))) as (CommitRecord | undefined)[]
for (let i = 0; i < probes.length; i++) {
const c = found[i]
if (c && c.firstVersion <= version) lo = probes[i]!
else {
hi = probes[i]! - 1
break
}
}
}
return lo
}
async *#read(s: Stream, opts: ReadOptions): AsyncGenerator<StoredEvent<unknown>> {
const from = Math.max(1, opts.from ?? 1)
const to = opts.to ?? Infinity
let remaining = opts.limit ?? Infinity
if (to < from || remaining <= 0) return
const { driver } = this.#ctx
if (!opts.reverse) {
let gte = s.base
if (from > 1) {
const seq = await this.#locate(s, from)
if (seq === 0) return
gte = s.base + pad(seq)
}
for await (const [key, value] of driver.scan({ gte, lt: s.end, values: true })) {
const c = value as CommitRecord
const commit = Number(key.slice(s.base.length))
for (let i = 0; i < c.events.length; i++) {
const version = c.firstVersion + i
if (version < from) continue
if (version > to) return
yield { version, commit, at: c.at, data: c.events[i] }
if (--remaining <= 0) return
}
}
return
}
let lt = s.end
if (to !== Infinity) {
const seq = await this.#locate(s, to)
if (seq !== 0) lt = s.base + pad(seq + 1)
}
for await (const [key, value] of driver.scan({ gte: s.base, lt, reverse: true, values: true })) {
const c = value as CommitRecord
const commit = Number(key.slice(s.base.length))
for (let i = c.events.length - 1; i >= 0; i--) {
const version = c.firstVersion + i
if (version > to) continue
if (version < from) return
yield { version, commit, at: c.at, data: c.events[i] }
if (--remaining <= 0) return
}
}
}
}
/** Event storage over a `KVStorage` (or any `KVNode` as root). */
export class KVEventStorage<S extends Spec = AnySpec> extends KVEventNode<S> {
constructor(kv: KVNode<AnySpec>, opts: KVEventStorageOptions = {}) {
super(
{
driver: kv.driver,
sep: kv.separator,
heads: new LRU(opts.headCacheSize ?? 10_000),
mutex: new KeyedMutex(),
maxRetries: opts.maxRetries ?? 5,
now: opts.now ?? Date.now,
},
kv,
)
}
}
-69
View File
@@ -1,69 +0,0 @@
import type { AnySpec, ChildOf, KeyOf, KeyString, Spec, ValueOf } from '../types/schema.js'
export interface AppendOptions {
/** Version the stream must be at (0 = empty). Default `'any'`. */
expectedVersion?: number | 'any'
/**
* Commit sequence of the head at `expectedVersion`, if the caller knows it (e.g. from a
* projection). Lets a cold append skip the head lookup. Must come from the stream itself: a
* stale hint only causes a collision, but an invented one could leave a gap. Ignored unless
* `expectedVersion` is a number; backends may ignore it.
*/
expectedCommit?: number
}
export interface AppendResult {
/** Stream version after the append (the last event's version). */
version: number
/** Commit sequence number of this append (the current head commit when no events were given). */
commit: number
}
export interface ReadOptions {
/** First version to read (inclusive). Default 1. */
from?: number
/** Last version to read (inclusive). Default: the head. */
to?: number
reverse?: boolean
limit?: number
}
export interface StoredEvent<E> {
version: number
commit: number
/** Commit timestamp (ms since epoch). */
at: number
data: E
}
export interface StreamKeysOptions {
limit?: number
after?: string | number
reverse?: boolean
}
/**
* One node of an event store: a stream (its own events) and a container of child streams.
* Same navigation and self/child arity rules as `KVNode`.
*/
export interface EventNode<S extends Spec = AnySpec> {
readonly path: readonly string[]
child<K extends KeyOf<S>>(key: K): EventNode<ChildOf<S, K>>
as<S2 extends Spec>(): EventNode<S2>
append(events: readonly ValueOf<S>[], opts?: AppendOptions): Promise<AppendResult>
append<K extends KeyOf<S>>(key: K, events: readonly ValueOf<ChildOf<S, K>>[], opts?: AppendOptions): Promise<AppendResult>
read(opts?: ReadOptions): AsyncIterable<StoredEvent<ValueOf<S>>>
read<K extends KeyOf<S>>(key: K, opts?: ReadOptions): AsyncIterable<StoredEvent<ValueOf<ChildOf<S, K>>>>
/** Current version (0 = empty). Always reads storage. */
version(): Promise<number>
version<K extends KeyOf<S>>(key: K): Promise<number>
/** Child stream keys. */
keys(opts?: StreamKeysOptions): AsyncIterable<KeyString<S>>
}
/** The root of an event store. Native backends (EventStoreDB, Kafka, …) implement this too. */
export type EventStorage<S extends Spec = AnySpec> = EventNode<S>
-115
View File
@@ -1,115 +0,0 @@
import { BaseKVDriver, JsonCodec, type BatchOp, type Codec, type ScanOptions } from '../kv/driver.js'
import type { HttpCapabilities } from './handler.js'
export interface HttpKVDriverOptions {
/** Base URL of a `createKVHttpHandler` endpoint, e.g. `https://host/kv`. */
url: string
/** Optional capabilities the server's driver supports. Default: all. `connect()` discovers them. */
capabilities?: Partial<HttpCapabilities>
codec?: Codec
headers?: Record<string, string>
fetch?: typeof fetch
}
/**
* Driver client for `createKVHttpHandler`. Optional methods exist only when the server
* supports them, so stores' feature detection works across HTTP.
*/
export class HttpKVDriver extends BaseKVDriver {
readonly #url: string
readonly #codec: Codec
readonly #headers: Record<string, string>
readonly #fetch: typeof fetch
readonly #nativeDeleteRange: boolean
setIfAbsent?: (entries: [string, unknown][]) => Promise<boolean[]>
batch?: (ops: BatchOp[]) => Promise<void>
/** Creates a client after asking the server which optional capabilities it has. */
static async connect(opts: Omit<HttpKVDriverOptions, 'capabilities'>): Promise<HttpKVDriver> {
const probe = new HttpKVDriver({ ...opts, capabilities: {} })
const capabilities = (await probe.#request('GET', '/capabilities')) as HttpCapabilities
return new HttpKVDriver({ ...opts, capabilities })
}
constructor(opts: HttpKVDriverOptions) {
super()
this.#url = opts.url.replace(/\/$/, '')
this.#codec = opts.codec ?? JsonCodec
this.#headers = opts.headers ?? {}
this.#fetch = opts.fetch ?? globalThis.fetch.bind(globalThis)
const caps = { setIfAbsent: true, batch: true, deleteRange: true, ...opts.capabilities }
this.#nativeDeleteRange = caps.deleteRange
if (caps.setIfAbsent) this.setIfAbsent = async (entries) => ((await this.#request('POST', '/setIfAbsent', { entries })) as { written: boolean[] }).written
if (caps.batch) this.batch = async (ops) => void (await this.#request('POST', '/batch', { ops }))
}
async getMany(keys: string[]): Promise<unknown[]> {
if (keys.length === 0) return []
const { values, missing } = (await this.#request('POST', '/getMany', { keys })) as { values: unknown[]; missing: number[] }
for (const i of missing) values[i] = undefined
return values
}
async setMany(entries: [string, unknown][]): Promise<void> {
if (entries.length) await this.#request('POST', '/setMany', { entries })
}
async deleteMany(keys: string[]): Promise<void> {
if (keys.length) await this.#request('POST', '/deleteMany', { keys })
}
override async deleteRange(gte: string, lt: string): Promise<void> {
if (!this.#nativeDeleteRange) return super.deleteRange(gte, lt)
await this.#request('POST', '/deleteRange', { gte, lt })
}
async *scan(opts: ScanOptions): AsyncGenerator<[string, unknown]> {
if (opts.limit !== undefined && opts.limit <= 0) return
const q = new URLSearchParams()
if (opts.gte !== undefined) q.set('gte', opts.gte)
if (opts.lt !== undefined) q.set('lt', opts.lt)
if (opts.limit !== undefined && opts.limit !== Infinity) q.set('limit', String(opts.limit))
if (opts.reverse) q.set('reverse', 'true')
if (opts.values) q.set('values', 'true')
const res = await this.#fetch(`${this.#url}/scan?${q}`, { headers: this.#headers })
if (!res.ok || !res.body) throw await httpError(res)
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buf = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let nl: number
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl)
buf = buf.slice(nl + 1)
const [key, v] = this.#codec.decode(line) as [string, unknown?]
yield [key, v]
}
}
} finally {
await reader.cancel().catch(() => {})
}
}
async #request(method: 'GET' | 'POST', path: string, body?: unknown): Promise<unknown> {
const res = await this.#fetch(this.#url + path, {
method,
headers: { ...this.#headers, ...(body === undefined ? {} : { 'content-type': 'application/json' }) },
body: body === undefined ? undefined : this.#codec.encode(body),
})
if (!res.ok) throw await httpError(res)
return res.status === 204 ? undefined : this.#codec.decode(await res.text())
}
}
async function httpError(res: Response): Promise<Error> {
let detail = ''
try {
detail = await res.text()
} catch {}
return new Error(`HTTP ${res.status}: ${detail}`)
}
-115
View File
@@ -1,115 +0,0 @@
import { JsonCodec, type BatchOp, type Codec, type KVDriver, type ScanOptions } from '../kv/driver.js'
export interface KVHttpHandlerOptions {
/** Serialises request/response bodies and NDJSON scan lines. Must not emit newlines. Default JSON. */
codec?: Codec
/** Path prefix to strip, e.g. `/kv`. Default `''`. */
basePath?: string
}
export interface HttpCapabilities {
setIfAbsent: boolean
batch: boolean
deleteRange: boolean
}
/**
* Framework-agnostic HTTP front for a driver (`Request → Response`).
*
* - `POST /getMany {keys}` → `{values, missing}` (`missing` = indices of absent keys)
* - `POST /setMany {entries}`, `POST /deleteMany {keys}`, `POST /batch {ops}` → 204
* - `POST /setIfAbsent {entries}` → `{written}`
* - `POST /deleteRange {gte, lt}` → 204
* - `GET /scan?gte&lt&limit&reverse&values` → NDJSON, one `[key]` or `[key, value]` per line
* - `GET /capabilities` → `{setIfAbsent, batch, deleteRange}`
*/
export function createKVHttpHandler(driver: KVDriver, opts: KVHttpHandlerOptions = {}): (req: Request) => Promise<Response> {
const codec = opts.codec ?? JsonCodec
const base = opts.basePath ?? ''
const reply = (body: unknown, status = 200) => new Response(codec.encode(body), { status, headers: { 'content-type': 'application/json' } })
const noContent = () => new Response(null, { status: 204 })
const fail = (status: number, message: string) => reply({ error: message }, status)
return async (req) => {
const url = new URL(req.url)
if (!url.pathname.startsWith(base)) return fail(404, 'Not found')
const route = `${req.method} ${url.pathname.slice(base.length)}`
try {
const body = async <T>() => codec.decode(await req.text()) as T
switch (route) {
case 'POST /getMany': {
const { keys } = await body<{ keys: string[] }>()
const found = await driver.getMany(keys)
const missing: number[] = []
const values = found.map((v, i) => (v === undefined ? (missing.push(i), null) : v))
return reply({ values, missing })
}
case 'POST /setMany':
await driver.setMany((await body<{ entries: [string, unknown][] }>()).entries)
return noContent()
case 'POST /deleteMany':
await driver.deleteMany((await body<{ keys: string[] }>()).keys)
return noContent()
case 'POST /setIfAbsent':
if (!driver.setIfAbsent) return fail(501, 'setIfAbsent not supported')
return reply({ written: await driver.setIfAbsent((await body<{ entries: [string, unknown][] }>()).entries) })
case 'POST /batch':
if (!driver.batch) return fail(501, 'batch not supported')
await driver.batch((await body<{ ops: BatchOp[] }>()).ops)
return noContent()
case 'POST /deleteRange': {
if (!driver.deleteRange) return fail(501, 'deleteRange not supported')
const { gte, lt } = await body<{ gte: string; lt: string }>()
await driver.deleteRange(gte, lt)
return noContent()
}
case 'GET /capabilities':
return reply({ setIfAbsent: !!driver.setIfAbsent, batch: !!driver.batch, deleteRange: !!driver.deleteRange } satisfies HttpCapabilities)
case 'GET /scan':
return scanResponse(driver, parseScan(url.searchParams), codec)
default:
return fail(404, `No route ${route}`)
}
} catch (err) {
return fail(500, err instanceof Error ? err.message : String(err))
}
}
}
function parseScan(q: URLSearchParams): ScanOptions {
const opts: ScanOptions = {}
if (q.has('gte')) opts.gte = q.get('gte')!
if (q.has('lt')) opts.lt = q.get('lt')!
if (q.has('limit')) opts.limit = Number(q.get('limit'))
if (q.get('reverse') === 'true') opts.reverse = true
if (q.get('values') === 'true') opts.values = true
return opts
}
function scanResponse(driver: KVDriver, opts: ScanOptions, codec: Codec): Response {
const it = driver.scan(opts)[Symbol.asyncIterator]()
const encoder = new TextEncoder()
// Chunks grow so short (skip-scan) reads don't make the server run far ahead.
let chunk = 16
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
let buf = ''
for (let i = 0; i < chunk; i++) {
const r = await it.next()
if (r.done) {
if (buf) controller.enqueue(encoder.encode(buf))
controller.close()
return
}
const [key, value] = r.value
buf += codec.encode(opts.values ? [key, value] : [key]) + '\n'
}
chunk = Math.min(chunk * 2, 256)
controller.enqueue(encoder.encode(buf))
},
async cancel() {
await it.return?.()
},
})
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
}
-2
View File
@@ -1,2 +0,0 @@
export * from './handler.js'
export * from './client.js'
+18 -8
View File
@@ -1,8 +1,18 @@
export * from './errors.js'
export * from './core/path.js'
export * from './core/encoding.js'
export * from './kv/index.js'
export * from './types/schema.js'
export * from './events/index.js'
export * from './documents/index.js'
export * from './http/index.js'
export * from './kv-storage/base-kv-storage.js'
export * from './kv-storage/kv-storage-in-memory.js'
export * from './kv-storage/kv-storage-http.js'
export * from './kv-storage/kv-storage-http-server.js'
export * from './event-storage/base-event-storage.js'
export * from './event-storage/event-storage-kv.js'
export * from './document-storage/base-document-storage.js'
export * from './document-storage/document-storage-in-memory.js'
export * from './document-storage/document-storage-kv.js'
export * from './document-storage/event-sourced-document-storage.js'
export * from './utils/document-path.js'
export * from './utils/document-query.js'
export * from './utils/document-update.js'
export * from './utils/errors.js'
export * from './utils/json.js'
export * from './utils/keys.js'
export * from './utils/schema.js'
export * from './utils/ulid.js'
+184
View File
@@ -0,0 +1,184 @@
import { Keys } from '../utils/keys.js'
import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueAt, ValueOf } from '../utils/schema.js'
export interface ScanOptions {
/** Inclusive lower bound. */
gte?: string
/** Exclusive upper bound. */
lt?: string
limit?: number
reverse?: boolean
/** Include values; otherwise they are `undefined`. */
values?: boolean
}
export interface KeysOptions {
limit?: number
/** Start after this child key (exclusive), in the direction of travel. */
after?: string | number
reverse?: boolean
}
/**
* Ordered key-value storage. Implementations provide five raw primitives over absolute keys;
* everything else (navigation, self/child reads and writes, child listing) is defined here once.
*
* Every instance is a node: `child(key)` returns a view of the same storage rooted at that key.
* Methods taking an optional `key` act on that child, or on this node's own value when omitted.
* A key that is passed but `undefined`/`null` throws rather than silently meaning "self".
*/
export abstract class BaseKVStorage<S extends Spec = AnySpec> {
/** Unescaped path of this node (`[]` for the root). */
path: readonly string[] = []
/** This node's own key (`''` for the root). */
key = ''
/** Prefix of every descendant key (`''` for the root). */
prefix = ''
/** Values for absolute keys, in order; `undefined` for missing. */
abstract readMany(keys: string[]): Promise<unknown[]>
abstract writeMany(entries: [key: string, value: unknown][]): Promise<void>
abstract removeMany(keys: string[]): Promise<void>
/** Writes each entry only if its key is absent; `true` = written. Must be atomic per key. */
abstract writeIfAbsent(entries: [key: string, value: unknown][]): Promise<boolean[]>
/** Entries in key order (descending when `reverse`). */
abstract scan(options?: ScanOptions): AsyncIterable<[key: string, value: unknown]>
// ------------------------------------------------------------------ navigation
/** A view of this storage rooted at `key`. Synchronous; no I/O. */
child<K extends KeyOf<S>>(key: K): BaseKVStorage<ChildOf<S, K>> {
const node = Object.create(this) as BaseKVStorage<ChildOf<S, K>>
node.path = [...this.path, Keys.segment(key)]
node.key = this.prefix + Keys.escape(Keys.segment(key))
node.prefix = node.key + Keys.separator
return node
}
/** Re-types this node. No runtime effect. */
as<S2 extends Spec>(): BaseKVStorage<S2> {
return this as unknown as BaseKVStorage<S2>
}
// ------------------------------------------------------------------ reads and writes
async get<K extends KeyOf<S> | undefined = undefined>(key?: K): Promise<ValueAt<S, K> | undefined> {
const [value] = await this.readMany([this.keyOf(arguments.length > 0, key)])
return value as ValueAt<S, K> | undefined
}
async getMany<K extends KeyOf<S>>(keys: readonly K[]): Promise<(ValueOf<ChildOf<S, K>> | undefined)[]> {
return (await this.readMany(keys.map((k) => this.keyOf(true, k)))) as (ValueOf<ChildOf<S, K>> | undefined)[]
}
/** `set(value)` writes this node's own value; `set(key, value)` a child's. */
async set<K extends KeyOf<S>>(...args: [value: ValueOf<S>] | [key: K, value: ValueOf<ChildOf<S, K>>]): Promise<void> {
const value = args[args.length - 1]
if (value === undefined) throw new TypeError('Cannot set undefined; use delete()')
await this.writeMany([[this.keyOf(args.length > 1, args[0]), value]])
}
async setMany(entries: readonly EntryOf<S>[]): Promise<void> {
if (entries.some(([, v]) => v === undefined)) throw new TypeError('Cannot set undefined; use delete()')
await this.writeMany(entries.map(([k, v]) => [this.keyOf(true, k), v]))
}
/** Deletes this node's own value (or a child's), not descendants. */
async delete(key?: KeyOf<S>): Promise<void> {
await this.removeMany([this.keyOf(arguments.length > 0, key)])
}
async deleteMany(keys: readonly KeyOf<S>[]): Promise<void> {
await this.removeMany(keys.map((k) => this.keyOf(true, k)))
}
/** Deletes this node's own value (or a child's) and all descendants. */
async deleteTree(key?: KeyOf<S>): Promise<void> {
const own = this.keyOf(arguments.length > 0, key, true)
if (own !== '') await this.removeMany([own])
const prefix = own === '' ? '' : own + Keys.separator
for (;;) {
const batch: string[] = []
for await (const [k] of this.scan({ gte: prefix, lt: own === '' ? undefined : own + Keys.afterSeparator, limit: 1000 })) batch.push(k)
if (batch.length === 0) return
await this.removeMany(batch)
}
}
// ------------------------------------------------------------------ listing
/**
* Direct child keys in key order, including children that only have descendants.
* Skip-scan: one seek per child that has descendants, not one read per descendant.
*/
async *keys(options: KeysOptions = {}): AsyncGenerator<KeyString<S>> {
for await (const [key] of this.children(options, false)) yield key as KeyString<S>
}
/** Direct children that have their own value, as `[key, value]`. */
async *entries(options: KeysOptions = {}): AsyncGenerator<StringEntryOf<S>> {
let remaining = options.limit ?? Infinity
for await (const [key, value, hasValue] of this.children({ ...options, limit: undefined }, true)) {
if (remaining <= 0) return
if (!hasValue) continue
remaining--
yield [key, value] as StringEntryOf<S>
}
}
private async *children(options: KeysOptions, values: boolean): AsyncGenerator<[key: string, value: unknown, hasValue: boolean]> {
const { reverse = false } = options
let remaining = options.limit ?? Infinity
let gte = this.prefix
let lt = this.key === '' ? undefined : this.key + Keys.afterSeparator
if (options.after !== undefined) {
const after = this.prefix + Keys.escape(Keys.segment(options.after))
if (reverse) lt = after
else gte = after + Keys.afterSeparator
}
let last: string | undefined
let restart = true
let iterator: AsyncIterator<[string, unknown]> | undefined
try {
while (remaining > 0) {
if (restart) {
await iterator?.return?.()
iterator = this.scan({ gte, lt, reverse, values })[Symbol.asyncIterator]()
restart = false
}
const next = await iterator!.next()
if (next.done) return
const [key, value] = next.value
const end = key.indexOf(Keys.separator, this.prefix.length)
const segment = key.slice(this.prefix.length, end === -1 ? undefined : end)
const isOwn = end === -1
if (segment !== last && !Keys.isReserved(segment)) {
// Forward, a child's own key comes before its descendants; in reverse it comes after
// them and is skipped by the jump below, so read it directly when values are wanted.
let own = isOwn ? value : undefined
if (!isOwn && reverse && values) [own] = await this.readMany([this.prefix + segment])
yield [Keys.unescape(segment), own, isOwn || own !== undefined]
remaining--
}
last = segment
// Jump over the rest of this child's subtree instead of reading it.
if (!isOwn || Keys.isReserved(segment)) {
if (reverse) lt = this.prefix + segment
else gte = this.prefix + segment + Keys.afterSeparator
restart = true
}
}
} finally {
await iterator?.return?.()
}
}
// ------------------------------------------------------------------ internals
/** Absolute key for a child (`hasKey`) or this node; validates keys. */
protected keyOf(hasKey: boolean, key: unknown, allowRoot = false): string {
if (hasKey) return this.prefix + Keys.escape(Keys.segment(key))
if (this.key === '' && !allowRoot) throw new TypeError('The root node has no own value')
return this.key
}
}
+70
View File
@@ -0,0 +1,70 @@
import type { BaseKVStorage, ScanOptions } from './base-kv-storage.js'
/**
* Serves any KV storage over HTTP for `KVStorageHttp`. Framework-agnostic: `handle(Request)`
* returns a `Response`. All routes are `POST /<primitive>` with a JSON body; `scan` streams NDJSON.
* The raw primitives use absolute keys, so this exposes the whole store behind `storage`.
*/
export class KVStorageHttpServer {
constructor(
private readonly storage: BaseKVStorage,
private readonly basePath = '',
) {}
async handle(request: Request): Promise<Response> {
const path = new URL(request.url).pathname
if (request.method !== 'POST' || !path.startsWith(this.basePath)) return KVStorageHttpServer.error(404, 'Not found')
try {
const body = (await request.json()) as { keys: string[]; entries: [string, unknown][] } & ScanOptions
switch (path.slice(this.basePath.length)) {
case '/readMany': {
const values = await this.storage.readMany(body.keys)
const missing = values.flatMap((v, i) => (v === undefined ? [i] : []))
return Response.json({ values: values.map((v) => v ?? null), missing })
}
case '/writeMany':
await this.storage.writeMany(body.entries)
return new Response(null, { status: 204 })
case '/removeMany':
await this.storage.removeMany(body.keys)
return new Response(null, { status: 204 })
case '/writeIfAbsent':
return Response.json(await this.storage.writeIfAbsent(body.entries))
case '/scan':
return this.scan(body)
default:
return KVStorageHttpServer.error(404, 'Not found')
}
} catch (error) {
return KVStorageHttpServer.error(500, error instanceof Error ? error.message : String(error))
}
}
private scan(options: ScanOptions): Response {
const iterator = this.storage.scan(options)[Symbol.asyncIterator]()
const encoder = new TextEncoder()
const stream = new ReadableStream<Uint8Array>({
async pull(controller) {
// Send in small batches so short reads (skip-scan seeks) don't make the server run far ahead.
let chunk = ''
for (let i = 0; i < 32; i++) {
const next = await iterator.next()
if (next.done) {
if (chunk) controller.enqueue(encoder.encode(chunk))
return controller.close()
}
chunk += JSON.stringify(options.values ? next.value : [next.value[0]]) + '\n'
}
controller.enqueue(encoder.encode(chunk))
},
async cancel() {
await iterator.return?.()
},
})
return new Response(stream, { headers: { 'content-type': 'application/x-ndjson' } })
}
private static error(status: number, message: string): Response {
return Response.json({ error: message }, { status })
}
}
+79
View File
@@ -0,0 +1,79 @@
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js'
export interface KVStorageHttpOptions {
/** Base URL of a `KVStorageHttpServer`, e.g. `https://host/kv`. */
url: string
headers?: Record<string, string>
fetch?: typeof fetch
}
/** KV storage client for a `KVStorageHttpServer`. Same API as any other KV storage. */
export class KVStorageHttp<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly url: string
private readonly headers: Record<string, string>
private readonly fetcher: typeof fetch
constructor(options: KVStorageHttpOptions) {
super()
this.url = options.url.replace(/\/$/, '')
this.headers = options.headers ?? {}
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis)
}
async readMany(keys: string[]): Promise<unknown[]> {
if (keys.length === 0) return []
const { values, missing } = (await this.post('readMany', { keys })) as { values: unknown[]; missing: number[] }
for (const i of missing) values[i] = undefined
return values
}
async writeMany(entries: [string, unknown][]): Promise<void> {
if (entries.length) await this.post('writeMany', { entries })
}
async removeMany(keys: string[]): Promise<void> {
if (keys.length) await this.post('removeMany', { keys })
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.length ? ((await this.post('writeIfAbsent', { entries })) as boolean[]) : []
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
if (options.limit !== undefined && options.limit <= 0) return
const response = await this.request('scan', options)
const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
try {
for (;;) {
const { done, value } = await reader.read()
if (done) return
buffer += value
const lines = buffer.split('\n')
buffer = lines.pop()!
for (const line of lines) {
const [key, value] = JSON.parse(line) as [string, unknown?]
yield [key, value]
}
}
} finally {
await reader.cancel().catch(() => {})
}
}
private async post(path: string, body: unknown): Promise<unknown> {
const response = await this.request(path, body)
return response.status === 204 ? undefined : response.json()
}
private async request(path: string, body: unknown): Promise<Response> {
const response = await this.fetcher(`${this.url}/${path}`, {
method: 'POST',
headers: { ...this.headers, 'content-type': 'application/json' },
body: JSON.stringify(body),
})
if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`)
return response
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Json } from '../utils/json.js'
import { SortedMap } from '../utils/sorted-map.js'
import type { AnySpec, Spec } from '../utils/schema.js'
import { BaseKVStorage, type ScanOptions } from './base-kv-storage.js'
/** In-memory KV storage. Values are deep-copied on write and read. */
export class KVStorageInMemory<S extends Spec = AnySpec> extends BaseKVStorage<S> {
private readonly data = new SortedMap<unknown>()
async readMany(keys: string[]): Promise<unknown[]> {
return keys.map((k) => Json.clone(this.data.get(k)))
}
async writeMany(entries: [string, unknown][]): Promise<void> {
for (const [k, v] of entries) this.data.set(k, Json.clone(v))
}
async removeMany(keys: string[]): Promise<void> {
for (const k of keys) this.data.delete(k)
}
async writeIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.map(([k, v]) => !this.data.has(k) && (this.data.set(k, Json.clone(v)), true))
}
async *scan(options: ScanOptions = {}): AsyncGenerator<[string, unknown]> {
let remaining = options.limit ?? Infinity
if (remaining <= 0) return
for (const key of this.data.keys(options.gte, options.lt, options.reverse)) {
yield [key, options.values ? Json.clone(this.data.get(key)) : undefined]
if (--remaining <= 0) return
}
}
}
-236
View File
@@ -1,236 +0,0 @@
import { escapeSegment, succ } from '../core/path.js'
import type { KVDriver } from './driver.js'
/**
* Direct-children skip-scan, shared by every layer.
*
* ## Child order
*
* Children are ordered by **escaped segment followed by the separator** ("subtree order": the
* order in which their descendants appear in key space). For segments without characters that
* sort below the separator this is plain lexicographic order. When one segment extends another
* with such a character (`a` vs `a-1` with `/`), the longer one comes first (`a-1/` < `a/`).
*
* This is the one order that can be produced without extra reads: a child's own key
* (`p/a`) sorts *before* siblings that extend it (`p/a-1`), while its descendants (`p/a/…`) sort
* after them. The forward scan therefore defers yielding a child seen only through its own key
* until the scan passes `p/a/`, using a stack bounded by segment length.
*
* ## Cost
*
* - Forward: one seek per child that has descendants (to jump over its subtree), one at the start,
* and one per reserved (`~…`) region. Siblings without descendants share an iterator.
* `after` adds one `getMany` when the `after` segment contains a below-separator character.
* - Reverse: as forward, plus one `getMany` per newly found child whose segment contains a
* below-separator character (to find shorter siblings that only have an own key), or that was
* found through a descendant when `own` is requested.
*/
export interface ChildHit {
/** Escaped segment. */
esc: string
/** Whether the child has an own value. Only accurate when `own` was requested. */
hasOwn: boolean
/** The own value, when `values` was requested and `hasOwn`. */
value: unknown
}
export interface ChildScanOptions {
/** Unescaped segment to start after (exclusive), in the direction of travel. */
after?: string
reverse?: boolean
limit?: number
/** Fetch own values. Implies `own`. */
values?: boolean
/** Require accurate `hasOwn`. */
own?: boolean
}
const RESERVED_START = '~'
const RESERVED_END = succ('~')
export async function* scanChildren(
driver: KVDriver,
prefix: string,
end: string | undefined,
sep: string,
opts: ChildScanOptions = {},
): AsyncGenerator<ChildHit> {
const limit = opts.limit ?? Infinity
if (limit <= 0) return
if (opts.reverse) yield* reverseScan(driver, prefix, end, sep, opts, limit)
else yield* forwardScan(driver, prefix, end, sep, opts, limit)
}
/** Prefixes `E[0..j)` (j ≥ 1) where `E[j]` sorts below the separator. */
function lowPrefixes(esc: string, sepCode: number): string[] {
let out: string[] | undefined
for (let j = 1; j < esc.length; j++) {
if (esc.charCodeAt(j) < sepCode) (out ??= []).push(esc.slice(0, j))
}
return out ?? NONE
}
const NONE: string[] = []
async function* forwardScan(
driver: KVDriver,
prefix: string,
end: string | undefined,
sep: string,
opts: ChildScanOptions,
limit: number,
): AsyncGenerator<ChildHit> {
const sepCode = sep.charCodeAt(0)
const succSep = succ(sep)
const values = opts.values === true
const pending: ChildHit[] = []
let emitted = 0
let gte = prefix
if (opts.after !== undefined) {
const escA = escapeSegment(opts.after, sep)
gte = prefix + escA + succSep
// Shorter siblings that extend into `after` with a low char come after it, but their own
// keys sort before `gte`; probe for them.
const cands = lowPrefixes(escA, sepCode)
if (cands.length) {
const found = await driver.getMany(cands.map((g) => prefix + g))
for (let i = 0; i < cands.length; i++) {
if (found[i] !== undefined) pending.push({ esc: cands[i]!, hasOwn: true, value: values ? found[i] : undefined })
}
}
}
const prefixLen = prefix.length
let it = driver.scan({ gte, lt: end, values })[Symbol.asyncIterator]()
const reseek = async (from: string) => {
await it.return?.()
it = driver.scan({ gte: from, lt: end, values })[Symbol.asyncIterator]()
}
try {
outer: for (;;) {
const r = await it.next()
if (r.done) {
while (pending.length) {
yield pending.pop()!
if (++emitted >= limit) return
}
return
}
const key = r.value[0]
const cut = key.indexOf(sep, prefixLen)
const esc = cut === -1 ? key.slice(prefixLen) : key.slice(prefixLen, cut)
while (pending.length) {
const top = pending[pending.length - 1]!
if (top.esc === esc) {
// First descendant of a deferred child: emit it and jump over its subtree.
pending.pop()
yield top
if (++emitted >= limit) return
await reseek(prefix + esc + succSep)
continue outer
}
if (top.esc + sep < esc + sep) {
pending.pop()
yield top
if (++emitted >= limit) return
} else break
}
if (esc.startsWith(RESERVED_START)) {
await reseek(prefix + RESERVED_END)
continue
}
if (cut === -1) {
// Own key: defer until we know no sibling extending it with a low char comes next.
pending.push({ esc, hasOwn: true, value: r.value[1] })
continue
}
// Descendant of a child with no own value (an own key would have sorted first).
yield { esc, hasOwn: false, value: undefined }
if (++emitted >= limit) return
await reseek(prefix + esc + succSep)
}
} finally {
await it.return?.()
}
}
async function* reverseScan(
driver: KVDriver,
prefix: string,
end: string | undefined,
sep: string,
opts: ChildScanOptions,
limit: number,
): AsyncGenerator<ChildHit> {
const sepCode = sep.charCodeAt(0)
const values = opts.values === true
const needOwn = values || opts.own === true
// Segments already emitted whose own key may still appear (it sorts after everything else of theirs).
const done: string[] = []
let emitted = 0
let lt = end
if (opts.after !== undefined) {
const escA = escapeSegment(opts.after, sep)
lt = prefix + escA + sep
// `after` itself and shorter siblings extending into it with a low char sort after it in
// child order but their own keys fall below `lt`: mark them done.
done.push(...lowPrefixes(escA, sepCode), escA)
}
const prefixLen = prefix.length
let it = driver.scan({ gte: prefix, lt, reverse: true, values })[Symbol.asyncIterator]()
const reseek = async (below: string) => {
await it.return?.()
it = driver.scan({ gte: prefix, lt: below, reverse: true, values })[Symbol.asyncIterator]()
}
try {
for (;;) {
const r = await it.next()
if (r.done) return
const key = r.value[0]
const cut = key.indexOf(sep, prefixLen)
const esc = cut === -1 ? key.slice(prefixLen) : key.slice(prefixLen, cut)
if (esc.startsWith(RESERVED_START)) {
await reseek(prefix + RESERVED_START)
continue
}
while (done.length && !esc.startsWith(done[done.length - 1]!)) done.pop()
if (done.length && done[done.length - 1] === esc) {
done.pop()
if (cut !== -1) await reseek(prefix + esc + sep)
continue
}
// New child. Shorter siblings that it extends with a low char come before it in reverse
// order; any with descendants were already emitted, so only own-key-only ones are unknown.
const cands = lowPrefixes(esc, sepCode).filter((g) => !done.includes(g))
const probeOwn = cut !== -1 && needOwn
let found: unknown[] = NONE
if (cands.length || probeOwn) {
const probe = cands.map((g) => prefix + g)
if (probeOwn) probe.push(prefix + esc)
found = await driver.getMany(probe)
}
for (let i = 0; i < cands.length; i++) {
if (found[i] === undefined) continue
yield { esc: cands[i]!, hasOwn: true, value: values ? found[i] : undefined }
done.push(cands[i]!)
if (++emitted >= limit) return
}
const own = cut === -1 ? r.value[1] : probeOwn ? found[cands.length] : undefined
yield { esc, hasOwn: cut === -1 || own !== undefined, value: values ? own : undefined }
done.push(esc)
if (++emitted >= limit) return
if (cut !== -1) await reseek(prefix + esc + sep)
}
} finally {
await it.return?.()
}
}
-77
View File
@@ -1,77 +0,0 @@
export interface ScanOptions {
gte?: string
lt?: string
limit?: number
reverse?: boolean
/** When false/omitted, yielded values are `undefined`. */
values?: boolean
}
export type BatchOp = { type: 'set'; key: string; value: unknown } | { type: 'delete'; key: string }
/**
* A flat, string-keyed backend. Values are JSON-serialisable; byte-oriented backends serialise
* internally with a {@link Codec}. All path, index and typing logic lives above this interface.
*/
export interface KVDriver {
/** Same order as `keys`; `undefined` for missing keys. */
getMany(keys: string[]): Promise<(unknown | undefined)[]>
setMany(entries: [key: string, value: unknown][]): Promise<void>
deleteMany(keys: string[]): Promise<void>
/** MUST yield in key order (descending when `reverse`). `gte` is inclusive, `lt` exclusive. */
scan(opts: ScanOptions): AsyncIterable<[key: string, value: unknown]>
// Optional capabilities. Stores feature-detect them and fall back where possible.
/** Writes each entry only if its key is absent. `true` = written. */
setIfAbsent?(entries: [string, unknown][]): Promise<boolean[]>
/** Applies all ops atomically. */
batch?(ops: BatchOp[]): Promise<void>
deleteRange?(gte: string, lt: string): Promise<void>
}
/** Serialises values for byte/string-oriented backends. */
export interface Codec {
encode(value: unknown): string
decode(encoded: string): unknown
}
export const JsonCodec: Codec = {
encode: (value) => JSON.stringify(value),
decode: (encoded) => JSON.parse(encoded),
}
/**
* Base class for drivers: implement the four required methods and get `deleteRange`
* (scan + deleteMany in chunks) and single-key helpers for free.
*/
export abstract class BaseKVDriver implements KVDriver {
abstract getMany(keys: string[]): Promise<(unknown | undefined)[]>
abstract setMany(entries: [string, unknown][]): Promise<void>
abstract deleteMany(keys: string[]): Promise<void>
abstract scan(opts: ScanOptions): AsyncIterable<[string, unknown]>
/** Chunk size used by the default `deleteRange`. */
protected deleteRangeChunk = 1000
async deleteRange(gte: string, lt: string): Promise<void> {
for (;;) {
const keys: string[] = []
for await (const [key] of this.scan({ gte, lt, limit: this.deleteRangeChunk })) keys.push(key)
if (keys.length === 0) return
await this.deleteMany(keys)
if (keys.length < this.deleteRangeChunk) return
}
}
async get(key: string): Promise<unknown | undefined> {
return (await this.getMany([key]))[0]
}
set(key: string, value: unknown): Promise<void> {
return this.setMany([[key, value]])
}
delete(key: string): Promise<void> {
return this.deleteMany([key])
}
}
-6
View File
@@ -1,6 +0,0 @@
export * from './driver.js'
export * from './memory.js'
export * from './sorted.js'
export * from './node.js'
export * from './indexes.js'
export * from './children.js'
-157
View File
@@ -1,157 +0,0 @@
import { indexValueCodec, isIndexValue, type IndexValue, type IndexValueCodec } from '../core/encoding.js'
import { escapeSegment, RESERVED, succ } from '../core/path.js'
import { PatternTrie, patternsOverlap, type TrieState } from '../core/trie.js'
export type { IndexValue }
/** Maps a child's value to index value(s). An array means multi-entry; `undefined` means not indexed. */
export type IndexFn<V = any> = (value: V) => IndexValue | readonly IndexValue[] | undefined
/** Index name → function, for the direct children of nodes matching one pattern. */
export type IndexDefs = Record<string, IndexFn>
/** Path pattern (`*` = one segment, `''` = root) → index definitions. */
export type IndexDeclarations = Record<string, IndexDefs>
/** A value or a range over index values. Bounds combine (`{ gte, lt }`). */
export type IndexQuery = IndexValue | IndexRange
export interface IndexRange {
gt?: IndexValue
gte?: IndexValue
lt?: IndexValue
lte?: IndexValue
}
export function isIndexRange(q: unknown): q is IndexRange {
return typeof q === 'object' && q !== null
}
interface CompiledIndex {
name: string
/** `escapeSegment(name) + sep` */
head: string
fn: IndexFn
}
/** Indexes over the direct children of one concrete node. Entry keys are rooted at `base`. */
export class BoundIndexes {
readonly #byName: Map<string, CompiledIndex>
readonly #list: CompiledIndex[]
readonly #codec: IndexValueCodec
constructor(
defs: CompiledIndex[],
/** `<nodePrefix>~i<sep>` */
readonly base: string,
readonly sep: string,
) {
this.#list = defs
this.#byName = new Map(defs.map((d) => [d.name, d]))
this.#codec = indexValueCodec(sep)
}
has(name: string): boolean {
return this.#byName.has(name)
}
/** All index entry keys for a child (escaped segment) holding `value`. */
entriesFor(escChild: string, value: unknown, out: Set<string> = new Set()): Set<string> {
if (value === undefined) return out
for (const idx of this.#list) this.#entriesOf(idx, escChild, value, out)
return out
}
#entriesOf(idx: CompiledIndex, escChild: string, value: unknown, out: Set<string>): void {
const r = idx.fn(value)
if (r === undefined) return
const stem = this.base + idx.head
const tail = this.sep + escChild
if (Array.isArray(r)) {
for (const v of r as IndexValue[]) out.add(stem + this.#encode(idx.name, v) + tail)
} else out.add(stem + this.#encode(idx.name, r as IndexValue) + tail)
}
#encode(name: string, v: unknown): string {
if (!isIndexValue(v)) throw new TypeError(`Index "${name}" returned a non-indexable value: ${String(v)}`)
return this.#codec.encode(v)
}
/** Whether `entryKey` is among the entries `value` currently produces for index `name`. */
matches(name: string, escChild: string, value: unknown, entryKey: string): boolean {
if (value === undefined) return false
const out = new Set<string>()
this.#entriesOf(this.#get(name), escChild, value, out)
return out.has(entryKey)
}
/** Scan bounds over the entries of index `name` for a query. */
range(name: string, q: IndexQuery): { gte: string; lt: string } {
const stem = this.base + this.#get(name).head
const succSep = succ(this.sep)
const enc = (v: IndexValue) => stem + this.#encode(name, v)
if (!isIndexRange(q)) {
const k = enc(q)
return { gte: k + this.sep, lt: k + succSep }
}
let gte = stem
let lt = stem.slice(0, -1) + succSep
if (q.gte !== undefined) gte = maxStr(gte, enc(q.gte))
if (q.gt !== undefined) gte = maxStr(gte, enc(q.gt) + succSep)
if (q.lt !== undefined) lt = minStr(lt, enc(q.lt))
if (q.lte !== undefined) lt = minStr(lt, enc(q.lte) + succSep)
return { gte, lt }
}
/** Splits an entry key under index `name` into the escaped child segment. */
childOf(name: string, entryKey: string): string {
const start = this.base.length + this.#get(name).head.length
return entryKey.slice(entryKey.indexOf(this.sep, start) + 1)
}
#get(name: string): CompiledIndex {
const idx = this.#byName.get(name)
if (!idx) throw new TypeError(`Unknown index "${name}"`)
return idx
}
}
const maxStr = (a: string, b: string) => (a > b ? a : b)
const minStr = (a: string, b: string) => (a < b ? a : b)
/** Compiles index declarations into a pattern trie and binds them to nodes. */
export class IndexRegistry {
readonly trie: PatternTrie<IndexDefs>
readonly empty: boolean
constructor(
decls: IndexDeclarations,
readonly sep: string,
) {
this.trie = new PatternTrie(sep)
const patterns = Object.keys(decls)
for (let i = 0; i < patterns.length; i++) {
for (let j = i + 1; j < patterns.length; j++) {
const a = patterns[i]!
const b = patterns[j]!
if (!patternsOverlap(a, b, sep)) continue
const clash = Object.keys(decls[a]!).filter((n) => n in decls[b]!)
if (clash.length) throw new TypeError(`Index name(s) ${clash.join(', ')} declared on overlapping patterns "${a}" and "${b}"`)
}
}
for (const p of patterns) this.trie.add(p, decls[p]!)
this.empty = patterns.length === 0
}
/** Indexes over the children of the node with trie state `state` and key prefix `prefix`. */
bind(state: TrieState<IndexDefs>, prefix: string): BoundIndexes | undefined {
if (this.empty) return undefined
const found = PatternTrie.values(state)
if (found.length === 0) return undefined
const defs: CompiledIndex[] = []
for (const { value } of found) {
for (const [name, fn] of Object.entries(value)) defs.push({ name, head: escapeSegment(name, this.sep) + this.sep, fn })
}
return new BoundIndexes(defs, prefix + RESERVED.index + this.sep, this.sep)
}
}
-93
View File
@@ -1,93 +0,0 @@
import { BaseKVDriver, type BatchOp, type ScanOptions } from './driver.js'
import { ChunkedSortedMap, type SortedMap } from './sorted.js'
export interface InMemoryKVDriverOptions {
/**
* Deep-copy values on write and read so callers can't mutate stored state through a shared
* reference (matching serialising drivers). Values must be JSON-compatible. Default `true`.
* Disable for speed when values are treated as immutable.
*/
clone?: boolean
}
/**
* In-memory driver over a sorted map. Implements every optional capability:
* `setIfAbsent`, atomic `batch` and `deleteRange`.
*/
export class InMemoryKVDriver extends BaseKVDriver {
readonly #data: SortedMap<unknown> = new ChunkedSortedMap()
readonly #clone: (v: unknown) => unknown
constructor(opts: InMemoryKVDriverOptions = {}) {
super()
this.#clone = opts.clone === false ? identity : cloneValue
}
get size(): number {
return this.#data.size
}
async getMany(keys: string[]): Promise<unknown[]> {
const out = new Array<unknown>(keys.length)
for (let i = 0; i < keys.length; i++) out[i] = this.#clone(this.#data.get(keys[i]!))
return out
}
async setMany(entries: [string, unknown][]): Promise<void> {
const clone = this.#clone
this.#data.setMany(entries.map(([k, v]) => [k, clone(v)]))
}
async deleteMany(keys: string[]): Promise<void> {
for (const key of keys) this.#data.delete(key)
}
async setIfAbsent(entries: [string, unknown][]): Promise<boolean[]> {
return entries.map(([key, value]) => {
if (this.#data.has(key)) return false
this.#data.set(key, this.#clone(value))
return true
})
}
async batch(ops: BatchOp[]): Promise<void> {
// Applied synchronously within one microtask, so no other operation can observe a partial batch.
for (const op of ops) {
if (op.type === 'set') this.#data.set(op.key, this.#clone(op.value))
else this.#data.delete(op.key)
}
}
override async deleteRange(gte: string, lt: string): Promise<void> {
const doomed = [...this.#data.keys({ gte, lt })]
for (const key of doomed) this.#data.delete(key)
}
async *scan(opts: ScanOptions): AsyncGenerator<[string, unknown]> {
const { gte, lt, reverse = false, values = false } = opts
let remaining = opts.limit ?? Infinity
if (remaining <= 0) return
// The key iterator re-seeks by itself if the map changes while we're suspended.
for (const key of this.#data.keys({ gte, lt, reverse })) {
yield [key, values ? this.#clone(this.#data.get(key)) : undefined]
if (--remaining <= 0) return
}
}
}
function identity(v: unknown): unknown {
return v
}
/** Deep copy of a JSON-compatible value (much faster than `structuredClone`). */
function cloneValue(v: unknown): unknown {
if (v === null || typeof v !== 'object') return v
if (Array.isArray(v)) {
const out = new Array<unknown>(v.length)
for (let i = 0; i < v.length; i++) out[i] = cloneValue(v[i])
return out
}
const out: Record<string, unknown> = {}
for (const k in v) out[k] = cloneValue((v as Record<string, unknown>)[k])
return out
}
-402
View File
@@ -1,402 +0,0 @@
import { DEFAULT_SEPARATOR, escapeSegment, succ, toSegment, unescapeSegment, validateSeparator } from '../core/path.js'
import type { TrieState } from '../core/trie.js'
import type { AnySpec, ChildOf, EntryOf, KeyOf, KeyString, Spec, StringEntryOf, ValueOf } from '../types/schema.js'
import { scanChildren } from './children.js'
import type { BatchOp, KVDriver } from './driver.js'
import { BoundIndexes, IndexRegistry, isIndexRange, type IndexDeclarations, type IndexDefs, type IndexQuery } from './indexes.js'
export interface KVStorageOptions {
/** Single printable ASCII character other than `%` and `~`. Default `/`. */
separator?: string
/** Path pattern → index definitions over that node's direct children. */
indexes?: IndexDeclarations
/**
* Delete stale index entries found by `findBy` (read-repair). Default `true`.
* See DESIGN.md §12 (M4) for the race with concurrent non-atomic writers.
*/
readRepair?: boolean
}
export interface KeysOptions {
limit?: number
/** Start after this child key (exclusive), in the direction of travel. */
after?: string | number
reverse?: boolean
}
export interface TreeOptions {
/** Levels below this node to include; `1` = direct children only. */
depth: number
}
export interface FindByOptions {
limit?: number
reverse?: boolean
}
/** @internal */
export interface KVContext {
readonly driver: KVDriver
readonly sep: string
readonly registry: IndexRegistry
readonly readRepair: boolean
}
interface Slot {
key: string
esc: string
value: unknown
}
const FIND_CHUNK = 128
const DELETE_CHUNK = 1000
/**
* A store over its direct children. `child(key)` re-roots to that key (sync, no I/O).
* Methods take an optional leading key: without it they act on this node's own value,
* with it on that child's. The choice is made by argument count; a passed-but-`undefined`
* key throws.
*/
export class KVNode<S extends Spec = AnySpec> {
/** Unescaped path segments. */
readonly path: readonly string[]
/** This node's own key (escaped). `''` for the root. */
readonly selfKey: string
/** Prefix of every descendant key. `''` for the root. */
readonly prefix: string
/** Exclusive upper bound of the subtree range; `undefined` for the root. */
readonly end: string | undefined
readonly #ctx: KVContext
readonly #esc: string
readonly #state: TrieState<IndexDefs>
readonly #selfIdx: BoundIndexes | undefined
readonly #childIdx: BoundIndexes | undefined
/** @internal Use `KVStorage` and `child()`. */
constructor(ctx: KVContext, path: readonly string[], selfKey: string, esc: string, state: TrieState<IndexDefs>, selfIdx: BoundIndexes | undefined) {
this.#ctx = ctx
this.path = path
this.selfKey = selfKey
this.#esc = esc
const root = path.length === 0
this.prefix = root ? '' : selfKey + ctx.sep
this.end = root ? undefined : selfKey + succ(ctx.sep)
this.#state = state
this.#selfIdx = selfIdx
this.#childIdx = ctx.registry.bind(state, this.prefix)
}
get driver(): KVDriver {
return this.#ctx.driver
}
get separator(): string {
return this.#ctx.sep
}
get isRoot(): boolean {
return this.path.length === 0
}
child<K extends KeyOf<S>>(key: K): KVNode<ChildOf<S, K>> {
const seg = toSegment(key)
const esc = escapeSegment(seg, this.#ctx.sep)
return new KVNode(this.#ctx, [...this.path, seg], this.prefix + esc, esc, this.#ctx.registry.trie.step(this.#state, seg), this.#childIdx)
}
/** Re-types this node. No runtime effect. */
as<S2 extends Spec>(): KVNode<S2> {
return this as unknown as KVNode<S2>
}
get(): Promise<ValueOf<S> | undefined>
get<K extends KeyOf<S>>(key: K): Promise<ValueOf<ChildOf<S, K>> | undefined>
async get(...args: unknown[]): Promise<unknown> {
const t = this.#resolve(args, 0)
return (await this.#ctx.driver.getMany([t.key]))[0]
}
async getMany<K extends KeyOf<S>>(keys: readonly K[]): Promise<(ValueOf<ChildOf<S, K>> | undefined)[]> {
if (keys.length === 0) return []
return (await this.#ctx.driver.getMany(keys.map((k) => this.#childKey(k)))) as (ValueOf<ChildOf<S, K>> | undefined)[]
}
set(value: ValueOf<S>): Promise<void>
set<K extends KeyOf<S>>(key: K, value: ValueOf<ChildOf<S, K>>): Promise<void>
async set(...args: unknown[]): Promise<void> {
const t = this.#resolve(args, 1)
const value = args[args.length - 1]
if (value === undefined) throw new TypeError('Cannot set undefined; use delete()')
return this.#write(t.idx, [{ key: t.key, esc: t.esc, value }])
}
async setMany(entries: readonly EntryOf<S>[]): Promise<void> {
if (entries.length === 0) return
const slots = entries.map(([k, value]): Slot => {
if (value === undefined) throw new TypeError('Cannot set undefined; use delete()')
const esc = escapeSegment(toSegment(k), this.#ctx.sep)
return { key: this.prefix + esc, esc, value }
})
return this.#write(this.#childIdx, slots)
}
/** Deletes the own value (or a child's own value) only, not descendants. */
delete(): Promise<void>
delete<K extends KeyOf<S>>(key: K): Promise<void>
async delete(...args: unknown[]): Promise<void> {
const t = this.#resolve(args, 0)
return this.#write(t.idx, [{ key: t.key, esc: t.esc, value: undefined }])
}
async deleteMany(keys: readonly KeyOf<S>[]): Promise<void> {
if (keys.length === 0) return
const slots = keys.map((k): Slot => {
const esc = escapeSegment(toSegment(k), this.#ctx.sep)
return { key: this.prefix + esc, esc, value: undefined }
})
return this.#write(this.#childIdx, slots)
}
/** Deletes the own value and all descendants (or a child's). On the root, deletes everything. */
deleteTree(): Promise<void>
deleteTree<K extends KeyOf<S>>(key: K): Promise<void>
async deleteTree(...args: unknown[]): Promise<void> {
if (args.length === 0 && this.isRoot) return this.#deleteRange('', undefined)
const t = this.#resolve(args, 0)
await this.#write(t.idx, [{ key: t.key, esc: t.esc, value: undefined }])
await this.#deleteRange(t.key + this.#ctx.sep, t.key + succ(this.#ctx.sep))
}
/**
* Direct child keys (unescaped), including children that only have descendants.
* Order: see `scanChildren` — lexicographic by escaped segment followed by the separator.
*/
async *keys(opts: KeysOptions = {}): AsyncGenerator<KeyString<S>> {
const after = opts.after === undefined ? undefined : toSegment(opts.after)
for await (const hit of scanChildren(this.#ctx.driver, this.prefix, this.end, this.#ctx.sep, {
after,
reverse: opts.reverse,
limit: opts.limit,
})) {
yield unescapeSegment(hit.esc) as KeyString<S>
}
}
/** Direct children that have an own value, as `[key, value]`. */
async *entries(opts: KeysOptions = {}): AsyncGenerator<StringEntryOf<S>> {
const after = opts.after === undefined ? undefined : toSegment(opts.after)
let remaining = opts.limit ?? Infinity
if (remaining <= 0) return
for await (const hit of scanChildren(this.#ctx.driver, this.prefix, this.end, this.#ctx.sep, {
after,
reverse: opts.reverse,
values: true,
})) {
if (!hit.hasOwn) continue
yield [unescapeSegment(hit.esc), hit.value] as StringEntryOf<S>
if (--remaining <= 0) return
}
}
/**
* Assembles descendants up to `depth` levels into a plain object: leaves become values,
* nodes with children become objects. A node with both an own value and children exposes the
* own value under the `''` property (empty keys are invalid, so it can't collide).
* Reserved (`~…`) subtrees are skipped. This node's own value is not included.
*/
async tree<T = Record<string, unknown>>(opts: TreeOptions): Promise<T> {
const { depth } = opts
if (!(depth >= 1)) throw new RangeError('tree() depth must be >= 1')
const { driver, sep } = this.#ctx
type TNode = { own?: unknown; has: boolean; kids?: Map<string, TNode> }
const root: TNode = { has: false }
const prefix = this.prefix
let it = driver.scan({ gte: prefix, lt: this.end, values: true })[Symbol.asyncIterator]()
const reseek = async (gte: string) => {
await it.return?.()
it = driver.scan({ gte, lt: this.end, values: true })[Symbol.asyncIterator]()
}
try {
outer: for (;;) {
const r = await it.next()
if (r.done) break
const [key, value] = r.value
const segs = key.slice(prefix.length).split(sep)
let base = prefix
for (let i = 0; i < segs.length && i < depth; i++) {
if (segs[i]!.startsWith('~')) {
await reseek(base + succ('~'))
continue outer
}
base += segs[i]! + sep
}
if (segs.length > depth) {
await reseek(prefix + segs.slice(0, depth).join(sep) + succ(sep))
continue
}
let node = root
for (const s of segs) {
const kids = (node.kids ??= new Map())
const seg = unescapeSegment(s)
let next = kids.get(seg)
if (!next) kids.set(seg, (next = { has: false }))
node = next
}
node.own = value
node.has = true
}
} finally {
await it.return?.()
}
const build = (n: TNode): unknown => {
if (!n.kids) return n.own
const obj: Record<string, unknown> = {}
if (n.has) obj[''] = n.own
for (const [k, v] of n.kids) obj[k] = build(v)
return obj
}
return (root.kids ? build(root) : {}) as T
}
/**
* Looks up direct children by an index declared for this node, by exact value or range.
* Each hit's value is re-read and re-checked; stale entries are skipped (and deleted when
* `readRepair` is on). Multi-entry hits are de-duplicated.
*/
async *findBy(index: string, query: IndexQuery, opts: FindByOptions = {}): AsyncGenerator<StringEntryOf<S>> {
const idx = this.#childIdx
if (!idx || !idx.has(index)) throw new TypeError(`No index "${index}" on node "${this.selfKey}"`)
const { driver, readRepair } = this.#ctx
const { gte, lt } = idx.range(index, query)
let remaining = opts.limit ?? Infinity
if (remaining <= 0) return
const seen = isIndexRange(query) ? new Set<string>() : undefined
let chunk: string[] = []
const it = driver.scan({ gte, lt, reverse: opts.reverse })[Symbol.asyncIterator]()
// Processes one chunk of entry keys; returns false when the limit is reached.
const flush = async function* (this: KVNode<S>): AsyncGenerator<StringEntryOf<S>, boolean> {
const entries = chunk
chunk = []
const escs = entries.map((e) => idx.childOf(index, e))
const values = await driver.getMany(escs.map((e) => this.prefix + e))
const stale: string[] = []
try {
for (let i = 0; i < entries.length; i++) {
const esc = escs[i]!
if (!idx.matches(index, esc, values[i], entries[i]!)) {
stale.push(entries[i]!)
continue
}
if (seen) {
if (seen.has(esc)) continue
seen.add(esc)
}
yield [unescapeSegment(esc), values[i]] as StringEntryOf<S>
if (--remaining <= 0) return false
}
return true
} finally {
if (readRepair && stale.length) await driver.deleteMany(stale)
}
}
try {
for (;;) {
const r = await it.next()
if (r.done) break
chunk.push(r.value[0])
// Small first chunk so `findOne`-style lookups cost one probe.
if (chunk.length >= (remaining < FIND_CHUNK ? remaining : FIND_CHUNK)) {
if (!(yield* flush.call(this))) return
}
}
if (chunk.length) yield* flush.call(this)
} finally {
await it.return?.()
}
}
// ---------------------------------------------------------------- internals
#childKey(key: unknown): string {
return this.prefix + escapeSegment(toSegment(key), this.#ctx.sep)
}
/** Self vs child by argument count; `selfArity` is the self form's argument count. */
#resolve(args: readonly unknown[], selfArity: number): { key: string; esc: string; idx: BoundIndexes | undefined } {
if (args.length <= selfArity) {
if (this.isRoot) throw new TypeError('The root node has no own value')
return { key: this.selfKey, esc: this.#esc, idx: this.#selfIdx }
}
const esc = escapeSegment(toSegment(args[0]), this.#ctx.sep)
return { key: this.prefix + esc, esc, idx: this.#childIdx }
}
/** Writes slots (`value: undefined` = delete), maintaining indexes when present. */
async #write(idx: BoundIndexes | undefined, slots: Slot[]): Promise<void> {
const { driver } = this.#ctx
if (!idx) {
const sets: [string, unknown][] = []
const dels: string[] = []
for (const s of slots) {
if (s.value === undefined) dels.push(s.key)
else sets.push([s.key, s.value])
}
if (sets.length) await driver.setMany(sets)
if (dels.length) await driver.deleteMany(dels)
return
}
// Last write per key wins.
const byKey = new Map<string, Slot>()
for (const s of slots) byKey.set(s.key, s)
const unique = [...byKey.values()]
const old = await driver.getMany(unique.map((s) => s.key))
const adds: string[] = []
const removes: string[] = []
for (let i = 0; i < unique.length; i++) {
const s = unique[i]!
const before = idx.entriesFor(s.esc, old[i])
const after = idx.entriesFor(s.esc, s.value)
for (const e of after) if (!before.has(e)) adds.push(e)
for (const e of before) if (!after.has(e)) removes.push(e)
}
if (driver.batch) {
const ops: BatchOp[] = []
for (const k of adds) ops.push({ type: 'set', key: k, value: true })
for (const s of unique) ops.push(s.value === undefined ? { type: 'delete', key: s.key } : { type: 'set', key: s.key, value: s.value })
for (const k of removes) ops.push({ type: 'delete', key: k })
return driver.batch(ops)
}
// Non-atomic: adds first, then values, then removes. Entries are hints; findBy re-checks.
if (adds.length) await driver.setMany(adds.map((k) => [k, true]))
const sets = unique.filter((s) => s.value !== undefined)
const dels = unique.filter((s) => s.value === undefined)
if (sets.length) await driver.setMany(sets.map((s) => [s.key, s.value]))
if (dels.length) await driver.deleteMany(dels.map((s) => s.key))
if (removes.length) await driver.deleteMany(removes)
}
async #deleteRange(gte: string, lt: string | undefined): Promise<void> {
const { driver } = this.#ctx
if (lt !== undefined && driver.deleteRange) return driver.deleteRange(gte, lt)
for (;;) {
const keys: string[] = []
for await (const [k] of driver.scan({ gte, lt, limit: DELETE_CHUNK })) keys.push(k)
if (keys.length === 0) return
await driver.deleteMany(keys)
if (keys.length < DELETE_CHUNK) return
}
}
}
/** The root `KVNode` over a driver. */
export class KVStorage<S extends Spec = AnySpec> extends KVNode<S> {
constructor(driver: KVDriver, opts: KVStorageOptions = {}) {
const sep = opts.separator ?? DEFAULT_SEPARATOR
validateSeparator(sep)
const registry = new IndexRegistry(opts.indexes ?? {}, sep)
super({ driver, sep, registry, readRepair: opts.readRepair ?? true }, [], '', '', registry.trie.rootState, undefined)
}
}
-181
View File
@@ -1,181 +0,0 @@
/**
* Ordered string-keyed map used by the in-memory driver. Kept behind an interface so the
* implementation can be swapped (e.g. for a B-tree) without touching the driver.
*/
export interface SortedMap<V> {
readonly size: number
get(key: string): V | undefined
has(key: string): boolean
set(key: string, value: V): void
/** Inserts many entries; may be faster than repeated `set`. */
setMany(entries: Iterable<[string, V]>): void
delete(key: string): boolean
/**
* Keys in `[gte, lt)` in order (descending when `reverse`). The iterator stays valid across
* mutations between steps: it resumes after (before) the last key it returned.
*/
keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator<string>
}
/**
* Sorted chunks of at most `maxChunk` keys (split in half on overflow) plus a `Map` for values.
* Inserts and deletes cost O(log n + maxChunk); iteration is O(1) per step while the map
* is unchanged and re-seeks in O(log n) after a mutation.
*/
export class ChunkedSortedMap<V> implements SortedMap<V> {
constructor(readonly maxChunk = 1024) {}
#chunks: string[][] = [[]]
readonly #values = new Map<string, V>()
#version = 0
get size(): number {
return this.#values.size
}
get(key: string): V | undefined {
return this.#values.get(key)
}
has(key: string): boolean {
return this.#values.has(key)
}
set(key: string, value: V): void {
if (!this.#values.has(key)) this.#insert(key)
this.#values.set(key, value)
}
setMany(entries: Iterable<[string, V]>): void {
const fresh: string[] = []
for (const [key, value] of entries) {
if (!this.#values.has(key)) fresh.push(key)
this.#values.set(key, value)
}
if (fresh.length === 0) return
if (fresh.length < 64 || fresh.length * 8 < this.#values.size) {
for (const key of fresh) this.#insert(key)
return
}
// Bulk path: merge and re-chunk, O(n + k log k).
fresh.sort(cmp)
const old = this.#chunks.flat()
const merged = new Array<string>(old.length + fresh.length)
let i = 0
let j = 0
let k = 0
while (i < old.length && j < fresh.length) merged[k++] = old[i]! < fresh[j]! ? old[i++]! : fresh[j++]!
while (i < old.length) merged[k++] = old[i++]!
while (j < fresh.length) merged[k++] = fresh[j++]!
const half = Math.max(1, this.maxChunk >>> 1)
this.#chunks = []
for (let s = 0; s < merged.length; s += half) this.#chunks.push(merged.slice(s, s + half))
this.#version++
}
delete(key: string): boolean {
if (!this.#values.delete(key)) return false
const c = this.#chunkFor(key)
const chunk = this.#chunks[c]!
chunk.splice(lowerBound(chunk, key), 1)
if (chunk.length === 0 && this.#chunks.length > 1) this.#chunks.splice(c, 1)
this.#version++
return true
}
*keys(range: { gte?: string; lt?: string; reverse?: boolean }): IterableIterator<string> {
const { gte, lt, reverse = false } = range
let version = this.#version
let c: number
let i: number
// Position of the first candidate.
if (reverse) {
;[c, i] = lt === undefined ? this.#end() : this.#seek(lt, false)
;[c, i] = this.#prev(c, i)
} else {
;[c, i] = gte === undefined ? [0, 0] : this.#seek(gte, false)
;[c, i] = this.#normalize(c, i)
}
for (;;) {
const key = this.#chunks[c]?.[i]
if (key === undefined || c < 0) return
if (reverse ? gte !== undefined && key < gte : lt !== undefined && key >= lt) return
yield key
if (this.#version !== version) {
version = this.#version
;[c, i] = this.#seek(key, !reverse)
if (reverse) [c, i] = this.#prev(c, i)
else [c, i] = this.#normalize(c, i)
} else if (reverse) [c, i] = this.#prev(c, i)
else [c, i] = this.#normalize(c, i + 1)
}
}
#insert(key: string): void {
const c = this.#chunkFor(key)
const chunk = this.#chunks[c]!
chunk.splice(lowerBound(chunk, key), 0, key)
if (chunk.length > this.maxChunk) this.#chunks.splice(c + 1, 0, chunk.splice(this.maxChunk >>> 1))
this.#version++
}
/** Index of the last chunk whose first key is <= key (0 if none). */
#chunkFor(key: string): number {
const chunks = this.#chunks
let lo = 0
let hi = chunks.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1
if (chunks[mid]![0]! <= key) lo = mid
else hi = mid - 1
}
return lo
}
/** Position of the first key `>= key` (`> key` when exclusive); may point one past a chunk's end. */
#seek(key: string, exclusive: boolean): [number, number] {
const c = this.#chunkFor(key)
const chunk = this.#chunks[c]!
let i = lowerBound(chunk, key)
if (exclusive && chunk[i] === key) i++
return [c, i]
}
#end(): [number, number] {
const c = this.#chunks.length - 1
return [c, this.#chunks[c]!.length]
}
/** Moves a position that is past its chunk's end to the start of the next chunk. */
#normalize(c: number, i: number): [number, number] {
while (c < this.#chunks.length && i >= this.#chunks[c]!.length) {
c++
i = 0
}
return [c, i]
}
#prev(c: number, i: number): [number, number] {
i--
while (i < 0 && c > 0) {
c--
i = this.#chunks[c]!.length - 1
}
return i < 0 ? [-1, -1] : [c, i]
}
}
function lowerBound(arr: string[], key: string): number {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (arr[mid]! < key) lo = mid + 1
else hi = mid
}
return lo
}
function cmp(a: string, b: string): number {
return a < b ? -1 : a > b ? 1 : 0
}
-1
View File
@@ -1 +0,0 @@
export * from './kv-conformance.js'
-171
View File
@@ -1,171 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { KVDriver, ScanOptions } from '../kv/driver.js'
/**
* Reusable conformance suite for {@link KVDriver} implementations. Call it from a Vitest test file:
*
* ```ts
* runKVDriverConformance('MyDriver', () => new MyDriver())
* ```
*
* `makeDriver` must return an empty driver each time.
*/
export function runKVDriverConformance(name: string, makeDriver: () => KVDriver | Promise<KVDriver>): void {
describe(`KVDriver conformance: ${name}`, () => {
let d: KVDriver
beforeEach(async () => {
d = await makeDriver()
})
async function collect(opts: ScanOptions): Promise<[string, unknown][]> {
const out: [string, unknown][] = []
for await (const e of d.scan(opts)) out.push(e)
return out
}
const keysOf = async (opts: ScanOptions) => (await collect(opts)).map(([k]) => k)
async function seed(): Promise<void> {
// Deliberately inserted out of order.
await d.setMany([
['b', 2],
['a/2', { n: 'a2' }],
['c', [3]],
['a', 1],
['a/1', 'a1'],
['a0', null],
['10', 10],
['9', 9],
])
}
it('round-trips JSON values with getMany in key order, undefined for missing', async () => {
await seed()
expect(await d.getMany(['c', 'missing', 'a/2', 'a0', 'a'])).toEqual([[3], undefined, { n: 'a2' }, null, 1])
expect(await d.getMany([])).toEqual([])
})
it('overwrites on setMany and deletes with deleteMany', async () => {
await seed()
await d.setMany([['a', 'one']])
await d.deleteMany(['b', 'missing'])
expect(await d.getMany(['a', 'b'])).toEqual(['one', undefined])
})
it('scans all keys in lexicographic order', async () => {
await seed()
expect(await keysOf({})).toEqual(['10', '9', 'a', 'a/1', 'a/2', 'a0', 'b', 'c'])
})
it('honours gte (inclusive) and lt (exclusive)', async () => {
await seed()
expect(await keysOf({ gte: 'a/', lt: 'a0' })).toEqual(['a/1', 'a/2'])
expect(await keysOf({ gte: 'a', lt: 'b' })).toEqual(['a', 'a/1', 'a/2', 'a0'])
expect(await keysOf({ gte: 'a0' })).toEqual(['a0', 'b', 'c'])
expect(await keysOf({ lt: 'a' })).toEqual(['10', '9'])
expect(await keysOf({ gte: 'zz' })).toEqual([])
expect(await keysOf({ gte: 'b', lt: 'b' })).toEqual([])
})
it('scans in reverse within bounds', async () => {
await seed()
expect(await keysOf({ reverse: true })).toEqual(['c', 'b', 'a0', 'a/2', 'a/1', 'a', '9', '10'])
expect(await keysOf({ reverse: true, gte: 'a', lt: 'a0' })).toEqual(['a/2', 'a/1', 'a'])
})
it('honours limit, forwards and in reverse', async () => {
await seed()
expect(await keysOf({ limit: 2 })).toEqual(['10', '9'])
expect(await keysOf({ limit: 2, reverse: true, lt: 'b' })).toEqual(['a0', 'a/2'])
expect(await keysOf({ limit: 0 })).toEqual([])
})
it('yields values only when values: true', async () => {
await seed()
expect(await collect({ gte: 'a/', lt: 'a0' })).toEqual([
['a/1', undefined],
['a/2', undefined],
])
expect(await collect({ gte: 'a/', lt: 'a0', values: true })).toEqual([
['a/1', 'a1'],
['a/2', { n: 'a2' }],
])
})
it('tolerates writes while a scan is suspended', async () => {
await d.setMany([
['k1', 1],
['k2', 2],
['k3', 3],
])
const seen: string[] = []
for await (const [key] of d.scan({})) {
seen.push(key)
if (key === 'k1') await d.deleteMany(['k1'])
}
expect(seen).toEqual(['k1', 'k2', 'k3'])
})
it('does not let callers mutate stored values through returned references', async () => {
await d.setMany([['o', { n: 1 }]])
const [v] = (await d.getMany(['o'])) as [{ n: number }]
v.n = 2
expect(await d.getMany(['o'])).toEqual([{ n: 1 }])
})
it('stores unicode keys and orders them by code unit', async () => {
await d.setMany([
['é', 1],
['z', 2],
['日本', 3],
])
expect(await keysOf({})).toEqual(['z', 'é', '日本'])
})
describe('optional: setIfAbsent', () => {
it('writes only absent keys and reports which were written', async (ctx) => {
if (!d.setIfAbsent) return ctx.skip()
await d.setMany([['x', 1]])
expect(
await d.setIfAbsent([
['x', 2],
['y', 3],
]),
).toEqual([false, true])
expect(await d.getMany(['x', 'y'])).toEqual([1, 3])
})
it('only the first of concurrent claims wins', async (ctx) => {
if (!d.setIfAbsent) return ctx.skip()
const results = await Promise.all([1, 2, 3, 4].map((n) => d.setIfAbsent!([['claim', n]])))
expect(results.flat().filter(Boolean)).toHaveLength(1)
})
})
describe('optional: batch', () => {
it('applies sets and deletes in order', async (ctx) => {
if (!d.batch) return ctx.skip()
await d.setMany([['gone', 1]])
await d.batch([
{ type: 'set', key: 'p', value: 1 },
{ type: 'delete', key: 'gone' },
{ type: 'set', key: 'q', value: { a: [1] } },
{ type: 'set', key: 'p', value: 2 },
])
expect(await d.getMany(['p', 'q', 'gone'])).toEqual([2, { a: [1] }, undefined])
})
})
describe('optional: deleteRange', () => {
it('deletes exactly [gte, lt)', async (ctx) => {
if (!d.deleteRange) return ctx.skip()
await seed()
await d.deleteRange('a/', 'a0')
expect(await keysOf({})).toEqual(['10', '9', 'a', 'a0', 'b', 'c'])
await d.deleteRange('a', 'b')
expect(await keysOf({})).toEqual(['10', '9', 'b', 'c'])
})
})
})
}
+32
View File
@@ -0,0 +1,32 @@
import { Json } from './json.js'
export type Doc = Record<string, unknown>
/** Dot-path access into plain JSON documents (`'address.city'`, `'tags.0'`). */
export class DocumentPath {
static get(doc: unknown, path: string): unknown {
let value = doc
for (const segment of path.split('.')) {
if (Array.isArray(value) || Json.isObject(value)) value = (value as Record<string, unknown>)[segment]
else return undefined
}
return value
}
/** Sets a path in place, creating intermediate objects. */
static set(doc: Doc, path: string, value: unknown): void {
const segments = path.split('.')
let target = doc as Record<string, unknown>
for (const segment of segments.slice(0, -1)) {
if (typeof target[segment] !== 'object' || target[segment] === null) target[segment] = {}
target = target[segment] as Record<string, unknown>
}
target[segments.at(-1)!] = value
}
static unset(doc: Doc, path: string): void {
const segments = path.split('.')
const parent = segments.length === 1 ? doc : DocumentPath.get(doc, segments.slice(0, -1).join('.'))
if (Json.isObject(parent)) delete parent[segments.at(-1)!]
}
}
+106
View File
@@ -0,0 +1,106 @@
import { DocumentPath, type Doc } from './document-path.js'
import { Json } from './json.js'
export type Comparison = { $eq?: unknown; $gt?: unknown; $gte?: unknown; $lt?: unknown; $lte?: unknown; $in?: unknown[] }
/** Field (dot path) → value (equality) or comparison operators. All fields must match. */
export type Filter = Record<string, unknown>
export interface FindOptions {
/** Field → 1 (ascending) or -1 (descending). */
sort?: Record<string, 1 | -1>
limit?: number
skip?: number
}
export interface FindResult<T = Doc> {
key: string
data: T
}
const OPERATORS = new Set(['$eq', '$gt', '$gte', '$lt', '$lte', '$in'])
/**
* Filter evaluation, sorting and paging for document stores that don't query natively.
*
* - `{ field: value }` matches equal values; `null` also matches a missing field.
* - Comparisons only match values of the same type (`$gt: 5` never matches a string).
* - On an array field, a condition matches if the array itself or any one element satisfies it.
*/
export class DocumentQuery {
constructor(
readonly filter: Filter = {},
readonly options: FindOptions = {},
) {}
matches(doc: Doc): boolean {
return Object.entries(this.filter).every(([path, condition]) => DocumentQuery.matchesValue(DocumentPath.get(doc, path), condition))
}
/** Whether one field value satisfies a condition. */
static matchesValue(value: unknown, condition: unknown): boolean {
const candidates = Array.isArray(value) ? [value, ...value] : [value]
if (!DocumentQuery.isComparison(condition)) return candidates.some((c) => DocumentQuery.equal(c, condition))
const ops = Object.entries(condition)
return candidates.some((c) => ops.every(([op, arg]) => DocumentQuery.compare(op, c, arg)))
}
/** Applies filter, sort, skip and limit to a stream of candidates. */
async *run(source: AsyncIterable<FindResult>): AsyncGenerator<FindResult> {
const { sort, skip = 0, limit = Infinity } = this.options
if (limit <= 0) return
if (sort && Object.keys(sort).length) {
const all: FindResult[] = []
for await (const result of source) if (this.matches(result.data)) all.push(result)
all.sort((a, b) => this.compareResults(a, b))
yield* all.slice(skip, skip + limit)
return
}
let skipped = 0
let emitted = 0
for await (const result of source) {
if (!this.matches(result.data)) continue
if (skipped++ < skip) continue
yield result
if (++emitted >= limit) return
}
}
private compareResults(a: FindResult, b: FindResult): number {
for (const [path, direction] of Object.entries(this.options.sort!)) {
const order = DocumentQuery.order(DocumentPath.get(a.data, path), DocumentPath.get(b.data, path))
if (order !== 0) return order * direction
}
return a.key < b.key ? -1 : a.key > b.key ? 1 : 0
}
private static isComparison(condition: unknown): condition is Comparison {
if (!Json.isObject(condition)) return false
const keys = Object.keys(condition)
if (keys.length === 0 || !keys.every((k) => k.startsWith('$'))) return false
for (const k of keys) if (!OPERATORS.has(k)) throw new TypeError(`Unsupported operator ${k}`)
return true
}
private static equal(value: unknown, expected: unknown): boolean {
return expected === null ? value === null || value === undefined : Json.equal(value, expected)
}
private static compare(op: string, value: unknown, arg: unknown): boolean {
if (op === '$eq') return DocumentQuery.equal(value, arg)
if (op === '$in') return (arg as unknown[]).some((x) => DocumentQuery.equal(value, x))
if (typeof value !== typeof arg || !['number', 'string', 'boolean'].includes(typeof value)) return false
const order = DocumentQuery.order(value, arg)
return op === '$gt' ? order > 0 : op === '$gte' ? order >= 0 : op === '$lt' ? order < 0 : order <= 0
}
/** Total order: missing/null < booleans < numbers < strings < anything else. */
static order(a: unknown, b: unknown): number {
const rank = (v: unknown) => (v === undefined || v === null ? 0 : (({ boolean: 1, number: 2, string: 3 } as Record<string, number>)[typeof v] ?? 4))
const ra = rank(a)
const rb = rank(b)
if (ra !== rb) return ra - rb
if (ra === 4) [a, b] = [JSON.stringify(a), JSON.stringify(b)]
return a === b ? 0 : (a as number) < (b as number) ? -1 : 1
}
}
+75
View File
@@ -0,0 +1,75 @@
import { DocumentPath, type Doc } from './document-path.js'
import { DocumentQuery } from './document-query.js'
import { Json } from './json.js'
/** Update operators. Paths may use dots. `$push`/`$addToSet` accept `{ $each: [...] }`. */
export interface UpdateSpec {
$set?: Record<string, unknown>
$unset?: Record<string, unknown>
$inc?: Record<string, number>
$push?: Record<string, unknown>
$pull?: Record<string, unknown>
$addToSet?: Record<string, unknown>
}
/**
* A resolved change to one document. Operators are resolved to final values (`$inc` becomes
* `set: { n: 6 }`), so replaying changes is deterministic.
*/
export type DocChange = { op: 'set'; doc: Doc } | { op: 'patch'; set: Record<string, unknown>; unset: string[] } | { op: 'delete' }
export class DocumentUpdate {
/** Resolves update operators against the current state into a patch. */
static resolve(state: Doc, spec: UpdateSpec): DocChange {
const set: Record<string, unknown> = {}
const unset: string[] = []
const array = (path: string) => {
const current = DocumentPath.get(state, path) ?? []
if (!Array.isArray(current)) throw new TypeError(`"${path}" is not an array`)
return Json.clone(current)
}
const items = (value: unknown) => (Json.isObject(value) && Array.isArray(value.$each) ? value.$each : [value])
for (const [op, fields] of Object.entries(spec)) {
for (const [path, value] of Object.entries(fields as Record<string, unknown>)) {
if (path in set || unset.includes(path)) throw new TypeError(`"${path}" is updated twice`)
switch (op) {
case '$set':
set[path] = Json.clone(value)
break
case '$unset':
unset.push(path)
break
case '$inc': {
const current = DocumentPath.get(state, path) ?? 0
if (typeof current !== 'number' || typeof value !== 'number') throw new TypeError(`$inc needs numbers at "${path}"`)
set[path] = current + value
break
}
case '$push':
set[path] = [...array(path), ...Json.clone(items(value))]
break
case '$addToSet':
set[path] = items(value).reduce<unknown[]>((acc, item) => (acc.some((x) => Json.equal(x, item)) ? acc : [...acc, Json.clone(item)]), array(path))
break
case '$pull':
set[path] = array(path).filter((item) => !DocumentQuery.matchesValue(item, value))
break
default:
throw new TypeError(`Unsupported update operator ${op}`)
}
}
}
return { op: 'patch', set, unset }
}
/** Applies a change to a state (`undefined` = no document). */
static apply(state: Doc | undefined, change: DocChange): Doc | undefined {
if (change.op === 'delete') return undefined
if (change.op === 'set') return Json.clone(change.doc)
const next = Json.clone(state ?? {})
for (const [path, value] of Object.entries(change.set)) DocumentPath.set(next, path, Json.clone(value))
for (const path of change.unset) DocumentPath.unset(next, path)
return next
}
}
+19
View File
@@ -0,0 +1,19 @@
/** An optimistic-concurrency check failed (stale `expectedVersion` or a lost commit race). */
export class ConcurrencyError extends Error {
constructor(
readonly stream: string,
readonly expectedVersion: number | 'any',
readonly actualVersion: number,
) {
super(`Concurrency conflict on "${stream}": expected version ${expectedVersion}, actual ${actualVersion}`)
this.name = 'ConcurrencyError'
}
}
/** A key argument was passed but is `undefined`/`null`, empty, or not a string/number. */
export class InvalidKeyError extends TypeError {
constructor(message: string) {
super(message)
this.name = 'InvalidKeyError'
}
}
+24
View File
@@ -0,0 +1,24 @@
/** Helpers for JSON-compatible values (all stored values are JSON by contract). */
export class Json {
/** Deep copy; much cheaper than `structuredClone` for plain JSON. */
static clone<T>(value: T): T {
if (value === null || typeof value !== 'object') return value
if (Array.isArray(value)) return value.map((v) => Json.clone(v)) as T
const out: Record<string, unknown> = {}
for (const k in value) out[k] = Json.clone((value as Record<string, unknown>)[k])
return out as T
}
static equal(a: unknown, b: unknown): boolean {
if (a === b) return true
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
if (Array.isArray(a) !== Array.isArray(b)) return false
const ka = Object.keys(a)
const kb = Object.keys(b)
return ka.length === kb.length && ka.every((k) => Json.equal((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]))
}
static isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
}
+43
View File
@@ -0,0 +1,43 @@
import { InvalidKeyError } from './errors.js'
/**
* Key encoding. A node's key is its escaped path segments joined by `/`.
*
* Every character that sorts at or below `/` (and `~` itself) is escaped as `~XX` (hex). Escaped
* segments therefore only contain characters above `/`, so all keys of one child sort together:
* `p/a`, `p/a/...`, then siblings like `p/a~2D1` (`a-1`). That keeps child listing a simple skip-scan.
*
* Segments starting with `~` + a lowercase letter are reserved for internal use (e.g. `~e` for
* event commits); escaping only ever produces `~` + uppercase hex, so user keys can't collide.
*/
export class Keys {
static readonly separator = '/'
/** The character right after `/`: `prefix + segment + '0'` is the first key past a subtree. */
static readonly afterSeparator = '0'
/** Validates a user key and converts it to a segment. */
static segment(key: unknown): string {
if (key === undefined || key === null) throw new InvalidKeyError(`Key must not be ${key}`)
if (typeof key === 'number' && Number.isFinite(key)) return String(key)
if (typeof key !== 'string') throw new InvalidKeyError(`Key must be a string or finite number, got ${String(key)}`)
if (key === '') throw new InvalidKeyError('Key must not be empty')
return key
}
static escape(segment: string): string {
if (!/[\x00-\x2f~]/.test(segment)) return segment
return segment.replace(/[\x00-\x2f~]/g, (c) => '~' + c.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'))
}
static unescape(segment: string): string {
return segment.includes('~') ? segment.replace(/~([0-9A-F]{2})/g, (_, hex: string) => String.fromCharCode(parseInt(hex, 16))) : segment
}
static join(path: readonly string[]): string {
return path.map((s) => Keys.escape(s)).join(Keys.separator)
}
static isReserved(escapedSegment: string): boolean {
return /^~[a-z]/.test(escapedSegment)
}
}
+4 -1
View File
@@ -15,13 +15,16 @@ export type ChildrenOf<S> = S extends { children: infer C } ? C : {}
export type KeyOf<S> = Extract<keyof ChildrenOf<S>, string | number>
export type ChildOf<S, K extends KeyOf<S>> = ChildrenOf<S>[K] extends infer C extends Spec ? C : never
/** Value of this node (`K = undefined`) or of child `K`. */
export type ValueAt<S, K> = [K] extends [KeyOf<S>] ? ValueOf<ChildOf<S, K>> : ValueOf<S>
/** Child keys as yielded at runtime (always strings). */
export type KeyString<S> = `${KeyOf<S>}`
/** `[key, value]` for each child key, as a union of precise pairs. */
export type EntryOf<S> = { [K in KeyOf<S>]: [K, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
/** `[key, value]` as yielded at runtime (string keys). Dynamic levels collapse to `[string, V]`. */
/** `[key, value]` as yielded at runtime (string keys). */
export type StringEntryOf<S> =
string extends KeyOf<S> ? [string, ValueOf<ChildOf<S, KeyOf<S> & string>>] : { [K in KeyOf<S>]: [`${K}`, ValueOf<ChildOf<S, K>>] }[KeyOf<S>]
+103
View File
@@ -0,0 +1,103 @@
/**
* Ordered string-keyed map: sorted chunks of at most `maxChunk` keys (split in half when full)
* plus a `Map` for values. Inserts and deletes cost O(log n + maxChunk).
*/
export class SortedMap<V> {
private chunks: string[][] = [[]]
private readonly values = new Map<string, V>()
/** Bumped on every insert/delete so iterators know to re-seek. */
private version = 0
constructor(private readonly maxChunk = 1024) {}
get size(): number {
return this.values.size
}
get(key: string): V | undefined {
return this.values.get(key)
}
has(key: string): boolean {
return this.values.has(key)
}
set(key: string, value: V): void {
if (!this.values.has(key)) {
const c = this.chunkFor(key)
const chunk = this.chunks[c]!
chunk.splice(SortedMap.lowerBound(chunk, key), 0, key)
if (chunk.length > this.maxChunk) this.chunks.splice(c + 1, 0, chunk.splice(this.maxChunk >>> 1))
this.version++
}
this.values.set(key, value)
}
delete(key: string): boolean {
if (!this.values.delete(key)) return false
const c = this.chunkFor(key)
const chunk = this.chunks[c]!
chunk.splice(SortedMap.lowerBound(chunk, key), 1)
if (chunk.length === 0 && this.chunks.length > 1) this.chunks.splice(c, 1)
this.version++
return true
}
/**
* Keys in `[gte, lt)`, descending when `reverse`. Safe across mutations between steps:
* after a change it re-seeks from the last key returned.
*/
*keys(gte?: string, lt?: string, reverse = false): IterableIterator<string> {
let last: string | undefined
let version = -1
let c = 0
let i = 0
for (;;) {
if (version !== this.version) {
version = this.version
const from = last ?? (reverse ? lt : gte)
if (from === undefined) {
c = reverse ? this.chunks.length - 1 : 0
i = reverse ? this.chunks[c]!.length - 1 : 0
} else {
c = this.chunkFor(from)
i = SortedMap.lowerBound(this.chunks[c]!, from)
// Forward: skip `last` itself. Reverse: step to the key before `from`.
if (reverse) i--
else if (last !== undefined && this.chunks[c]![i] === last) i++
}
}
// Move across chunk boundaries.
while (!reverse && c < this.chunks.length - 1 && i >= this.chunks[c]!.length) (c++, (i = 0))
while (reverse && c > 0 && i < 0) i = this.chunks[--c]!.length - 1
const key = this.chunks[c]?.[i]
if (key === undefined || (reverse ? gte !== undefined && key < gte : lt !== undefined && key >= lt)) return
last = key
yield key
i += reverse ? -1 : 1
}
}
/** Index of the last chunk whose first key is <= key (0 if none). */
private chunkFor(key: string): number {
let lo = 0
let hi = this.chunks.length - 1
while (lo < hi) {
const mid = (lo + hi + 1) >>> 1
if (this.chunks[mid]![0]! <= key) lo = mid
else hi = mid - 1
}
return lo
}
private static lowerBound(arr: string[], key: string): number {
let lo = 0
let hi = arr.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (arr[mid]! < key) lo = mid + 1
else hi = mid
}
return lo
}
}
+36
View File
@@ -0,0 +1,36 @@
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ' // Crockford base32
/**
* ULID-like ids: 10 chars of millisecond time + 16 random chars, sortable by creation time.
* Ids from one generator within the same millisecond increment the random part, so they stay
* strictly increasing.
*/
export class Ulid {
private lastTime = -1
private readonly random = new Uint8Array(16) // one base32 digit (0–31) per byte
constructor(private readonly now: () => number = Date.now) {}
generate(): string {
let time = this.now()
if (time <= this.lastTime) {
time = this.lastTime
let i = 15
while (i >= 0 && this.random[i] === 31) this.random[i--] = 0
if (i < 0) throw new Error('ULID overflow within one millisecond')
this.random[i]!++
} else {
crypto.getRandomValues(this.random)
for (let i = 0; i < 16; i++) this.random[i]! &= 31
this.random[0]! &= 15 // headroom for increments
this.lastTime = time
}
let out = ''
for (let i = 0; i < 10; i++) {
out = ALPHABET[time % 32]! + out
time = Math.floor(time / 32)
}
for (const digit of this.random) out += ALPHABET[digit]
return out
}
}