M3: KVNode/KVStorage with arity dispatch, skip-scan keys, deleteTree, tree
Children are listed in subtree order (escaped segment + separator) so the skip-scan stays O(children) even when sibling keys extend each other with characters below the separator. Model-based property test vs a reference Map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -0,0 +1,350 @@
|
||||
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<Op> = 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<string, unknown>, parent: Path): { key: string; own: unknown; hasOwn: boolean }[] {
|
||||
const kids = new Map<string, { own: unknown; hasOwn: boolean }>()
|
||||
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<KVStorage['child']>
|
||||
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<string, unknown>()
|
||||
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)
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user