- 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
169 lines
7.1 KiB
TypeScript
169 lines
7.1 KiB
TypeScript
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([])
|
|
})
|
|
})
|
|
})
|
|
}
|