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
+54
View File
@@ -0,0 +1,54 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server.js'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js'
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
import { EventSourcedDocumentStorage } from '../../src/document-storage/event-sourced-document-storage.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
import { serve } from './node-server.js'
runKVStorageConformance('KVStorageHttp (in-process)', () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), '/kv')
return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) })
})
describe('over a real socket', () => {
let server: Awaited<ReturnType<typeof serve>>
const stores: KVStorageHttpServer[] = []
beforeAll(async () => {
// One socket; each test storage gets its own backend under /<n>.
server = await serve((request) => stores[Number(new URL(request.url).pathname.split('/')[1])]!.handle(request))
})
afterAll(() => server.close())
const fresh = () => {
stores.push(new KVStorageHttpServer(new KVStorageInMemory(), `/${stores.length}`))
return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` })
}
runKVStorageConformance('KVStorageHttp (node:http)', fresh)
it('stops a long scan early', async () => {
const db = fresh()
await db.writeMany(Array.from({ length: 5000 }, (_, i) => [`k${String(i).padStart(5, '0')}`, i] as [string, number]))
const seen: string[] = []
for await (const [key] of db.scan()) if (seen.push(key) === 3) break
expect(seen).toEqual(['k00000', 'k00001', 'k00002'])
})
it('runs events and event-sourced documents over HTTP', async () => {
const kv = fresh()
const docs = new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageKV(kv.child('projections')))
const id = await docs.child('people').insert({ email: 'a@x', n: 1 })
await docs.child('people').update(id, { $inc: { n: 1 } })
expect(await docs.child('people').findOne({ email: 'a@x' })).toEqual({ key: id, data: { email: 'a@x', n: 2 } })
})
})
describe('server', () => {
it('maps errors to 404 / 500', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory())
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404)
expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500)
})
})
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
import { collect, KVStorageCounting } from '../helpers.js'
import { runKVStorageConformance } from './kv-storage.conformance.js'
runKVStorageConformance('KVStorageInMemory', () => new KVStorageInMemory())
runKVStorageConformance('wrapper (KVStorageCounting)', () => new KVStorageCounting())
describe('costs', () => {
it('single-key operations are one primitive call', async () => {
const db = new KVStorageCounting()
const users = db.child('users')
await users.set('1', 'x')
await users.get('1')
await users.child('1').get()
await users.delete('1')
expect(db.calls).toEqual({ writeMany: 1, readMany: 2, removeMany: 1 })
})
it('keys() seeks once per child with descendants, not once per key', async () => {
const db = new KVStorageCounting()
const writes: [string, number][] = []
for (let c = 0; c < 50; c++) for (let d = 0; d < 20; d++) writes.push([`n/c${c}/d${d}`, d])
for (let c = 0; c < 50; c++) writes.push([`n/leaf${c}`, c])
await db.writeMany(writes)
db.reset()
expect(await collect(db.child('n').keys())).toHaveLength(100)
expect(db.calls).toEqual({ scan: 51 })
})
})
describe('model-based: random writes vs a reference map', () => {
const segment = fc.constantFrom('a', 'a-1', 'a.b', 'b', 'x/y', '%', '~e', '1', '10', '9', 'ab', 'a!', 'é')
const path = fc.array(segment, { minLength: 1, maxLength: 3 })
const op = fc.oneof(
fc.record({ t: fc.constant('set' as const), path, value: fc.integer() }),
fc.record({ t: fc.constant('delete' as const), path }),
fc.record({ t: fc.constant('deleteTree' as const), path }),
)
const at = (db: BaseKVStorage, p: string[]) => p.reduce<BaseKVStorage>((n, s) => n.child(s), db)
it('get, keys and entries agree with the model', async () => {
await fc.assert(
fc.asyncProperty(fc.array(op, { maxLength: 30 }), fc.array(segment, { maxLength: 2 }), async (ops, parent) => {
const db = new KVStorageInMemory()
const model = new Map<string, number>()
const isUnder = (p: string[], q: string[]) => q.length >= p.length && p.every((s, i) => q[i] === s)
for (const o of ops) {
if (o.t === 'set') (await at(db, o.path).set(o.value), model.set(JSON.stringify(o.path), o.value))
else if (o.t === 'delete') (await at(db, o.path).delete(), model.delete(JSON.stringify(o.path)))
else {
await at(db, o.path).deleteTree()
for (const k of [...model.keys()]) if (isUnder(o.path, JSON.parse(k))) model.delete(k)
}
}
for (const [k, v] of model) expect(await at(db, JSON.parse(k)).get()).toBe(v)
const children = new Map<string, number | undefined>()
for (const [k, v] of model) {
const p = JSON.parse(k) as string[]
if (p.length <= parent.length || !isUnder(parent, p)) continue
const own = p.length === parent.length + 1
if (own || !children.has(p[parent.length]!)) children.set(p[parent.length]!, own ? v : children.get(p[parent.length]!))
}
const node = at(db, parent)
const listed = await collect(node.keys())
expect([...listed].sort()).toEqual([...children.keys()].sort())
expect(await collect(node.keys({ reverse: true }))).toEqual([...listed].reverse())
const withValues = listed.filter((k) => children.get(k) !== undefined)
expect(await collect(node.entries())).toEqual(withValues.map((k) => [k, children.get(k)]))
expect(await collect(node.entries({ reverse: true }))).toEqual(withValues.reverse().map((k) => [k, children.get(k)]))
for (let i = 0; i < listed.length; i++) expect(await collect(node.keys({ after: listed[i]! }))).toEqual(listed.slice(i + 1))
}),
{ numRuns: 300 },
)
})
})
+168
View File
@@ -0,0 +1,168 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { BaseKVStorage, ScanOptions } from '../../src/kv-storage/base-kv-storage.js'
import { InvalidKeyError } from '../../src/utils/errors.js'
import { collect } from '../helpers.js'
/** Behaviour every KV storage must have. `make` returns an empty storage. */
export function runKVStorageConformance(name: string, make: () => BaseKVStorage | Promise<BaseKVStorage>): void {
describe(`KV storage conformance: ${name}`, () => {
let db: BaseKVStorage
beforeEach(async () => {
db = await make()
})
const scanKeys = async (options: ScanOptions) => (await collect(db.scan(options))).map(([k]) => k)
describe('primitives', () => {
beforeEach(async () => {
await db.writeMany([
['b', 2],
['a/2', { n: 'a2' }],
['c', [3]],
['a', 1],
['a/1', 'a1'],
['a0', null],
['10', 10],
['9', 9],
])
})
it('reads in request order with undefined for missing keys and null preserved', async () => {
expect(await db.readMany(['c', 'missing', 'a/2', 'a0'])).toEqual([[3], undefined, { n: 'a2' }, null])
})
it('scans in key order with bounds, reverse, limit and optional values', async () => {
expect(await scanKeys({})).toEqual(['10', '9', 'a', 'a/1', 'a/2', 'a0', 'b', 'c'])
expect(await scanKeys({ gte: 'a/', lt: 'a0' })).toEqual(['a/1', 'a/2'])
expect(await scanKeys({ reverse: true, gte: 'a', lt: 'b' })).toEqual(['a0', 'a/2', 'a/1', 'a'])
expect(await scanKeys({ limit: 2, reverse: true })).toEqual(['c', 'b'])
expect(await scanKeys({ limit: 0 })).toEqual([])
expect(await collect(db.scan({ gte: 'a/', lt: 'a0', values: true }))).toEqual([
['a/1', 'a1'],
['a/2', { n: 'a2' }],
])
expect((await collect(db.scan({ gte: 'b' })))[0]).toEqual(['b', undefined])
})
it('removes keys', async () => {
await db.removeMany(['b', 'missing'])
expect(await db.readMany(['b', 'c'])).toEqual([undefined, [3]])
})
it('writeIfAbsent writes only absent keys, and only one concurrent claim wins', async () => {
expect(
await db.writeIfAbsent([
['a', 'x'],
['new', 'y'],
]),
).toEqual([false, true])
expect(await db.readMany(['a', 'new'])).toEqual([1, 'y'])
const results = await Promise.all([1, 2, 3].map((n) => db.writeIfAbsent([['claim', n]])))
expect(results.flat().filter(Boolean)).toHaveLength(1)
})
it('keeps scanning correctly when keys change mid-scan', async () => {
// Keys removed mid-scan may or may not still appear (stores may read ahead), but every key
// present throughout must appear exactly once, in order.
const seen: string[] = []
for await (const [key] of db.scan({})) {
seen.push(key)
if (key === 'a') await db.removeMany(['a', 'a/1'])
}
expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c'])
})
it('does not share references with callers', async () => {
const [value] = (await db.readMany(['a/2'])) as [{ n: string }]
value.n = 'changed'
expect(await db.readMany(['a/2'])).toEqual([{ n: 'a2' }])
})
})
describe('nodes', () => {
it('reads and writes own and child values at the same key', async () => {
const users = db.child('users')
await users.set('123', { 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(await db.readMany(['users/123'])).toEqual([{ name: 'Bob' }])
await db.child('n').set(5, 'five')
expect(await db.child('n').get('5')).toBe('five')
})
it('batch reads, writes and deletes children', async () => {
const n = db.child('n')
await n.setMany([
['a', 1],
['b', 2],
])
expect(await n.getMany(['b', 'x', 'a'])).toEqual([2, undefined, 1])
await n.deleteMany(['a'])
expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2])
})
it('keeps own values and children independent', async () => {
const user = db.child('users').child('1')
await user.set('own')
await user.set('name', 'Ada')
await user.delete()
expect(await user.get()).toBeUndefined()
expect(await user.get('name')).toBe('Ada')
})
it('rejects passed-but-undefined keys, root values and undefined values', async () => {
const users = db.child('users')
const id: string | undefined = undefined
await expect(users.get(id as never)).rejects.toThrow(InvalidKeyError)
await expect(users.delete(null as never)).rejects.toThrow(TypeError)
await expect(users.set(id as never, 1 as never)).rejects.toThrow(TypeError)
await expect(users.set('x', undefined)).rejects.toThrow(/undefined/)
await expect(db.get()).rejects.toThrow(/root/)
expect(() => db.child('')).toThrow(InvalidKeyError)
})
it('escapes keys so special characters round-trip', async () => {
const n = db.child('a/b')
for (const key of ['x/y', '50%', '~e', 'a-1', 'a.b', 'a b', 'é']) await n.set(key, key)
for (const key of ['x/y', '50%', '~e', 'a-1', 'a.b', 'a b', 'é']) expect(await n.get(key)).toBe(key)
expect((await collect(n.keys())).sort()).toEqual(['50%', 'a b', 'a-1', 'a.b', 'x/y', '~e', 'é'].sort())
})
it('lists direct children (including ones with only descendants), skipping reserved keys', async () => {
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 n.set('a-1', 4)
await db.writeMany([['n/~e/000000000001', {}]])
expect(await collect(n.keys())).toEqual(['a', 'a-1', 'b', 'c'])
expect(await collect(n.keys({ reverse: true }))).toEqual(['c', 'b', 'a-1', 'a'])
expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['a-1', 'b'])
expect(await collect(n.keys({ after: 'c', reverse: true }))).toEqual(['b', 'a-1', 'a'])
expect(await collect(n.entries())).toEqual([
['a', 1],
['a-1', 4],
['c', 3],
])
expect(await collect(n.entries({ reverse: true, limit: 2 }))).toEqual([
['c', 3],
['a-1', 4],
])
expect(await collect(db.keys())).toEqual(['n'])
})
it('deleteTree removes a subtree but not lookalike siblings', async () => {
const n = db.child('n')
await n.set('a', 1)
await n.child('a').child('b').set('c', 3)
await n.set('a!', 2)
await n.set('a0', 3)
await n.deleteTree('a')
expect(await collect(n.keys())).toEqual(['a0', 'a!']) // 'a!' is stored as 'a~21'
await db.deleteTree()
expect(await collect(db.scan())).toEqual([])
})
})
})
}
+24
View File
@@ -0,0 +1,24 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
/** Serves a `Request → Response` handler over a real socket (test helper). */
export async function serve(handle: (request: Request) => Promise<Response>): Promise<{ url: string; close: () => Promise<void> }> {
const server = createServer(async (req, res) => {
const chunks: Buffer[] = []
for await (const chunk of req) chunks.push(chunk as Buffer)
const response = await handle(
new Request(`http://localhost${req.url}`, { method: req.method, body: req.method === 'POST' ? Buffer.concat(chunks) : undefined }),
)
res.writeHead(response.status, Object.fromEntries(response.headers))
if (!response.body) return void res.end()
const reader = response.body.getReader()
res.on('close', () => void reader.cancel().catch(() => {}))
for (let next = await reader.read(); !next.done; next = await reader.read()) res.write(next.value)
res.end()
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
close: () => new Promise((resolve) => (server.closeAllConnections(), server.close(() => resolve()))),
}
}