M2: KV driver interface, BaseKVDriver, InMemoryKVDriver, conformance suite
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 @@
|
||||
export * from './kv-conformance.js'
|
||||
@@ -0,0 +1,171 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import type { KVDriver, ScanOptions } from '../kv/driver.js'
|
||||
|
||||
/**
|
||||
* Reusable conformance suite for {@link KVDriver} implementations. Call it from a Vitest test file:
|
||||
*
|
||||
* ```ts
|
||||
* runKVDriverConformance('MyDriver', () => new MyDriver())
|
||||
* ```
|
||||
*
|
||||
* `makeDriver` must return an empty driver each time.
|
||||
*/
|
||||
export function runKVDriverConformance(name: string, makeDriver: () => KVDriver | Promise<KVDriver>): void {
|
||||
describe(`KVDriver conformance: ${name}`, () => {
|
||||
let d: KVDriver
|
||||
|
||||
beforeEach(async () => {
|
||||
d = await makeDriver()
|
||||
})
|
||||
|
||||
async function collect(opts: ScanOptions): Promise<[string, unknown][]> {
|
||||
const out: [string, unknown][] = []
|
||||
for await (const e of d.scan(opts)) out.push(e)
|
||||
return out
|
||||
}
|
||||
|
||||
const keysOf = async (opts: ScanOptions) => (await collect(opts)).map(([k]) => k)
|
||||
|
||||
async function seed(): Promise<void> {
|
||||
// Deliberately inserted out of order.
|
||||
await d.setMany([
|
||||
['b', 2],
|
||||
['a/2', { n: 'a2' }],
|
||||
['c', [3]],
|
||||
['a', 1],
|
||||
['a/1', 'a1'],
|
||||
['a0', null],
|
||||
['10', 10],
|
||||
['9', 9],
|
||||
])
|
||||
}
|
||||
|
||||
it('round-trips JSON values with getMany in key order, undefined for missing', async () => {
|
||||
await seed()
|
||||
expect(await d.getMany(['c', 'missing', 'a/2', 'a0', 'a'])).toEqual([[3], undefined, { n: 'a2' }, null, 1])
|
||||
expect(await d.getMany([])).toEqual([])
|
||||
})
|
||||
|
||||
it('overwrites on setMany and deletes with deleteMany', async () => {
|
||||
await seed()
|
||||
await d.setMany([['a', 'one']])
|
||||
await d.deleteMany(['b', 'missing'])
|
||||
expect(await d.getMany(['a', 'b'])).toEqual(['one', undefined])
|
||||
})
|
||||
|
||||
it('scans all keys in lexicographic order', async () => {
|
||||
await seed()
|
||||
expect(await keysOf({})).toEqual(['10', '9', 'a', 'a/1', 'a/2', 'a0', 'b', 'c'])
|
||||
})
|
||||
|
||||
it('honours gte (inclusive) and lt (exclusive)', async () => {
|
||||
await seed()
|
||||
expect(await keysOf({ gte: 'a/', lt: 'a0' })).toEqual(['a/1', 'a/2'])
|
||||
expect(await keysOf({ gte: 'a', lt: 'b' })).toEqual(['a', 'a/1', 'a/2', 'a0'])
|
||||
expect(await keysOf({ gte: 'a0' })).toEqual(['a0', 'b', 'c'])
|
||||
expect(await keysOf({ lt: 'a' })).toEqual(['10', '9'])
|
||||
expect(await keysOf({ gte: 'zz' })).toEqual([])
|
||||
expect(await keysOf({ gte: 'b', lt: 'b' })).toEqual([])
|
||||
})
|
||||
|
||||
it('scans in reverse within bounds', async () => {
|
||||
await seed()
|
||||
expect(await keysOf({ reverse: true })).toEqual(['c', 'b', 'a0', 'a/2', 'a/1', 'a', '9', '10'])
|
||||
expect(await keysOf({ reverse: true, gte: 'a', lt: 'a0' })).toEqual(['a/2', 'a/1', 'a'])
|
||||
})
|
||||
|
||||
it('honours limit, forwards and in reverse', async () => {
|
||||
await seed()
|
||||
expect(await keysOf({ limit: 2 })).toEqual(['10', '9'])
|
||||
expect(await keysOf({ limit: 2, reverse: true, lt: 'b' })).toEqual(['a0', 'a/2'])
|
||||
expect(await keysOf({ limit: 0 })).toEqual([])
|
||||
})
|
||||
|
||||
it('yields values only when values: true', async () => {
|
||||
await seed()
|
||||
expect(await collect({ gte: 'a/', lt: 'a0' })).toEqual([
|
||||
['a/1', undefined],
|
||||
['a/2', undefined],
|
||||
])
|
||||
expect(await collect({ gte: 'a/', lt: 'a0', values: true })).toEqual([
|
||||
['a/1', 'a1'],
|
||||
['a/2', { n: 'a2' }],
|
||||
])
|
||||
})
|
||||
|
||||
it('tolerates writes while a scan is suspended', async () => {
|
||||
await d.setMany([
|
||||
['k1', 1],
|
||||
['k2', 2],
|
||||
['k3', 3],
|
||||
])
|
||||
const seen: string[] = []
|
||||
for await (const [key] of d.scan({})) {
|
||||
seen.push(key)
|
||||
if (key === 'k1') await d.deleteMany(['k1'])
|
||||
}
|
||||
expect(seen).toEqual(['k1', 'k2', 'k3'])
|
||||
})
|
||||
|
||||
it('does not let callers mutate stored values through returned references', async () => {
|
||||
await d.setMany([['o', { n: 1 }]])
|
||||
const [v] = (await d.getMany(['o'])) as [{ n: number }]
|
||||
v.n = 2
|
||||
expect(await d.getMany(['o'])).toEqual([{ n: 1 }])
|
||||
})
|
||||
|
||||
it('stores unicode keys and orders them by code unit', async () => {
|
||||
await d.setMany([
|
||||
['é', 1],
|
||||
['z', 2],
|
||||
['日本', 3],
|
||||
])
|
||||
expect(await keysOf({})).toEqual(['z', 'é', '日本'])
|
||||
})
|
||||
|
||||
describe('optional: setIfAbsent', () => {
|
||||
it('writes only absent keys and reports which were written', async (ctx) => {
|
||||
if (!d.setIfAbsent) return ctx.skip()
|
||||
await d.setMany([['x', 1]])
|
||||
expect(
|
||||
await d.setIfAbsent([
|
||||
['x', 2],
|
||||
['y', 3],
|
||||
]),
|
||||
).toEqual([false, true])
|
||||
expect(await d.getMany(['x', 'y'])).toEqual([1, 3])
|
||||
})
|
||||
|
||||
it('only the first of concurrent claims wins', async (ctx) => {
|
||||
if (!d.setIfAbsent) return ctx.skip()
|
||||
const results = await Promise.all([1, 2, 3, 4].map((n) => d.setIfAbsent!([['claim', n]])))
|
||||
expect(results.flat().filter(Boolean)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('optional: batch', () => {
|
||||
it('applies sets and deletes in order', async (ctx) => {
|
||||
if (!d.batch) return ctx.skip()
|
||||
await d.setMany([['gone', 1]])
|
||||
await d.batch([
|
||||
{ type: 'set', key: 'p', value: 1 },
|
||||
{ type: 'delete', key: 'gone' },
|
||||
{ type: 'set', key: 'q', value: { a: [1] } },
|
||||
{ type: 'set', key: 'p', value: 2 },
|
||||
])
|
||||
expect(await d.getMany(['p', 'q', 'gone'])).toEqual([2, { a: [1] }, undefined])
|
||||
})
|
||||
})
|
||||
|
||||
describe('optional: deleteRange', () => {
|
||||
it('deletes exactly [gte, lt)', async (ctx) => {
|
||||
if (!d.deleteRange) return ctx.skip()
|
||||
await seed()
|
||||
await d.deleteRange('a/', 'a0')
|
||||
expect(await keysOf({})).toEqual(['10', '9', 'a', 'a0', 'b', 'c'])
|
||||
await d.deleteRange('a', 'b')
|
||||
expect(await keysOf({})).toEqual(['10', '9', 'b', 'c'])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user