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): 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([ { key: 'b', value: 2 }, { key: 'a/2', value: { n: 'a2' } }, { key: 'c', value: [3] }, { key: 'a', value: 1 }, { key: 'a/1', value: 'a1' }, { key: 'a0', value: null }, { key: '10', value: 10 }, { key: '9', value: 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('writes and removes in one batch', async () => { await db.writeMany([{ key: 'b' }, { key: 'new', value: 'y' }, { key: 'a', value: 'x' }]) expect(await db.readMany(['a', 'b', 'new'])).toEqual(['x', undefined, 'y']) }) 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('emits change events for writes and removals', async () => { const events: unknown[] = [] const off = db.on('change', (event) => events.push(event)) await db.writeMany([{ key: 'x', value: 1 }]) await db.removeMany(['x']) await db.writeMany([]) off() expect(events).toEqual([ { changes: [{ key: 'x', value: 1 }], origin: 'local' }, { changes: [{ key: 'x' }], origin: 'local' }, ]) }) it('stores bigint and Uint8Array values', async () => { await db.writeMany([{ key: 'bin', value: { n: 10n ** 20n, bytes: new Uint8Array([0, 255]) } }]) expect(await db.readMany(['bin'])).toEqual([{ n: 10n ** 20n, bytes: new Uint8Array([0, 255]) }]) }) 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('sets and gets direct children as fields', async () => { const user = db.child('users').child('1') await user.setFields({ name: 'Ada', hobby: 'chess', skipped: undefined }) await user.child('address').set('city', 'Paris') expect(await user.get('hobby')).toBe('chess') expect(await user.getFields()).toEqual({ hobby: 'chess', name: 'Ada' }) }) 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([{ key: 'n/~e/000000000001', value: {} }]) 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([]) }) }) }) }