diff --git a/DESIGN.md b/DESIGN.md index 215def5..e363137 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -397,6 +397,43 @@ Decisions taken where the brief was silent. Each is the simplest option consiste - **No fake `batch`**: `BaseKVDriver` deliberately does not provide a non-atomic `batch`, since stores use its presence to mean "atomic". +### M3 — KV node + +- **Child order is "subtree order", not plain segment order.** The brief's skip-scan assumes a + child's keys are contiguous, but they aren't when a sibling extends a segment with a character + that sorts below the separator (for `/`: space and `!"#$%&'()*+,-.`). With children `a` and `a-1` + the key space is `p/a`, `p/a-1`, `p/a-1/x`, `p/a/x`, so `a`'s descendants sort *after* `a-1`, and + "yield if different from the previous segment" would emit `a` twice. `keys()`/`entries()` therefore + order children by **escaped segment followed by the separator** (`a-1/` < `a/`), which is the order + of their subtrees in key space. For keys without such characters this is plain lexicographic order. + - Forward listing defers a child seen only through its own key on a small stack (bounded by + segment length) until the scan passes `p//`. It costs no extra driver calls beyond the + brief's one seek per child with descendants. + - Reverse listing needs one batched `getMany` probe per new child whose segment contains a + below-separator character, to find shorter siblings that only have an own key. + - `after` is exclusive in the direction of travel and follows the same order. + - Implemented once in `src/kv/children.ts` (`scanChildren`) and reused by the event and document + layers. A model-based property test checks forward, reverse, `after` and `limit` against + brute force with keys like `a`, `a-1`, `a.b`, `a/x`, `%`, `~t`. + - **Open question for the owner:** keep subtree order, or change segment escaping so the key + layout nests exactly. That would mean escaping every below-separator character with an escape + character above the separator, at the cost of the readable percent-encoding. +- **Errors from async methods are rejections.** Every async method (`get`, `set`, …) is an `async` + function, so a bad key yields a rejected promise rather than a synchronous throw. `child()` is sync + and throws synchronously. +- **`set(undefined)` is rejected** (`TypeError`: use `delete()`); `undefined` means "missing" in reads. +- **`deleteTree()` on the root** is allowed and clears the whole store (chunked scan + delete, since + the root range has no upper bound). Subtree deletion is `delete(selfKey)` plus + `deleteRange(prefix, selfKey + succ(sep))`. It is not `[selfKey, …)`, which would also catch + siblings such as `a!`. +- **`tree({ depth })`** builds a plain object: leaves are values, interior nodes are objects. A node + with both an own value and children exposes its own value under the `''` property (empty keys are + invalid, so it can't collide). Reserved subtrees are skipped. Deeper levels are skipped by seeking + rather than reading. +- **`keys()` and `entries()` yield string keys** (number keys come back as strings). +- **Exposed low-level properties** on every node: `path`, `selfKey`, `prefix`, `end` (subtree upper + bound), `driver`, `separator`, `isRoot`. The event and document layers build on these. + ## 13. Benchmark baselines diff --git a/src/core/trie.ts b/src/core/trie.ts new file mode 100644 index 0000000..f377f18 --- /dev/null +++ b/src/core/trie.ts @@ -0,0 +1,82 @@ +/** + * 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 { + readonly literal: Map> + star: TrieNode | undefined + values: { pattern: string; value: T }[] +} + +export type TrieState = readonly TrieNode[] + +export class PatternTrie { + readonly root: TrieNode = 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 { + return [this.root] + } + + step(state: TrieState, segment: string): TrieState { + 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[] = [] + 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 { + 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(state: TrieState): { pattern: string; value: T }[] { + if (state.length === 1) return state[0]!.values + return state.flatMap((n) => n.values) + } +} + +const EMPTY: TrieState = [] + +function newNode(): TrieNode { + 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]) +} diff --git a/src/index.ts b/src/index.ts index 2602b40..6aabc88 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,4 @@ export * from './errors.js' export * from './core/path.js' export * from './core/encoding.js' export * from './kv/index.js' +export * from './types/schema.js' diff --git a/src/kv/children.ts b/src/kv/children.ts new file mode 100644 index 0000000..b295233 --- /dev/null +++ b/src/kv/children.ts @@ -0,0 +1,236 @@ +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 { + 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 { + 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 { + 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?.() + } +} diff --git a/src/kv/index.ts b/src/kv/index.ts index e2d7035..cfab7c0 100644 --- a/src/kv/index.ts +++ b/src/kv/index.ts @@ -1,3 +1,6 @@ export * from './driver.js' export * from './memory.js' export * from './sorted.js' +export * from './node.js' +export * from './indexes.js' +export * from './children.js' diff --git a/src/kv/indexes.ts b/src/kv/indexes.ts new file mode 100644 index 0000000..2138b10 --- /dev/null +++ b/src/kv/indexes.ts @@ -0,0 +1,157 @@ +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 = (value: V) => IndexValue | readonly IndexValue[] | undefined + +/** Index name → function, for the direct children of nodes matching one pattern. */ +export type IndexDefs = Record + +/** Path pattern (`*` = one segment, `''` = root) → index definitions. */ +export type IndexDeclarations = Record + +/** 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 + readonly #list: CompiledIndex[] + readonly #codec: IndexValueCodec + + constructor( + defs: CompiledIndex[], + /** `~i` */ + 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 = new Set()): Set { + 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): 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() + 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 + 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, 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) + } +} diff --git a/src/kv/node.ts b/src/kv/node.ts new file mode 100644 index 0000000..0fd42ef --- /dev/null +++ b/src/kv/node.ts @@ -0,0 +1,403 @@ +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 { + /** 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 + 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, 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>(key: K): KVNode> { + 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(): KVNode { + return this as unknown as KVNode + } + + get(): Promise | undefined> + get>(key: K): Promise> | undefined> + async get(...args: unknown[]): Promise { + const t = this.#resolve(args, 0) + return (await this.#ctx.driver.getMany([t.key]))[0] + } + + async getMany>(keys: readonly K[]): Promise<(ValueOf> | undefined)[]> { + if (keys.length === 0) return [] + return (await this.#ctx.driver.getMany(keys.map((k) => this.#childKey(k)))) as (ValueOf> | undefined)[] + } + + set(value: ValueOf): Promise + set>(key: K, value: ValueOf>): Promise + async set(...args: unknown[]): Promise { + 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[]): Promise { + 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 + delete>(key: K): Promise + async delete(...args: unknown[]): Promise { + const t = this.#resolve(args, 0) + return this.#write(t.idx, [{ key: t.key, esc: t.esc, value: undefined }]) + } + + async deleteMany(keys: readonly KeyOf[]): Promise { + 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 + deleteTree>(key: K): Promise + async deleteTree(...args: unknown[]): Promise { + 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> { + 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 + } + } + + /** Direct children that have an own value, as `[key, value]`. */ + async *entries(opts: KeysOptions = {}): AsyncGenerator> { + 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 + 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>(opts: TreeOptions): Promise { + 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 } + 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 = {} + 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> { + 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() : 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): AsyncGenerator, 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 + 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 { + 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() + 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 { + 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 extends KVNode { + 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) + } +} + diff --git a/src/types/schema.ts b/src/types/schema.ts new file mode 100644 index 0000000..1c9ea94 --- /dev/null +++ b/src/types/schema.ts @@ -0,0 +1,31 @@ +/** + * A Spec types one node: its own value and its children by key. + * Named child levels check keys at compile time; `[id: string]` levels accept any string. + */ +export type Spec = { value?: unknown; children?: Record } + +/** Untyped default: any value, any children. */ +export interface AnySpec { + value: unknown + children: Record +} + +export type ValueOf = S extends { value: infer V } ? V : never +export type ChildrenOf = S extends { children: infer C } ? C : {} +export type KeyOf = Extract, string | number> +export type ChildOf> = ChildrenOf[K] extends infer C extends Spec ? C : never + +/** Child keys as yielded at runtime (always strings). */ +export type KeyString = `${KeyOf}` + +/** `[key, value]` for each child key, as a union of precise pairs. */ +export type EntryOf = { [K in KeyOf]: [K, ValueOf>] }[KeyOf] + +/** `[key, value]` as yielded at runtime (string keys). */ +export type StringEntryOf = { [K in KeyOf]: [`${K}`, ValueOf>] }[KeyOf] + +/** A dynamic level of children with values `V`, each with children `C`. */ +export type Many = {}> = { children: { [id: string]: { value: V; children: C } } } + +/** Field-per-key: each property of `T` is a named child holding that field's value. */ +export type Fields = { [K in keyof T & string]: { value: T[K] } } diff --git a/test/kv/node.test.ts b/test/kv/node.test.ts new file mode 100644 index 0000000..36232f0 --- /dev/null +++ b/test/kv/node.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from 'vitest' +import fc from 'fast-check' +import { KVStorage } from '../../src/kv/node.js' +import { InMemoryKVDriver } from '../../src/kv/memory.js' +import { escapeSegment } from '../../src/core/path.js' +import { InvalidKeyError } from '../../src/errors.js' +import { collect, CountingDriver, MinimalDriver } from '../helpers.js' + +function setup() { + const driver = new CountingDriver() + const db = new KVStorage(driver) + return { driver, db } +} + +describe('KVNode basics', () => { + it('reads and writes own and child values at the same key', async () => { + const { db } = setup() + const users = db.child('users') + await users.set('123', { name: 'Ada' }) + expect(await users.get('123')).toEqual({ name: 'Ada' }) + expect(await users.child('123').get()).toEqual({ name: 'Ada' }) + await users.child('123').set({ name: 'Bob' }) + expect(await users.get('123')).toEqual({ name: 'Bob' }) + expect(users.child('123').selfKey).toBe('users/123') + }) + + it('treats number keys as strings', async () => { + const { db } = setup() + await db.child('n').set(5, 'five') + expect(await db.child('n').get('5')).toBe('five') + }) + + it('escapes keys containing the separator, % and leading ~', async () => { + const { db, driver } = setup() + const n = db.child('a/b') + await n.set('~x', 1) + await n.set('50%', 2) + expect(await collect(driver.inner.scan({}))).toEqual([ + ['a%2Fb/%7Ex', undefined], + ['a%2Fb/50%25', undefined], + ]) + // Ordered by escaped segment: '%7Ex' < '50%25'. + expect(await collect(n.keys())).toEqual(['~x', '50%']) + }) + + it('own value and children are independent keys', async () => { + const { db } = setup() + const u = db.child('users').child('1') + await u.set('own') + await u.set('name', 'Ada') + await u.delete() + expect(await u.get()).toBeUndefined() + expect(await u.get('name')).toBe('Ada') + }) + + it('getMany / setMany / deleteMany', async () => { + const { db, driver } = setup() + const n = db.child('n') + driver.reset() + await n.setMany([ + ['a', 1], + ['b', 2], + ]) + expect(await n.getMany(['b', 'x', 'a'])).toEqual([2, undefined, 1]) + await n.deleteMany(['a', 'x']) + expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2]) + expect(driver.calls).toEqual({ setMany: 1, getMany: 2, deleteMany: 1 }) + }) + + it('single-key ops make exactly one driver call', async () => { + const { db, driver } = setup() + const n = db.child('users') + driver.reset() + await n.set('1', 'x') + await n.get('1') + await n.child('1').get() + await n.delete('1') + expect(driver.calls).toEqual({ setMany: 1, getMany: 2, deleteMany: 1 }) + }) + + it('rejects setting undefined', async () => { + const { db } = setup() + await expect(db.child('a').set('b', undefined)).rejects.toThrow(TypeError) + }) +}) + +describe('arity dispatch', () => { + it('throws on passed-but-undefined or null keys instead of reading self', async () => { + const { db } = setup() + const users = db.child('users') + const maybe: string | undefined = undefined + await expect(users.get(maybe as never)).rejects.toThrow(InvalidKeyError) + await expect(users.get(null as never)).rejects.toThrow(TypeError) + await expect(users.set(maybe as never, 1 as never)).rejects.toThrow(TypeError) + await expect(users.delete(maybe as never)).rejects.toThrow(TypeError) + await expect(users.getMany([maybe as never])).rejects.toThrow(TypeError) + await expect(users.deleteTree(maybe as never)).rejects.toThrow(TypeError) + expect(() => users.child(maybe as never)).toThrow(TypeError) + }) + + it('root has no own value', async () => { + const { db } = setup() + await expect(db.get()).rejects.toThrow(TypeError) + await expect(db.set(1)).rejects.toThrow(TypeError) + await expect(db.delete()).rejects.toThrow(TypeError) + }) + + it('rejects empty keys', () => { + const { db } = setup() + expect(() => db.child('')).toThrow(InvalidKeyError) + }) +}) + +describe('keys / entries', () => { + it('lists direct children, including ones with only descendants, skipping reserved', async () => { + const { db } = setup() + const n = db.child('n') + await n.set('a', 1) + await n.child('b').set('deep', 1) + await n.set('c', 3) + await n.child('c').child('x').set('y', 1) + await db.driver.setMany([['n/~i/idx/v/a', true]]) + await n.set('\u{1F600}', 'emoji') + expect(await collect(n.keys())).toEqual(['a', 'b', 'c', '\u{1F600}']) + expect(await collect(n.keys({ reverse: true }))).toEqual(['\u{1F600}', 'c', 'b', 'a']) + expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['b', 'c']) + expect(await collect(n.keys({ after: 'c', reverse: true }))).toEqual(['b', 'a']) + expect(await collect(n.entries())).toEqual([ + ['a', 1], + ['c', 3], + ['\u{1F600}', 'emoji'], + ]) + expect(await collect(n.entries({ reverse: true, limit: 2 }))).toEqual([ + ['\u{1F600}', 'emoji'], + ['c', 3], + ]) + }) + + it('root keys skip the reserved ~p/~e roots', async () => { + const { db } = setup() + await db.driver.setMany([ + ['~p/users/1', {}], + ['users/1', {}], + ]) + expect(await collect(db.keys())).toEqual(['users']) + }) + + it('orders siblings that extend another with a below-separator char first', async () => { + const { db } = setup() + const n = db.child('n') + await n.set('a', 1) + await n.set('a-1', 2) + await n.child('a').set('x', 3) + expect(await collect(n.keys())).toEqual(['a-1', 'a']) + expect(await collect(n.keys({ reverse: true }))).toEqual(['a', 'a-1']) + expect(await collect(n.entries())).toEqual([ + ['a-1', 2], + ['a', 1], + ]) + }) + + it('is a skip-scan: seeks are O(children), not O(subtree)', async () => { + const { db, driver } = setup() + const n = db.child('n') + const writes: [string, unknown][] = [] + for (let c = 0; c < 50; c++) { + writes.push([`n/c${String(c).padStart(3, '0')}`, c]) + for (let d = 0; d < 100; d++) writes.push([`n/c${String(c).padStart(3, '0')}/d${d}`, d]) + } + await driver.inner.setMany(writes) + driver.reset() + const keys = await collect(n.keys()) + expect(keys).toHaveLength(50) + // One initial scan plus one seek per child with descendants. + expect(driver.calls.scan).toBe(51) + expect(driver.calls.getMany).toBeUndefined() + }) + + it('siblings without descendants share one iterator', async () => { + const { db, driver } = setup() + const n = db.child('n') + await n.setMany(Array.from({ length: 100 }, (_, i) => [`k${i}`, i] as [string, number])) + driver.reset() + expect(await collect(n.keys())).toHaveLength(100) + expect(driver.calls.scan).toBe(1) + }) +}) + +describe('deleteTree', () => { + it('deletes own value and all descendants but not lookalike siblings', async () => { + const { db } = setup() + const n = db.child('n') + await n.set('a', 1) + await n.child('a').set('b', 2) + await n.child('a').child('b').set('c', 3) + await n.set('a!', 'sibling') + await n.set('a0', 'sibling') + await n.deleteTree('a') + expect(await collect(n.keys())).toEqual(['a!', 'a0']) + await n.child('a0').deleteTree() + expect(await collect(n.keys())).toEqual(['a!']) + }) + + it('works without deleteRange, and on root', async () => { + const driver = new CountingDriver(new InMemoryKVDriver(), ['deleteRange']) + const db = new KVStorage(driver) + for (let i = 0; i < 2500; i++) await db.child('x').child(String(i)).set('v', i) + await db.child('y').set(1, 1) + await db.child('x').deleteTree() + expect(await collect(db.keys())).toEqual(['y']) + await db.deleteTree() + expect(await collect(driver.scan({}))).toEqual([]) + }) +}) + +describe('tree', () => { + it('assembles descendants up to depth, field-per-key style', async () => { + const { db } = setup() + const p = db.child('profile').child('9') + await p.set('username', 'ada') + await p.set('age', 36) + await p.child('address').set('city', 'London') + await p.child('address').child('geo').set('lat', 51) + await p.child('address').set('own') + await db.driver.setMany([['profile/9/~i/x/y', true]]) + expect(await p.tree({ depth: 1 })).toEqual({ username: 'ada', age: 36, address: 'own' }) + expect(await p.tree({ depth: 2 })).toEqual({ username: 'ada', age: 36, address: { '': 'own', city: 'London', geo: undefined } }) + expect(await p.tree({ depth: 3 })).toEqual({ username: 'ada', age: 36, address: { '': 'own', city: 'London', geo: { lat: 51 } } }) + }) +}) + +// ------------------------------------------------------------------ model-based + +type Path = string[] +const SEP = '/' +const segArb = fc.constantFrom('a', 'a-1', 'a.b', 'b', 'a/x', '%', '~t', '1', '10', '9', 'ab', 'a!', 'é') +const pathArb = fc.array(segArb, { minLength: 1, maxLength: 3 }) +const valArb = fc.oneof(fc.integer(), fc.string(), fc.constant(null), fc.record({ n: fc.integer() })) + +type Op = + | { t: 'set'; path: Path; value: unknown; selfForm: boolean } + | { t: 'delete'; path: Path; selfForm: boolean } + | { t: 'deleteTree'; path: Path } + | { t: 'setMany'; parent: Path; entries: [string, unknown][] } + +const opArb: fc.Arbitrary = fc.oneof( + { weight: 5, arbitrary: fc.record({ t: fc.constant('set' as const), path: pathArb, value: valArb, selfForm: fc.boolean() }) }, + { weight: 2, arbitrary: fc.record({ t: fc.constant('delete' as const), path: pathArb, selfForm: fc.boolean() }) }, + { weight: 1, arbitrary: fc.record({ t: fc.constant('deleteTree' as const), path: pathArb }) }, + { + weight: 1, + arbitrary: fc.record({ + t: fc.constant('setMany' as const), + parent: fc.array(segArb, { maxLength: 2 }), + entries: fc.array(fc.tuple(segArb, valArb), { maxLength: 4 }), + }), + }, +) + +const enc = (p: Path) => JSON.stringify(p) +const isPrefix = (p: Path, q: Path) => p.length <= q.length && p.every((s, i) => q[i] === s) + +/** Expected child order: escaped segment + separator, lexicographic. */ +function expectedChildren(model: Map, parent: Path): { key: string; own: unknown; hasOwn: boolean }[] { + const kids = new Map() + for (const [k, v] of model) { + const p = JSON.parse(k) as Path + if (p.length <= parent.length || !isPrefix(parent, p)) continue + const seg = p[parent.length]! + const e = kids.get(seg) ?? { own: undefined, hasOwn: false } + if (p.length === parent.length + 1) Object.assign(e, { own: v, hasOwn: true }) + kids.set(seg, e) + } + return [...kids] + .map(([key, e]) => ({ key, ...e })) + .sort((x, y) => { + const a = escapeSegment(x.key, SEP) + SEP + const b = escapeSegment(y.key, SEP) + SEP + return a < b ? -1 : a > b ? 1 : 0 + }) +} + +function nodeAt(db: KVStorage, path: Path) { + let n = db as ReturnType + for (const s of path) n = n.child(s) + return n +} + +describe('model-based (random ops vs reference Map)', () => { + for (const [name, make] of [ + ['InMemory', () => new InMemoryKVDriver()], + ['Minimal', () => new MinimalDriver()], + ] as const) { + it(`matches the model (${name})`, async () => { + await fc.assert( + fc.asyncProperty(fc.array(opArb, { maxLength: 30 }), fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }), async (ops, probes) => { + const db = new KVStorage(make()) + const model = new Map() + for (const op of ops) { + switch (op.t) { + case 'set': { + if (op.selfForm) await nodeAt(db, op.path).set(op.value) + else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value) + model.set(enc(op.path), op.value) + break + } + case 'delete': { + if (op.selfForm) await nodeAt(db, op.path).delete() + else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!) + model.delete(enc(op.path)) + break + } + case 'deleteTree': { + await nodeAt(db, op.path).deleteTree() + for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k) + break + } + case 'setMany': { + await nodeAt(db, op.parent).setMany(op.entries) + for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v) + break + } + } + } + for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v) + for (const parent of probes) { + const node = nodeAt(db, parent) + const exp = expectedChildren(model, parent) + const keys = exp.map((e) => e.key) + expect(await collect(node.keys())).toEqual(keys) + expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse()) + expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own])) + expect(await collect(node.entries({ reverse: true }))).toEqual( + exp + .filter((e) => e.hasOwn) + .map((e) => [e.key, e.own]) + .reverse(), + ) + for (let i = 0; i < keys.length; i++) { + expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1)) + expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse()) + expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1)) + } + } + }), + { numRuns: 300 }, + ) + }, 60_000) + } +})