import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { KVStorage } from '../../src/kv/node.js' import { InMemoryKVDriver } from '../../src/kv/memory.js' import { escapeSegment } from '../../src/core/path.js' import { InvalidKeyError } from '../../src/errors.js' import { collect, CountingDriver, MinimalDriver } from '../helpers.js' function setup() { const driver = new CountingDriver() const db = new KVStorage(driver) return { driver, db } } describe('KVNode basics', () => { it('reads and writes own and child values at the same key', async () => { const { db } = setup() const users = db.child('users') await users.set('123', { name: 'Ada' }) expect(await users.get('123')).toEqual({ 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(users.child('123').selfKey).toBe('users/123') }) it('treats number keys as strings', async () => { const { db } = setup() await db.child('n').set(5, 'five') expect(await db.child('n').get('5')).toBe('five') }) it('escapes keys containing the separator, % and leading ~', async () => { const { db, driver } = setup() const n = db.child('a/b') await n.set('~x', 1) await n.set('50%', 2) expect(await collect(driver.inner.scan({}))).toEqual([ ['a%2Fb/%7Ex', undefined], ['a%2Fb/50%25', undefined], ]) // Ordered by escaped segment: '%7Ex' < '50%25'. expect(await collect(n.keys())).toEqual(['~x', '50%']) }) it('own value and children are independent keys', async () => { const { db } = setup() const u = db.child('users').child('1') await u.set('own') await u.set('name', 'Ada') await u.delete() expect(await u.get()).toBeUndefined() expect(await u.get('name')).toBe('Ada') }) it('getMany / setMany / deleteMany', async () => { const { db, driver } = setup() const n = db.child('n') driver.reset() await n.setMany([ ['a', 1], ['b', 2], ]) expect(await n.getMany(['b', 'x', 'a'])).toEqual([2, undefined, 1]) await n.deleteMany(['a', 'x']) expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2]) expect(driver.calls).toEqual({ setMany: 1, getMany: 2, deleteMany: 1 }) }) it('single-key ops make exactly one driver call', async () => { const { db, driver } = setup() const n = db.child('users') driver.reset() await n.set('1', 'x') await n.get('1') await n.child('1').get() await n.delete('1') expect(driver.calls).toEqual({ setMany: 1, getMany: 2, deleteMany: 1 }) }) it('rejects setting undefined', async () => { const { db } = setup() await expect(db.child('a').set('b', undefined)).rejects.toThrow(TypeError) }) }) describe('arity dispatch', () => { it('throws on passed-but-undefined or null keys instead of reading self', async () => { const { db } = setup() const users = db.child('users') const maybe: string | undefined = undefined await expect(users.get(maybe as never)).rejects.toThrow(InvalidKeyError) await expect(users.get(null as never)).rejects.toThrow(TypeError) await expect(users.set(maybe as never, 1 as never)).rejects.toThrow(TypeError) await expect(users.delete(maybe as never)).rejects.toThrow(TypeError) await expect(users.getMany([maybe as never])).rejects.toThrow(TypeError) await expect(users.deleteTree(maybe as never)).rejects.toThrow(TypeError) expect(() => users.child(maybe as never)).toThrow(TypeError) }) it('root has no own value', async () => { const { db } = setup() await expect(db.get()).rejects.toThrow(TypeError) await expect(db.set(1)).rejects.toThrow(TypeError) await expect(db.delete()).rejects.toThrow(TypeError) }) it('rejects empty keys', () => { const { db } = setup() expect(() => db.child('')).toThrow(InvalidKeyError) }) }) describe('keys / entries', () => { it('lists direct children, including ones with only descendants, skipping reserved', async () => { const { db } = setup() 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 db.driver.setMany([['n/~i/idx/v/a', true]]) await n.set('\u{1F600}', 'emoji') expect(await collect(n.keys())).toEqual(['a', 'b', 'c', '\u{1F600}']) expect(await collect(n.keys({ reverse: true }))).toEqual(['\u{1F600}', 'c', 'b', 'a']) expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['b', 'c']) expect(await collect(n.keys({ after: 'c', reverse: true }))).toEqual(['b', 'a']) expect(await collect(n.entries())).toEqual([ ['a', 1], ['c', 3], ['\u{1F600}', 'emoji'], ]) expect(await collect(n.entries({ reverse: true, limit: 2 }))).toEqual([ ['\u{1F600}', 'emoji'], ['c', 3], ]) }) it('root keys skip the reserved ~p/~e roots', async () => { const { db } = setup() await db.driver.setMany([ ['~p/users/1', {}], ['users/1', {}], ]) expect(await collect(db.keys())).toEqual(['users']) }) it('orders siblings that extend another with a below-separator char first', async () => { const { db } = setup() const n = db.child('n') await n.set('a', 1) await n.set('a-1', 2) await n.child('a').set('x', 3) expect(await collect(n.keys())).toEqual(['a-1', 'a']) expect(await collect(n.keys({ reverse: true }))).toEqual(['a', 'a-1']) expect(await collect(n.entries())).toEqual([ ['a-1', 2], ['a', 1], ]) }) it('is a skip-scan: seeks are O(children), not O(subtree)', async () => { const { db, driver } = setup() const n = db.child('n') const writes: [string, unknown][] = [] for (let c = 0; c < 50; c++) { writes.push([`n/c${String(c).padStart(3, '0')}`, c]) for (let d = 0; d < 100; d++) writes.push([`n/c${String(c).padStart(3, '0')}/d${d}`, d]) } await driver.inner.setMany(writes) driver.reset() const keys = await collect(n.keys()) expect(keys).toHaveLength(50) // One initial scan plus one seek per child with descendants. expect(driver.calls.scan).toBe(51) expect(driver.calls.getMany).toBeUndefined() }) it('siblings without descendants share one iterator', async () => { const { db, driver } = setup() const n = db.child('n') await n.setMany(Array.from({ length: 100 }, (_, i) => [`k${i}`, i] as [string, number])) driver.reset() expect(await collect(n.keys())).toHaveLength(100) expect(driver.calls.scan).toBe(1) }) }) describe('deleteTree', () => { it('deletes own value and all descendants but not lookalike siblings', async () => { const { db } = setup() const n = db.child('n') await n.set('a', 1) await n.child('a').set('b', 2) await n.child('a').child('b').set('c', 3) await n.set('a!', 'sibling') await n.set('a0', 'sibling') await n.deleteTree('a') expect(await collect(n.keys())).toEqual(['a!', 'a0']) await n.child('a0').deleteTree() expect(await collect(n.keys())).toEqual(['a!']) }) it('works without deleteRange, and on root', async () => { const driver = new CountingDriver(new InMemoryKVDriver(), ['deleteRange']) const db = new KVStorage(driver) for (let i = 0; i < 2500; i++) await db.child('x').child(String(i)).set('v', i) await db.child('y').set(1, 1) await db.child('x').deleteTree() expect(await collect(db.keys())).toEqual(['y']) await db.deleteTree() expect(await collect(driver.scan({}))).toEqual([]) }) }) describe('tree', () => { it('assembles descendants up to depth, field-per-key style', async () => { const { db } = setup() const p = db.child('profile').child('9') await p.set('username', 'ada') await p.set('age', 36) await p.child('address').set('city', 'London') await p.child('address').child('geo').set('lat', 51) await p.child('address').set('own') await db.driver.setMany([['profile/9/~i/x/y', true]]) expect(await p.tree({ depth: 1 })).toEqual({ username: 'ada', age: 36, address: 'own' }) expect(await p.tree({ depth: 2 })).toEqual({ username: 'ada', age: 36, address: { '': 'own', city: 'London', geo: undefined } }) expect(await p.tree({ depth: 3 })).toEqual({ username: 'ada', age: 36, address: { '': 'own', city: 'London', geo: { lat: 51 } } }) }) }) // ------------------------------------------------------------------ model-based type Path = string[] const SEP = '/' const segArb = fc.constantFrom('a', 'a-1', 'a.b', 'b', 'a/x', '%', '~t', '1', '10', '9', 'ab', 'a!', 'é') const pathArb = fc.array(segArb, { minLength: 1, maxLength: 3 }) const valArb = fc.oneof(fc.integer(), fc.string(), fc.constant(null), fc.record({ n: fc.integer() })) type Op = | { t: 'set'; path: Path; value: unknown; selfForm: boolean } | { t: 'delete'; path: Path; selfForm: boolean } | { t: 'deleteTree'; path: Path } | { t: 'setMany'; parent: Path; entries: [string, unknown][] } const opArb: fc.Arbitrary = fc.oneof( { weight: 5, arbitrary: fc.record({ t: fc.constant('set' as const), path: pathArb, value: valArb, selfForm: fc.boolean() }) }, { weight: 2, arbitrary: fc.record({ t: fc.constant('delete' as const), path: pathArb, selfForm: fc.boolean() }) }, { weight: 1, arbitrary: fc.record({ t: fc.constant('deleteTree' as const), path: pathArb }) }, { weight: 1, arbitrary: fc.record({ t: fc.constant('setMany' as const), parent: fc.array(segArb, { maxLength: 2 }), entries: fc.array(fc.tuple(segArb, valArb), { maxLength: 4 }), }), }, ) const enc = (p: Path) => JSON.stringify(p) const isPrefix = (p: Path, q: Path) => p.length <= q.length && p.every((s, i) => q[i] === s) /** Expected child order: escaped segment + separator, lexicographic. */ function expectedChildren(model: Map, parent: Path): { key: string; own: unknown; hasOwn: boolean }[] { const kids = new Map() for (const [k, v] of model) { const p = JSON.parse(k) as Path if (p.length <= parent.length || !isPrefix(parent, p)) continue const seg = p[parent.length]! const e = kids.get(seg) ?? { own: undefined, hasOwn: false } if (p.length === parent.length + 1) Object.assign(e, { own: v, hasOwn: true }) kids.set(seg, e) } return [...kids] .map(([key, e]) => ({ key, ...e })) .sort((x, y) => { const a = escapeSegment(x.key, SEP) + SEP const b = escapeSegment(y.key, SEP) + SEP return a < b ? -1 : a > b ? 1 : 0 }) } function nodeAt(db: KVStorage, path: Path) { let n = db as ReturnType for (const s of path) n = n.child(s) return n } describe('model-based (random ops vs reference Map)', () => { for (const [name, make] of [ ['InMemory', () => new InMemoryKVDriver()], ['Minimal', () => new MinimalDriver()], ] as const) { it(`matches the model (${name})`, async () => { await fc.assert( fc.asyncProperty( fc.array(opArb, { maxLength: 30 }), fc.array(fc.array(segArb, { maxLength: 2 }), { minLength: 1, maxLength: 3 }), async (ops, probes) => { const db = new KVStorage(make()) const model = new Map() for (const op of ops) { switch (op.t) { case 'set': { if (op.selfForm) await nodeAt(db, op.path).set(op.value) else await nodeAt(db, op.path.slice(0, -1)).set(op.path.at(-1)!, op.value) model.set(enc(op.path), op.value) break } case 'delete': { if (op.selfForm) await nodeAt(db, op.path).delete() else await nodeAt(db, op.path.slice(0, -1)).delete(op.path.at(-1)!) model.delete(enc(op.path)) break } case 'deleteTree': { await nodeAt(db, op.path).deleteTree() for (const k of [...model.keys()]) if (isPrefix(op.path, JSON.parse(k))) model.delete(k) break } case 'setMany': { await nodeAt(db, op.parent).setMany(op.entries) for (const [k, v] of op.entries) model.set(enc([...op.parent, k]), v) break } } } for (const [k, v] of model) expect(await nodeAt(db, JSON.parse(k)).get()).toEqual(v) for (const parent of probes) { const node = nodeAt(db, parent) const exp = expectedChildren(model, parent) const keys = exp.map((e) => e.key) expect(await collect(node.keys())).toEqual(keys) expect(await collect(node.keys({ reverse: true }))).toEqual([...keys].reverse()) expect(await collect(node.entries())).toEqual(exp.filter((e) => e.hasOwn).map((e) => [e.key, e.own])) expect(await collect(node.entries({ reverse: true }))).toEqual( exp .filter((e) => e.hasOwn) .map((e) => [e.key, e.own]) .reverse(), ) for (let i = 0; i < keys.length; i++) { expect(await collect(node.keys({ after: keys[i]! }))).toEqual(keys.slice(i + 1)) expect(await collect(node.keys({ after: keys[i]!, reverse: true }))).toEqual(keys.slice(0, i).reverse()) expect(await collect(node.keys({ limit: i + 1 }))).toEqual(keys.slice(0, i + 1)) } } }, ), { numRuns: 300 }, ) }, 60_000) } })