Files
PotentialStorage/test/kv/indexes.test.ts
T
Claude e27541c46a
CI / Node 20 (push) Successful in 1m49s
CI / Node 22 (push) Successful in 1m52s
Add Prettier formatting and Gitea Actions CI (format, typecheck, test, build)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 07:29:38 +00:00

257 lines
11 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { KVStorage, type KVStorageOptions } from '../../src/kv/node.js'
import { InMemoryKVDriver } from '../../src/kv/memory.js'
import type { KVDriver } from '../../src/kv/driver.js'
import { encodeIndexValue } from '../../src/core/encoding.js'
import { collect, CountingDriver, type Capability } from '../helpers.js'
type User = { email?: string; age?: number; tags?: string[] }
type Post = { createdAt: string; tags: string[] }
const indexes: KVStorageOptions['indexes'] = {
users: {
byEmail: (u: User) => u.email,
byAge: (u: User) => u.age,
byTag: (u: User) => u.tags,
},
'users/*/posts': { byDate: (p: Post) => p.createdAt, byTag: (p: Post) => p.tags },
}
function setup(hide: Capability[] = []) {
const driver = new CountingDriver(new InMemoryKVDriver(), hide)
return { driver, db: new KVStorage(driver, { indexes }) }
}
const modes: [string, Capability[]][] = [
['atomic batch', []],
['non-atomic', ['batch']],
]
describe.each(modes)('indexes (%s)', (_, hide) => {
it('writes entries under <node>/~i/<name>/<enc>/<child>', async () => {
const { db, driver } = setup(hide)
await db.child('users').set('123', { email: 'sada@x.io', age: 30 })
const keys = (await collect(driver.inner.scan({}))).map(([k]) => k)
expect(keys).toEqual([`users/123`, `users/~i/byAge/${encodeIndexValue(30)}/123`, `users/~i/byEmail/${encodeIndexValue('sada@x.io')}/123`])
})
it('finds by exact value and keeps entries in sync on update and delete', async () => {
const { db } = setup(hide)
const users = db.child('users')
await users.set('1', { email: 'a@x', age: 30 })
await users.set('2', { email: 'b@x', age: 30 })
expect(await collect(users.findBy('byEmail', 'a@x'))).toEqual([['1', { email: 'a@x', age: 30 }]])
expect((await collect(users.findBy('byAge', 30))).map(([k]) => k)).toEqual(['1', '2'])
await users.set('1', { email: 'c@x', age: 31 })
expect(await collect(users.findBy('byEmail', 'a@x'))).toEqual([])
expect((await collect(users.findBy('byEmail', 'c@x'))).map(([k]) => k)).toEqual(['1'])
await users.delete('2')
expect(await collect(users.findBy('byAge', 30))).toEqual([])
})
it('self-form writes on a child update the parent index', async () => {
const { db } = setup(hide)
const u = db.child('users').child('7')
await u.set({ email: 'self@x' })
expect((await collect(db.child('users').findBy('byEmail', 'self@x'))).map(([k]) => k)).toEqual(['7'])
await u.delete()
expect(await collect(db.child('users').findBy('byEmail', 'self@x'))).toEqual([])
})
it('deleteTree removes the child entry', async () => {
const { db, driver } = setup(hide)
await db.child('users').set('7', { email: 'e' })
await db.child('users').child('7').child('posts').set('p', { createdAt: '2024', tags: [] })
await db.child('users').deleteTree('7')
expect(await collect(driver.inner.scan({}))).toEqual([])
})
it('supports multi-entry values, de-duplicated in range queries', async () => {
const { db } = setup(hide)
const users = db.child('users')
await users.set('1', { tags: ['a', 'b', 'a'] })
await users.set('2', { tags: ['b'] })
expect((await collect(users.findBy('byTag', 'b'))).map(([k]) => k)).toEqual(['1', '2'])
expect((await collect(users.findBy('byTag', { gte: 'a', lt: 'c' }))).map(([k]) => k)).toEqual(['1', '2'])
await users.set('1', { tags: ['c'] })
expect((await collect(users.findBy('byTag', 'b'))).map(([k]) => k)).toEqual(['2'])
})
it('range queries are ordered by value and honour bounds, limit and reverse', async () => {
const { db } = setup(hide)
const users = db.child('users')
await users.setMany([
['a', { age: 10 }],
['b', { age: 9 }],
['c', { age: -1.5 }],
['d', { age: 100 }],
['e', { age: 20 }],
])
const ages = async (q: object, o = {}) => (await collect(users.findBy('byAge', q as never, o))).map(([, v]) => (v as User).age)
expect(await ages({})).toEqual([-1.5, 9, 10, 20, 100])
expect(await ages({ gte: 9, lt: 20 })).toEqual([9, 10])
expect(await ages({ gt: 9, lte: 20 })).toEqual([10, 20])
expect(await ages({ gte: 0 }, { limit: 2 })).toEqual([9, 10])
expect(await ages({ lt: 100 }, { reverse: true })).toEqual([20, 10, 9, -1.5])
})
it('applies pattern indexes with * to nested nodes only', async () => {
const { db } = setup(hide)
const posts = db.child('users').child('u1').child('posts')
await posts.set('p1', { createdAt: '2024-02', tags: ['x'] })
await posts.set('p2', { createdAt: '2024-01', tags: ['y'] })
expect((await collect(posts.findBy('byDate', { gte: '2024' }))).map(([k]) => k)).toEqual(['p2', 'p1'])
await expect(db.child('other').findBy('byDate', 'x').next()).rejects.toThrow(/No index/)
})
it('handles child keys and values needing escaping', async () => {
const { db } = setup(hide)
const users = db.child('users')
await users.set('a/b%~', { email: 'we/ird%~@x' })
expect(await collect(users.findBy('byEmail', 'we/ird%~@x'))).toEqual([['a/b%~', { email: 'we/ird%~@x' }]])
})
})
describe('index costs', () => {
it('non-indexed nodes do not read before writing', async () => {
const { db, driver } = setup()
driver.reset()
await db.child('other').set('1', { email: 'x' })
await db.child('other').setMany([['2', {}]])
expect(driver.calls).toEqual({ setMany: 2 })
})
it('indexed writes: one getMany then one atomic batch', async () => {
const { db, driver } = setup()
driver.reset()
await db.child('users').setMany([
['1', { email: 'a' }],
['2', { email: 'b' }],
])
expect(driver.calls).toEqual({ getMany: 1, batch: 1 })
})
it('findBy with a small limit makes one value read', async () => {
const { db, driver } = setup()
await db.child('users').setMany(Array.from({ length: 300 }, (_, i) => [String(i), { age: 1 }] as [string, User]))
driver.reset()
expect(await collect(db.child('users').findBy('byAge', 1, { limit: 1 }))).toHaveLength(1)
expect(driver.calls).toEqual({ scan: 1, getMany: 1 })
driver.reset()
expect(await collect(db.child('users').findBy('byAge', 1))).toHaveLength(300)
expect(driver.calls).toEqual({ scan: 1, getMany: 3 })
})
})
describe('non-atomic crash recovery (read-repair)', () => {
/** Fails the n-th mutating call. */
class CrashingDriver extends CountingDriver {
failAt = Infinity
#n = 0
override setMany(entries: [string, unknown][]) {
if (++this.#n === this.failAt) return Promise.reject(new Error('crash'))
return super.setMany(entries)
}
override deleteMany(keys: string[]) {
if (++this.#n === this.failAt) return Promise.reject(new Error('crash'))
return super.deleteMany(keys)
}
}
it('skips and deletes stale entries left by a crash after the index add', async () => {
const driver = new CrashingDriver(new InMemoryKVDriver(), ['batch'])
const users = new KVStorage(driver, { indexes }).child('users')
await users.set('1', { email: 'old' })
driver.failAt = 2 + 2 // adds succeed, value write crashes
await expect(users.set('1', { email: 'new' })).rejects.toThrow('crash')
// The 'new' entry is a dangling hint; the value still says 'old'.
expect(await collect(users.findBy('byEmail', 'new'))).toEqual([])
expect(await collect(users.findBy('byEmail', 'old'))).toEqual([['1', { email: 'old' }]])
const entries = (await collect(driver.inner.scan({ gte: 'users/~i/' }))).map(([k]) => k)
expect(entries).toEqual([`users/~i/byEmail/${encodeIndexValue('old')}/1`])
})
it('skips stale entries left by a crash before the index remove', async () => {
const driver = new CrashingDriver(new InMemoryKVDriver(), ['batch'])
const users = new KVStorage(driver, { indexes }).child('users')
await users.set('1', { email: 'old' })
driver.failAt = 2 + 3 // adds + value succeed, remove crashes
await expect(users.set('1', { email: 'new' })).rejects.toThrow('crash')
expect(await collect(users.findBy('byEmail', 'old'))).toEqual([])
expect(await collect(users.findBy('byEmail', 'new'))).toEqual([['1', { email: 'new' }]])
})
it('can leave stale entries in place with readRepair: false', async () => {
const driver = new InMemoryKVDriver()
await driver.setMany([[`users/~i/byEmail/${encodeIndexValue('x')}/1`, true]])
const users = new KVStorage(driver, { indexes, readRepair: false }).child('users')
expect(await collect(users.findBy('byEmail', 'x'))).toEqual([])
expect(await collect(driver.scan({}))).toHaveLength(1)
})
})
describe('index declarations', () => {
it('rejects the same index name on overlapping patterns', () => {
expect(() => new KVStorage(new InMemoryKVDriver(), { indexes: { 'a/*': { i: () => 1 }, '*/b': { i: () => 2 } } })).toThrow(/overlapping/)
expect(() => new KVStorage(new InMemoryKVDriver(), { indexes: { 'a/*': { i: () => 1 }, '*/b': { j: () => 2 } } })).not.toThrow()
})
it('rejects non-indexable index function results', async () => {
const db = new KVStorage(new InMemoryKVDriver(), { indexes: { '': { bad: () => ({}) as never } } })
await expect(db.set('x', 1)).rejects.toThrow(/non-indexable/)
})
})
describe('property: findBy equals brute-force filter', () => {
const val = fc.record({
age: fc.option(fc.integer({ min: -3, max: 3 }), { nil: undefined }),
tags: fc.array(fc.constantFrom('a', 'b', 'c/d'), { maxLength: 3 }),
})
const op = fc.oneof(
fc.record({ t: fc.constant('set' as const), k: fc.constantFrom('1', '2', '3', 'x/y'), v: val }),
fc.record({ t: fc.constant('del' as const), k: fc.constantFrom('1', '2', '3', 'x/y') }),
)
for (const [mode, hide] of modes) {
it(mode, async () => {
await fc.assert(
fc.asyncProperty(fc.array(op, { maxLength: 20 }), fc.integer({ min: -4, max: 4 }), fc.integer({ min: -4, max: 4 }), async (ops, lo, hi) => {
const driver: KVDriver = new CountingDriver(new InMemoryKVDriver(), hide)
const users = new KVStorage(driver, { indexes }).child('users')
const model = new Map<string, User>()
for (const o of ops) {
if (o.t === 'set') {
await users.set(o.k, o.v)
model.set(o.k, o.v)
} else {
await users.delete(o.k)
model.delete(o.k)
}
}
const got = (await collect(users.findBy('byAge', { gte: lo, lt: hi }))).map(([k]) => k).sort()
const exp = [...model]
.filter(([, v]) => v.age !== undefined && v.age >= lo && v.age < hi)
.map(([k]) => k)
.sort()
expect(got).toEqual(exp)
for (const tag of ['a', 'b', 'c/d']) {
const g = (await collect(users.findBy('byTag', tag))).map(([k]) => k).sort()
expect(g).toEqual(
[...model]
.filter(([, v]) => v.tags?.includes(tag))
.map(([k]) => k)
.sort(),
)
}
// No stale entries are ever left behind by clean writes.
let entryCount = 0
for await (const _ of driver.scan({ gte: 'users/~i/', lt: 'users/~i0' })) entryCount++
const expectedEntries = [...model.values()].reduce((n, v) => n + (v.age !== undefined ? 1 : 0) + new Set(v.tags).size, 0)
expect(entryCount).toBe(expectedEntries)
}),
{ numRuns: 200 },
)
})
}
})