- Every KV storage emits change events (base class owns writeMany/removeMany/ writeIfAbsent; implementations provide protected apply* methods) - KVStorageHttpServer streams changes over SSE (GET /events) with a resumable history; KVStorageHttp.watch() subscribes and resumes via Last-Event-ID - KVStorageIndexedDB for local persistence - KVStorageComposite keeps replicas of a source in sync: local reads, resume instead of full sync, offline outbox replayed in order, echo tracking - Values may contain bigint/Uint8Array (Extended JSON over the network) - Vendor sse-session, event-emitter and extended-json (no runtime deps) - Freeze child nodes so shadowed state on children fails loudly - CI on Node 22, 24 and 26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
88 lines
4.3 KiB
TypeScript
88 lines
4.3 KiB
TypeScript
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 },
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('child nodes', () => {
|
|
it('are frozen, so implementations cannot accidentally shadow shared state', () => {
|
|
const child = new KVStorageInMemory().child('a').child('b')
|
|
expect(Object.isFrozen(child)).toBe(true)
|
|
expect(() => ((child as unknown as { state: number }).state = 1)).toThrow(TypeError)
|
|
expect(child.key).toBe('a/b')
|
|
})
|
|
})
|