M2: KV driver interface, BaseKVDriver, InMemoryKVDriver, conformance suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './driver.js'
|
||||
export * from './memory.js'
|
||||
export * from './sorted.js'
|
||||
@@ -0,0 +1,104 @@
|
||||
import { BaseKVDriver, type BatchOp, type ScanOptions } from './driver.js'
|
||||
import { SortedArrayMap, type SortedMap } from './sorted.js'
|
||||
|
||||
export interface InMemoryKVDriverOptions {
|
||||
/**
|
||||
* Copy values on write and read so callers can't mutate stored state through a shared
|
||||
* reference (matching serialising drivers). 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 SortedArrayMap()
|
||||
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 data = this.#data
|
||||
const start = data.lowerBound(gte)
|
||||
const doomed: string[] = []
|
||||
for (let i = start; ; i++) {
|
||||
const key = data.keyAt(i)
|
||||
if (key === undefined || key >= lt) break
|
||||
doomed.push(key)
|
||||
}
|
||||
for (const key of doomed) data.delete(key)
|
||||
}
|
||||
|
||||
async *scan(opts: ScanOptions): AsyncGenerator<[string, unknown]> {
|
||||
const data = this.#data
|
||||
const { gte, lt, reverse = false, values = false } = opts
|
||||
let remaining = opts.limit ?? Infinity
|
||||
if (remaining <= 0) return
|
||||
// `last` is the last key yielded; if the map changes while we're suspended, re-seek from it.
|
||||
let last: string | undefined
|
||||
let version = data.version
|
||||
let i = reverse ? (lt === undefined ? data.size : data.lowerBound(lt)) - 1 : gte === undefined ? 0 : data.lowerBound(gte)
|
||||
for (;;) {
|
||||
if (data.version !== version) {
|
||||
version = data.version
|
||||
if (last !== undefined) i = reverse ? data.lowerBound(last) - 1 : data.lowerBound(last, true)
|
||||
}
|
||||
const key = data.keyAt(i)
|
||||
if (key === undefined || i < 0) return
|
||||
if (reverse ? gte !== undefined && key < gte : lt !== undefined && key >= lt) return
|
||||
last = key
|
||||
i += reverse ? -1 : 1
|
||||
yield [key, values ? this.#clone(data.get(key)) : undefined]
|
||||
if (--remaining <= 0) return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function identity(v: unknown): unknown {
|
||||
return v
|
||||
}
|
||||
|
||||
function cloneValue(v: unknown): unknown {
|
||||
return v === null || typeof v !== 'object' ? v : structuredClone(v)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Ordered string-keyed map used by the in-memory driver. Kept behind an interface so the
|
||||
* sorted-array implementation can be swapped for a B-tree without touching the driver.
|
||||
*/
|
||||
export interface SortedMap<V> {
|
||||
readonly size: number
|
||||
/** Incremented on every structural change (insert or delete), for iterator revalidation. */
|
||||
readonly version: 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
|
||||
/** Index of the first key `>= key` (or `> key` when `exclusive`). */
|
||||
lowerBound(key: string, exclusive?: boolean): number
|
||||
keyAt(index: number): string | undefined
|
||||
}
|
||||
|
||||
export class SortedArrayMap<V> implements SortedMap<V> {
|
||||
#keys: string[] = []
|
||||
#values = new Map<string, V>()
|
||||
#version = 0
|
||||
|
||||
get size(): number {
|
||||
return this.#keys.length
|
||||
}
|
||||
|
||||
get version(): number {
|
||||
return this.#version
|
||||
}
|
||||
|
||||
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.#keys.splice(this.lowerBound(key), 0, key)
|
||||
this.#version++
|
||||
}
|
||||
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
|
||||
this.#version++
|
||||
if (fresh.length < 32) {
|
||||
for (const key of fresh) this.#keys.splice(this.lowerBound(key), 0, key)
|
||||
return
|
||||
}
|
||||
// Bulk path: sort the new keys and merge, O(n + k log k) instead of O(n·k).
|
||||
fresh.sort(cmp)
|
||||
const old = this.#keys
|
||||
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++]!
|
||||
this.#keys = merged
|
||||
}
|
||||
|
||||
delete(key: string): boolean {
|
||||
if (!this.#values.delete(key)) return false
|
||||
this.#keys.splice(this.lowerBound(key), 1)
|
||||
this.#version++
|
||||
return true
|
||||
}
|
||||
|
||||
lowerBound(key: string, exclusive = false): number {
|
||||
const keys = this.#keys
|
||||
let lo = 0
|
||||
let hi = keys.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >>> 1
|
||||
const k = keys[mid]!
|
||||
if (k < key || (exclusive && k === key)) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return lo
|
||||
}
|
||||
|
||||
keyAt(index: number): string | undefined {
|
||||
return this.#keys[index]
|
||||
}
|
||||
}
|
||||
|
||||
function cmp(a: string, b: string): number {
|
||||
return a < b ? -1 : a > b ? 1 : 0
|
||||
}
|
||||
Reference in New Issue
Block a user