Simplify: one abstract base class per layer
- BaseKVStorage / BaseEventStorage / BaseDocumentStorage define the API once; implementations provide only raw primitives (client and server share an API) - Drop KV indexes, query planner, unique indexes; document stores take index fields - Escape every char <= '/' so child keys stay contiguous; skip-scan is now simple - Event-sourced documents = event storage + any document store for projections - New folder layout (kv-storage, event-storage, document-storage, utils); tests only in test/ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { decodeIndexValue, encodeIndexValue, type IndexValue } from '../../src/core/encoding.js'
|
||||
|
||||
const separators = fc.constantFrom('/', ':', '.', '|', '#', '!')
|
||||
|
||||
const indexValue: fc.Arbitrary<IndexValue> = fc.oneof(
|
||||
fc.constant(null),
|
||||
fc.boolean(),
|
||||
fc.double({ noNaN: true }),
|
||||
fc.integer(),
|
||||
fc.string({ unit: 'binary' }),
|
||||
fc.string({ unit: fc.constantFrom('/', ':', '0', '1', 'a', '%', '~', '\0', '!') }),
|
||||
)
|
||||
|
||||
const rank = (v: IndexValue) => (v === null ? 0 : v === false ? 1 : v === true ? 2 : typeof v === 'number' ? 3 : 4)
|
||||
|
||||
function compare(a: IndexValue, b: IndexValue): number {
|
||||
const ra = rank(a)
|
||||
const rb = rank(b)
|
||||
if (ra !== rb) return ra - rb
|
||||
if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1
|
||||
if (typeof a === 'string' && typeof b === 'string') return a === b ? 0 : a < b ? -1 : 1
|
||||
return 0
|
||||
}
|
||||
|
||||
const sign = (n: number) => (n < 0 ? -1 : n > 0 ? 1 : 0)
|
||||
const cmpStr = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
|
||||
|
||||
describe('encodeIndexValue', () => {
|
||||
it('round-trips (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(indexValue, separators, (v, sep) => {
|
||||
const decoded = decodeIndexValue(encodeIndexValue(v, sep), sep)
|
||||
if (typeof v === 'number' && v === 0)
|
||||
expect(decoded).toBe(0) // -0 normalised
|
||||
else expect(decoded).toEqual(v)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('emits only characters above the separator (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(indexValue, separators, (v, sep) => {
|
||||
for (const c of encodeIndexValue(v, sep)) expect(c > sep).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves order, including when followed by a separator and suffix (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(indexValue, indexValue, fc.string(), fc.string(), separators, (a, b, sa, sb, sep) => {
|
||||
const ea = encodeIndexValue(a, sep)
|
||||
const eb = encodeIndexValue(b, sep)
|
||||
const expected = sign(compare(a, b))
|
||||
expect(cmpStr(ea, eb)).toBe(expected)
|
||||
if (expected !== 0) expect(cmpStr(ea + sep + sa, eb + sep + sb)).toBe(expected)
|
||||
}),
|
||||
{ numRuns: 2000 },
|
||||
)
|
||||
})
|
||||
|
||||
it('orders types null < false < true < numbers < strings', () => {
|
||||
const values: IndexValue[] = ['', 'a', -Infinity, -1, 0, 1e300, Infinity, true, false, null]
|
||||
const sorted = [...values].sort((a, b) => cmpStr(encodeIndexValue(a), encodeIndexValue(b)))
|
||||
expect(sorted).toEqual([null, false, true, -Infinity, -1, 0, 1e300, Infinity, '', 'a'])
|
||||
})
|
||||
|
||||
it('rejects NaN and non-index values', () => {
|
||||
expect(() => encodeIndexValue(NaN)).toThrow()
|
||||
expect(() => encodeIndexValue({} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -1,73 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { escapeSegment, joinPath, pathKeys, splitPath, succ, toSegment, unescapeSegment, validateSeparator } from '../../src/core/path.js'
|
||||
import { InvalidKeyError } from '../../src/errors.js'
|
||||
|
||||
const separators = fc.constantFrom('/', ':', '.', '|', '#', '\\', '!')
|
||||
|
||||
describe('escapeSegment', () => {
|
||||
it('escapes %, the separator and a leading ~', () => {
|
||||
expect(escapeSegment('a/b')).toBe('a%2Fb')
|
||||
expect(escapeSegment('50%')).toBe('50%25')
|
||||
expect(escapeSegment('~x')).toBe('%7Ex')
|
||||
expect(escapeSegment('x~')).toBe('x~')
|
||||
expect(escapeSegment('a:b', ':')).toBe('a%3Ab')
|
||||
expect(escapeSegment('a/b', ':')).toBe('a/b')
|
||||
expect(escapeSegment('plain')).toBe('plain')
|
||||
})
|
||||
|
||||
it('round-trips (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ unit: 'binary' }), separators, (s, sep) => {
|
||||
expect(unescapeSegment(escapeSegment(s, sep))).toBe(s)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('never emits a raw separator or a leading ~ (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ unit: 'binary' }), separators, (s, sep) => {
|
||||
const e = escapeSegment(s, sep)
|
||||
expect(e.includes(sep)).toBe(false)
|
||||
expect(e.startsWith('~')).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('joins and splits paths (property)', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(fc.string({ minLength: 1 }), { maxLength: 6 }), separators, (path, sep) => {
|
||||
expect(splitPath(joinPath(path, sep), sep)).toEqual(path)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pathKeys', () => {
|
||||
it('precomputes selfKey, prefix and subtree end', () => {
|
||||
expect(pathKeys(['users', '123'], '/')).toMatchObject({ selfKey: 'users/123', prefix: 'users/123/', end: 'users/1230' })
|
||||
expect(pathKeys([], '/')).toMatchObject({ selfKey: '', prefix: '', end: undefined })
|
||||
expect(pathKeys(['a/b'], '/').selfKey).toBe('a%2Fb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('misc', () => {
|
||||
it('succ returns the next char', () => {
|
||||
expect(succ('/')).toBe('0')
|
||||
})
|
||||
|
||||
it('toSegment rejects undefined, null, empty and non-finite', () => {
|
||||
expect(() => toSegment(undefined)).toThrow(InvalidKeyError)
|
||||
expect(() => toSegment(null)).toThrow(TypeError)
|
||||
expect(() => toSegment('')).toThrow(InvalidKeyError)
|
||||
expect(() => toSegment(NaN)).toThrow(InvalidKeyError)
|
||||
expect(toSegment(12)).toBe('12')
|
||||
})
|
||||
|
||||
it('validates separators', () => {
|
||||
expect(() => validateSeparator('//')).toThrow()
|
||||
expect(() => validateSeparator('%')).toThrow()
|
||||
expect(() => validateSeparator('~')).toThrow()
|
||||
expect(() => validateSeparator(':')).not.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BaseDocumentStorage } from '../../src/document-storage/base-document-storage.js'
|
||||
import { collect } from '../helpers.js'
|
||||
|
||||
type User = { name?: string; email?: string; age?: number; tags?: string[]; logins?: number; address?: { city?: string } }
|
||||
|
||||
/** Behaviour every document storage must have. `make` returns an empty storage. */
|
||||
export function runDocumentStorageSuite(name: string, make: () => BaseDocumentStorage): void {
|
||||
describe(`document storage: ${name}`, () => {
|
||||
const users = () => make().child('users')
|
||||
|
||||
it('inserts with time-sortable ids, gets and lists', async () => {
|
||||
const u = users()
|
||||
const a = await u.insert({ name: 'Ada' })
|
||||
const b = await u.insert({ name: 'Bob' })
|
||||
expect(a < b).toBe(true)
|
||||
expect(await u.get(a)).toEqual({ name: 'Ada' })
|
||||
expect(await collect(u.keys())).toEqual([a, b])
|
||||
})
|
||||
|
||||
it('set / get / delete in self and child forms', async () => {
|
||||
const u = users()
|
||||
await u.set('1', { name: 'Ada' })
|
||||
expect(await u.child('1').get()).toEqual({ name: 'Ada' })
|
||||
await u.child('1').set({ name: 'Ada L.' })
|
||||
expect(await u.getMany(['1', '2'])).toEqual([{ name: 'Ada L.' }, undefined])
|
||||
expect(await u.delete('1')).toBe(true)
|
||||
expect(await u.child('1').delete()).toBe(false)
|
||||
expect(await u.get('1')).toBeUndefined()
|
||||
await u.set('1', { name: 'Again' })
|
||||
expect(await u.get('1')).toEqual({ name: 'Again' })
|
||||
})
|
||||
|
||||
it('updates with operators, returns the new state, and supports upsert', async () => {
|
||||
const u = users()
|
||||
await u.set('1', { name: 'Ada', tags: ['a'], logins: 1 })
|
||||
expect(await u.update('1', { $inc: { logins: 2 }, $push: { tags: 'b' }, $set: { 'address.city': 'London' } })).toEqual({
|
||||
name: 'Ada',
|
||||
tags: ['a', 'b'],
|
||||
logins: 3,
|
||||
address: { city: 'London' },
|
||||
})
|
||||
expect(await u.child('1').update({ $unset: { address: 1 } })).toEqual({ name: 'Ada', tags: ['a', 'b'], logins: 3 })
|
||||
expect(await u.update('missing', { $set: { a: 1 } })).toBeUndefined()
|
||||
expect(await u.update('missing', { $inc: { n: 1 } }, { upsert: true })).toEqual({ n: 1 })
|
||||
})
|
||||
|
||||
it('rejects bad keys and documents', async () => {
|
||||
const u = users()
|
||||
await expect(u.get(undefined as never)).rejects.toThrow(TypeError)
|
||||
await expect(u.update(undefined as never, { $set: {} })).rejects.toThrow(TypeError)
|
||||
await expect(make().get()).rejects.toThrow(/root/)
|
||||
await expect(u.set('x', [] as never)).rejects.toThrow(/plain object/)
|
||||
})
|
||||
|
||||
it('nests documents under documents', async () => {
|
||||
const u = users()
|
||||
await u.set('1', { name: 'Ada' })
|
||||
await u.child('1').child('posts').set('p1', { title: 'Hi' })
|
||||
expect(await u.child('1').child('posts').get('p1')).toEqual({ title: 'Hi' })
|
||||
expect((await collect(u.find())).map((r) => r.key)).toEqual(['1'])
|
||||
})
|
||||
|
||||
it('finds with equality, comparisons, $in, dot paths, sort, skip and limit', async () => {
|
||||
const u = users()
|
||||
const rows: [string, User][] = [
|
||||
['a', { name: 'Ada', age: 36, email: 'ada@x', tags: ['x', 'y'], address: { city: 'London' } }],
|
||||
['b', { name: 'Bob', age: 20, email: 'bob@x', tags: ['y'] }],
|
||||
['c', { name: 'Cy', age: 51, address: { city: 'London' } }],
|
||||
['d', { name: 'Di', age: 20 }],
|
||||
]
|
||||
for (const [k, v] of rows) await u.set(k, v)
|
||||
const keys = async (filter: Record<string, unknown>, options = {}) => (await collect(u.find(filter, options))).map((r) => r.key)
|
||||
expect(await keys({ age: 20 })).toEqual(['b', 'd'])
|
||||
expect(await keys({ age: { $gte: 20, $lt: 40 } })).toEqual(['a', 'b', 'd'])
|
||||
expect(await keys({ age: { $in: [36, 51] } })).toEqual(['a', 'c'])
|
||||
expect(await keys({ 'address.city': 'London', age: { $gt: 40 } })).toEqual(['c'])
|
||||
expect(await keys({ tags: 'y' })).toEqual(['a', 'b'])
|
||||
expect(await keys({ email: null })).toEqual(['c', 'd'])
|
||||
expect(await keys({}, { sort: { age: -1 } })).toEqual(['c', 'a', 'b', 'd'])
|
||||
expect(await keys({}, { sort: { age: 1 }, skip: 1, limit: 2 })).toEqual(['d', 'a'])
|
||||
expect(await u.findOne({ email: 'bob@x' })).toEqual({ key: 'b', data: rows[1]![1] })
|
||||
expect(await u.findOne({ email: 'nobody' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { DocumentStorageInMemory } from '../../src/document-storage/document-storage-in-memory.js'
|
||||
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
|
||||
import { EventSourcedDocumentStorage } from '../../src/document-storage/event-sourced-document-storage.js'
|
||||
import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { collect, KVStorageCounting } from '../helpers.js'
|
||||
import { runDocumentStorageSuite } from './document-storage.suite.js'
|
||||
|
||||
const indexes = { users: ['email', 'age'] }
|
||||
|
||||
runDocumentStorageSuite('DocumentStorageInMemory', () => new DocumentStorageInMemory())
|
||||
runDocumentStorageSuite('DocumentStorageInMemory (indexed)', () => new DocumentStorageInMemory({ indexes }))
|
||||
runDocumentStorageSuite('DocumentStorageKV', () => new DocumentStorageKV(new KVStorageInMemory()))
|
||||
runDocumentStorageSuite('EventSourced (in-memory projections)', () => {
|
||||
const kv = new KVStorageInMemory()
|
||||
return new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageInMemory({ indexes }))
|
||||
})
|
||||
runDocumentStorageSuite('EventSourced (KV projections)', () => {
|
||||
const kv = new KVStorageInMemory()
|
||||
return new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageKV(kv.child('projections')))
|
||||
})
|
||||
|
||||
describe('DocumentStorageInMemory indexes', () => {
|
||||
it('keeps the equality index in sync with updates and deletes', async () => {
|
||||
const users = new DocumentStorageInMemory({ indexes }).child('users')
|
||||
await users.set('1', { email: 'a', age: 1 })
|
||||
await users.set('2', { email: 'b', age: 1 })
|
||||
await users.update('1', { $set: { email: 'c' } })
|
||||
await users.delete('2')
|
||||
expect(await collect(users.find({ email: 'a' }))).toEqual([])
|
||||
expect(await collect(users.find({ email: 'c' }))).toEqual([{ key: '1', data: { email: 'c', age: 1 } }])
|
||||
expect(await collect(users.find({ age: { $eq: 1 } }))).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('EventSourcedDocumentStorage', () => {
|
||||
function setup() {
|
||||
const kv = new KVStorageCounting()
|
||||
const events = new EventStorageKV(kv)
|
||||
const projections = new DocumentStorageKV(kv.child('projections'))
|
||||
return { kv, events, projections, docs: new EventSourcedDocumentStorage(events, projections) }
|
||||
}
|
||||
|
||||
it('records resolved changes as history and replays any version', async () => {
|
||||
const users = setup().docs.child('users')
|
||||
await users.set('1', { n: 5 })
|
||||
await users.update('1', { $inc: { n: 1 } })
|
||||
await users.delete('1')
|
||||
await users.set('1', { n: 10 })
|
||||
expect((await collect(users.history('1'))).map((e) => e.data)).toEqual([
|
||||
{ op: 'set', doc: { n: 5 } },
|
||||
{ op: 'patch', set: { n: 6 }, unset: [] },
|
||||
{ op: 'delete' },
|
||||
{ op: 'set', doc: { n: 10 } },
|
||||
])
|
||||
expect(await users.getAt('1', 2)).toEqual({ n: 6 })
|
||||
expect(await users.child('1').getAt(3)).toBeUndefined()
|
||||
expect(await users.get('1')).toEqual({ n: 10 })
|
||||
})
|
||||
|
||||
it('heals a projection left behind by a crash between append and projection write', async () => {
|
||||
const { docs, events } = setup()
|
||||
const users = docs.child('users')
|
||||
await users.set('1', { n: 1 })
|
||||
await events.child('users').append('1', [{ op: 'patch', set: { n: 2 }, unset: [] }]) // projection never written
|
||||
expect(await users.get('1')).toEqual({ n: 1 })
|
||||
expect(await users.update('1', { $inc: { n: 1 } })).toEqual({ n: 3 })
|
||||
await events.child('users').append('2', [{ op: 'set', doc: { n: 7 } }]) // first write crashed
|
||||
expect(await users.catchUp('2')).toEqual({ n: 7 })
|
||||
expect(await users.get('2')).toEqual({ n: 7 })
|
||||
})
|
||||
|
||||
it('two instances incrementing the same document converge', async () => {
|
||||
const kv = new KVStorageInMemory()
|
||||
const make = () => new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageKV(kv.child('projections'))).child('users')
|
||||
const a = make()
|
||||
const b = make()
|
||||
await a.set('1', { n: 0 })
|
||||
await Promise.all([...Array(5)].flatMap(() => [a.update('1', { $inc: { n: 1 } }), b.update('1', { $inc: { n: 1 } })]))
|
||||
expect(await a.get('1')).toEqual({ n: 10 })
|
||||
})
|
||||
|
||||
it('a write is a projection read, an append and a projection write', async () => {
|
||||
const { docs, kv } = setup()
|
||||
await docs.child('users').set('1', { n: 1 })
|
||||
kv.reset()
|
||||
await docs.child('users').update('1', { $inc: { n: 1 } })
|
||||
expect(kv.calls).toEqual({ readMany: 2, writeIfAbsent: 1, writeMany: 1 })
|
||||
})
|
||||
})
|
||||
@@ -1,379 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { KVStorage } from '../../src/kv/node.js'
|
||||
import { InMemoryKVDriver } from '../../src/kv/memory.js'
|
||||
import { KVEventStorage } from '../../src/events/kv.js'
|
||||
import { EventSourcedDocumentStorage, type EventSourcedDocumentStorageOptions } from '../../src/documents/event-sourced.js'
|
||||
import type { DocIndexDeclarations } from '../../src/documents/indexes.js'
|
||||
import type { Filter } from '../../src/documents/filter.js'
|
||||
import { CapabilityError, UniqueConstraintError } from '../../src/errors.js'
|
||||
import { collect, CountingDriver, type Capability } from '../helpers.js'
|
||||
|
||||
type User = { name?: string; email?: string; age?: number; tags?: string[]; logins?: number; address?: { city?: string } }
|
||||
|
||||
const userIndexes: DocIndexDeclarations = {
|
||||
users: [{ fields: ['email'], unique: true }, { fields: ['age'] }, { name: 'cityAge', fields: ['address.city', 'age'] }, { fields: ['tags'] }],
|
||||
'users/*/posts': [{ fields: ['createdAt'] }],
|
||||
}
|
||||
|
||||
function setup(opts: { hide?: Capability[]; inner?: InMemoryKVDriver } & EventSourcedDocumentStorageOptions = {}) {
|
||||
const { hide = [], inner = new InMemoryKVDriver(), ...docOpts } = opts
|
||||
const driver = new CountingDriver(inner, hide)
|
||||
const kv = new KVStorage(driver)
|
||||
const events = new KVEventStorage(kv)
|
||||
const docs = new EventSourcedDocumentStorage(events, { indexes: userIndexes, warnOnFullScan: false, ...docOpts })
|
||||
return { driver, kv, events, docs, users: docs.child('users') }
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe.each([
|
||||
['atomic batch', [] as Capability[]],
|
||||
['non-atomic', ['batch'] as Capability[]],
|
||||
])('EventSourcedDocumentStorage (%s)', (_, hide) => {
|
||||
describe('CRUD', () => {
|
||||
it('inserts with time-sortable ids and reads back', async () => {
|
||||
const { users } = setup({ hide })
|
||||
const a = await users.insert({ name: 'Ada', age: 36 })
|
||||
const b = await users.insert({ name: 'Bob', age: 20 })
|
||||
expect(a).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/)
|
||||
expect(a < b).toBe(true)
|
||||
expect(await users.get(a)).toEqual({ name: 'Ada', age: 36 })
|
||||
expect(await collect(users.keys())).toEqual([a, b])
|
||||
})
|
||||
|
||||
it('set / get / delete in self and child forms', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('123', { name: 'Ada', age: 36 })
|
||||
expect(await users.child('123').get()).toEqual({ name: 'Ada', age: 36 })
|
||||
await users.child('123').set({ name: 'Ada L.' })
|
||||
expect(await users.get('123')).toEqual({ name: 'Ada L.' })
|
||||
expect(await users.delete('123')).toBe(true)
|
||||
expect(await users.delete('123')).toBe(false)
|
||||
expect(await users.get('123')).toBeUndefined()
|
||||
expect(await collect(users.keys())).toEqual([])
|
||||
await users.set('123', { name: 'Again' })
|
||||
expect(await users.get('123')).toEqual({ name: 'Again' })
|
||||
})
|
||||
|
||||
it('applies arity rules', async () => {
|
||||
const { users, docs } = setup({ hide })
|
||||
const id: string | undefined = undefined
|
||||
await expect(users.get(id as never)).rejects.toThrow(TypeError)
|
||||
await expect(users.update(id as never, { $set: { a: 1 } })).rejects.toThrow(TypeError)
|
||||
await expect(users.delete(id as never)).rejects.toThrow(TypeError)
|
||||
await expect(docs.get()).rejects.toThrow(/root/)
|
||||
await expect(users.set('x', [] as never)).rejects.toThrow(/plain object/)
|
||||
})
|
||||
|
||||
it('nests documents under documents', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('u1', { name: 'Ada' })
|
||||
const posts = users.child('u1').child('posts')
|
||||
await posts.set('p1', { title: 'Hello', createdAt: '2024-01' })
|
||||
expect(await posts.get('p1')).toEqual({ title: 'Hello', createdAt: '2024-01' })
|
||||
expect((await collect(users.find())).map((r) => r.key)).toEqual(['u1'])
|
||||
expect((await collect(posts.find({ createdAt: { $gte: '2024' } }))).map((r) => r.key)).toEqual(['p1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('update', () => {
|
||||
it('supports $set $unset $inc $push $pull $addToSet with dot paths', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('1', { name: 'Ada', tags: ['a'], address: { city: 'X' }, nickname: 'A' } as User)
|
||||
const r = await users.update('1', {
|
||||
$set: { name: 'Ada L.', 'address.zip': '123' },
|
||||
$inc: { logins: 1 },
|
||||
$push: { tags: { $each: ['b', 'c'] } },
|
||||
$unset: { nickname: true },
|
||||
})
|
||||
expect(r).toEqual({ name: 'Ada L.', tags: ['a', 'b', 'c'], address: { city: 'X', zip: '123' }, logins: 1 })
|
||||
await users.update('1', { $pull: { tags: { $in: ['a', 'c'] } }, $addToSet: { 'address.list': { $each: [1, 1, 2] } } })
|
||||
await users.child('1').update({ $inc: { logins: 5 }, $addToSet: { tags: 'b' } })
|
||||
expect(await users.get('1')).toEqual({ name: 'Ada L.', tags: ['b'], address: { city: 'X', zip: '123', list: [1, 2] }, logins: 6 })
|
||||
})
|
||||
|
||||
it('returns undefined for missing docs unless upsert', async () => {
|
||||
const { users } = setup({ hide })
|
||||
expect(await users.update('nope', { $set: { a: 1 } })).toBeUndefined()
|
||||
expect(await users.get('nope')).toBeUndefined()
|
||||
expect(await users.update('nope', { $inc: { n: 2 } }, { upsert: true })).toEqual({ n: 2 })
|
||||
})
|
||||
|
||||
it('rejects conflicting paths and bad operators', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('1', { age: 1 })
|
||||
await expect(users.update('1', { $set: { a: 1 }, $unset: { 'a.b': 1 } })).rejects.toThrow(/conflict/)
|
||||
await expect(users.update('1', { $rename: {} } as never)).rejects.toThrow(/Unsupported/)
|
||||
await expect(users.update('1', { $inc: { name: 1 } }).then(() => users.update('1', { $push: { age: 1 } }))).rejects.toThrow(/non-array/)
|
||||
})
|
||||
|
||||
it('stores resolved deltas, not operators, and skips no-op updates', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('1', { logins: 5 })
|
||||
await users.update('1', { $inc: { logins: 1 } })
|
||||
await users.update('1', { $set: { logins: 6 } }) // no-op
|
||||
expect((await collect(users.history('1'))).map((e) => e.data)).toEqual([
|
||||
{ op: 'set', doc: { logins: 5 } },
|
||||
{ op: 'patch', set: { logins: 6 } },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('history and getAt', () => {
|
||||
it('replays to any version', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('1', { n: 1 })
|
||||
await users.update('1', { $inc: { n: 1 } })
|
||||
await users.delete('1')
|
||||
await users.set('1', { n: 10 })
|
||||
expect(await users.getAt('1', 0)).toBeUndefined()
|
||||
expect(await users.getAt('1', 1)).toEqual({ n: 1 })
|
||||
expect(await users.getAt('1', 2)).toEqual({ n: 2 })
|
||||
expect(await users.getAt('1', 3)).toBeUndefined()
|
||||
expect(await users.child('1').getAt(4)).toEqual({ n: 10 })
|
||||
expect(await users.getAt('1', 99)).toEqual({ n: 10 })
|
||||
expect((await collect(users.child('1').history())).map((e) => e.version)).toEqual([1, 2, 3, 4])
|
||||
})
|
||||
})
|
||||
|
||||
describe('find', () => {
|
||||
async function seed() {
|
||||
const ctx = setup({ hide })
|
||||
const rows: [string, User][] = [
|
||||
['a', { name: 'Ada', age: 36, email: 'ada@x', tags: ['x', 'y'], address: { city: 'London' } }],
|
||||
['b', { name: 'Bob', age: 20, email: 'bob@x', tags: ['y'], address: { city: 'Paris' } }],
|
||||
['c', { name: 'Cy', age: 51, tags: [], address: { city: 'London' } }],
|
||||
['d', { name: 'Di', age: 20, email: 'di@x' }],
|
||||
['e', { name: 'Ed', email: 'ed@x', address: { city: 'London' } }],
|
||||
]
|
||||
for (const [k, v] of rows) await ctx.users.set(k, v)
|
||||
await ctx.users.set('gone', { name: 'Gone', age: 20 })
|
||||
await ctx.users.delete('gone')
|
||||
return ctx
|
||||
}
|
||||
const keysOf = async (it: AsyncIterable<{ key: string }>) => (await collect(it)).map((r) => r.key)
|
||||
|
||||
it('evaluates comparison, logical and element operators', async () => {
|
||||
const { users } = await seed()
|
||||
// Unsorted results come in index or key order depending on the plan; compare as sets.
|
||||
const q = async (f: Filter) => (await keysOf(users.find(f))).sort()
|
||||
expect(await q({})).toEqual(['a', 'b', 'c', 'd', 'e'])
|
||||
expect(await q({ age: 20 })).toEqual(['b', 'd'])
|
||||
expect(await q({ age: { $gte: 20, $lt: 40 } })).toEqual(['a', 'b', 'd'])
|
||||
expect(await q({ age: { $gt: 20 } })).toEqual(['a', 'c'])
|
||||
expect(await q({ age: { $ne: 20 } })).toEqual(['a', 'c', 'e'])
|
||||
expect(await q({ age: { $in: [36, 51] } })).toEqual(['a', 'c'])
|
||||
expect(await q({ age: { $nin: [36, 51] } })).toEqual(['b', 'd', 'e'])
|
||||
expect(await q({ age: { $exists: false } })).toEqual(['e'])
|
||||
expect(await q({ age: null })).toEqual(['e'])
|
||||
expect(await q({ email: { $exists: true }, age: { $lte: 20 } })).toEqual(['b', 'd'])
|
||||
expect(await q({ 'address.city': 'London' })).toEqual(['a', 'c', 'e'])
|
||||
expect(await q({ 'address.city': 'London', age: { $gt: 40 } })).toEqual(['c'])
|
||||
expect(await q({ tags: 'y' })).toEqual(['a', 'b'])
|
||||
expect(await q({ tags: ['y'] })).toEqual(['b'])
|
||||
expect(await q({ $or: [{ age: 51 }, { name: 'Bob' }] })).toEqual(['b', 'c'])
|
||||
expect(await q({ $and: [{ age: { $gte: 20 } }, { age: { $lte: 20 } }] })).toEqual(['b', 'd'])
|
||||
expect(await q({ $not: { age: 20 } })).toEqual(['a', 'c', 'e'])
|
||||
expect(await q({ age: { $not: { $gte: 30 } } })).toEqual(['b', 'd', 'e'])
|
||||
expect(await q({ name: { $gt: 5 } })).toEqual([]) // type-bracketed
|
||||
})
|
||||
|
||||
it('sorts (index-backed and in-memory), skips, limits and projects', async () => {
|
||||
const { users } = await seed()
|
||||
expect(await keysOf(users.find({}, { sort: { age: 1 } }))).toEqual(['e', 'b', 'd', 'a', 'c'])
|
||||
expect(await keysOf(users.find({}, { sort: { age: -1 } }))).toEqual(['c', 'a', 'd', 'b', 'e'])
|
||||
expect(await keysOf(users.find({}, { sort: { name: -1 }, skip: 1, limit: 2 }))).toEqual(['d', 'c'])
|
||||
expect(await keysOf(users.find({ age: { $gte: 20 } }, { sort: { age: -1 }, limit: 2 }))).toEqual(['c', 'a'])
|
||||
expect(await keysOf(users.find({ 'address.city': 'London' }, { sort: { age: 1 } }))).toEqual(['e', 'a', 'c'])
|
||||
expect(await collect(users.find({ age: 36 }, { projection: { name: 1, 'address.city': 1 } }))).toEqual([
|
||||
{ key: 'a', data: { name: 'Ada', address: { city: 'London' } } },
|
||||
])
|
||||
expect(await collect(users.find({ age: 36 }, { projection: { tags: 0, address: 0, email: 0 } }))).toEqual([{ key: 'a', data: { name: 'Ada', age: 36 } }])
|
||||
expect(await users.findOne({ email: 'bob@x' })).toEqual({ key: 'b', data: expect.objectContaining({ name: 'Bob' }) })
|
||||
expect(await users.findOne({ email: 'nobody' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses an index: findOne costs one entry scan and one projection read', async () => {
|
||||
const { users, driver } = await seed()
|
||||
driver.reset()
|
||||
await users.findOne({ email: 'di@x' })
|
||||
expect(driver.calls).toEqual({ scan: 1, getMany: 1 })
|
||||
})
|
||||
|
||||
it('warns once per filter shape on full scans', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const { users } = setup({ hide, warnOnFullScan: true })
|
||||
await collect(users.find({ name: 'x' }))
|
||||
await collect(users.find({ name: 'y' }))
|
||||
await collect(users.find({ age: 1 }))
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0]![0]).toMatch(/full scan for filter \{"name":"\?"\}/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('unique indexes', () => {
|
||||
it('rejects duplicates and frees values on change and delete', async () => {
|
||||
const { users } = setup({ hide })
|
||||
await users.set('1', { email: 'a@x' })
|
||||
await expect(users.set('2', { email: 'a@x' })).rejects.toBeInstanceOf(UniqueConstraintError)
|
||||
await expect(users.insert({ email: 'a@x' })).rejects.toMatchObject({ index: 'email', existingKey: '1' })
|
||||
expect(await collect(users.keys())).toEqual(['1'])
|
||||
await users.update('1', { $set: { email: 'b@x' } })
|
||||
await users.set('2', { email: 'a@x' })
|
||||
await users.delete('2')
|
||||
await users.set('3', { email: 'a@x' })
|
||||
await users.set('4', {}) // missing values are not claimed
|
||||
await users.set('5', {})
|
||||
await users.set('3', { email: 'a@x', name: 'same value, same doc' })
|
||||
})
|
||||
|
||||
it('exactly one of concurrent writers claiming the same value wins', async () => {
|
||||
const { users } = setup({ hide })
|
||||
const results = await Promise.allSettled(['1', '2', '3'].map((k) => users.set(k, { email: 'same' })))
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
||||
expect((await collect(users.find({ email: 'same' }))).length).toBe(1)
|
||||
})
|
||||
|
||||
it('takes over a stale claim only after the grace period', async () => {
|
||||
let t = 0
|
||||
const { users, driver } = setup({ hide, now: () => t, claimGraceMs: 1000 })
|
||||
// Leave an orphaned claim: doc 1's projection and events vanish but its claim remains.
|
||||
await users.set('1', { email: 'a@x' })
|
||||
await driver.inner.deleteMany(['~p/users/1'])
|
||||
await driver.inner.deleteMany((await collect(driver.inner.scan({ gte: 'users/1/', lt: 'users/10' }))).map(([k]) => k))
|
||||
await expect(users.set('2', { email: 'a@x' })).rejects.toBeInstanceOf(UniqueConstraintError)
|
||||
t = 5000
|
||||
await users.set('2', { email: 'a@x' })
|
||||
expect(await users.get('2')).toEqual({ email: 'a@x' })
|
||||
})
|
||||
|
||||
it('requires setIfAbsent', () => {
|
||||
const kv = new KVStorage(new CountingDriver(new InMemoryKVDriver(), ['setIfAbsent']))
|
||||
expect(() => new EventSourcedDocumentStorage(new KVEventStorage(kv), { indexes: userIndexes })).toThrow(CapabilityError)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projection lag (crash between append and projection write)', () => {
|
||||
async function lagged() {
|
||||
const ctx = setup({ hide })
|
||||
await ctx.users.set('1', { n: 1, email: 'a@x' })
|
||||
// Simulate a crash: the event is appended but the projection is never written.
|
||||
await ctx.events.child('users').append('1', [{ op: 'patch', set: { n: 2, email: 'b@x' } }])
|
||||
return ctx
|
||||
}
|
||||
|
||||
it('reads see the stale projection; consistent reads catch up', async () => {
|
||||
const { users } = await lagged()
|
||||
expect(await users.get('1')).toEqual({ n: 1, email: 'a@x' })
|
||||
expect(await users.get('1', { consistent: true })).toEqual({ n: 2, email: 'b@x' })
|
||||
expect(await users.findOne({ email: 'b@x' })).toMatchObject({ key: '1' })
|
||||
expect(await users.findOne({ email: 'a@x' })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('the next write self-heals', async () => {
|
||||
const { users } = await lagged()
|
||||
expect(await users.update('1', { $inc: { n: 1 } })).toEqual({ n: 3, email: 'b@x' })
|
||||
expect((await collect(users.history('1'))).map((e) => e.version)).toEqual([1, 2, 3])
|
||||
await users.set('2', { email: 'a@x' }) // old claim was released by the catch-up
|
||||
})
|
||||
|
||||
it('catchUp() repairs a subtree', async () => {
|
||||
const { users, docs, events } = await lagged()
|
||||
await events
|
||||
.child('users')
|
||||
.child('9')
|
||||
.child('posts')
|
||||
.append('p', [{ op: 'set', doc: { createdAt: 'x' } }])
|
||||
expect(await docs.catchUp()).toBe(2)
|
||||
expect(await users.get('1')).toEqual({ n: 2, email: 'b@x' })
|
||||
expect(await users.child('9').child('posts').get('p')).toEqual({ createdAt: 'x' })
|
||||
expect(await docs.catchUp()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrency', () => {
|
||||
it('two storage instances incrementing the same doc converge', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
const a = setup({ hide, inner }).users
|
||||
const b = setup({ hide, inner }).users
|
||||
await a.set('1', { n: 0 })
|
||||
await Promise.all([...Array(5)].flatMap(() => [a.update('1', { $inc: { n: 1 } }), b.update('1', { $inc: { n: 1 } })]))
|
||||
expect(await a.get('1')).toEqual({ n: 10 })
|
||||
expect(await b.get('1', { consistent: true })).toEqual({ n: 10 })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('costs', () => {
|
||||
it('get is one driver call; update is projection read + append + projection write, even cold', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
await setup({ inner }).users.set('1', { name: 'a', age: 1 })
|
||||
const { users, driver } = setup({ inner }) // fresh instance: cold head cache
|
||||
driver.reset()
|
||||
await users.get('1')
|
||||
expect(driver.calls).toEqual({ getMany: 1 })
|
||||
driver.reset()
|
||||
await users.update('1', { $set: { age: 2 } })
|
||||
expect(driver.calls).toEqual({ getMany: 1, setIfAbsent: 1, batch: 1 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('property: indexed find equals unindexed find', () => {
|
||||
const doc = fc.record(
|
||||
{
|
||||
age: fc.option(fc.integer({ min: 0, max: 5 }), { nil: undefined }),
|
||||
email: fc.constantFrom('a', 'b', 'c', 'd', 'e', 'f'),
|
||||
tags: fc.array(fc.constantFrom('x', 'y', 'z'), { maxLength: 3 }),
|
||||
address: fc.option(fc.record({ city: fc.constantFrom('L', 'P') }), { nil: undefined }),
|
||||
},
|
||||
{ requiredKeys: [] },
|
||||
)
|
||||
const bound = fc.integer({ min: -1, max: 6 })
|
||||
const filter: fc.Arbitrary<Filter> = fc.oneof(
|
||||
fc.record({ age: bound }),
|
||||
fc.record({ age: fc.record({ $gte: bound, $lt: bound }, { requiredKeys: [] }) }),
|
||||
fc.record({ age: fc.record({ $gt: bound, $lte: bound }, { requiredKeys: [] }) }),
|
||||
fc.record({ tags: fc.constantFrom('x', 'y', 'z') }),
|
||||
fc.record({ tags: fc.record({ $gte: fc.constantFrom('x', 'y'), $lt: fc.constantFrom('y', 'zz') }) }),
|
||||
fc.record({ 'address.city': fc.constantFrom('L', 'P'), age: fc.record({ $gt: bound }) }),
|
||||
fc.record({ 'address.city': fc.constantFrom('L', 'P') }),
|
||||
fc.constant({ age: null }),
|
||||
fc.constant({}),
|
||||
)
|
||||
const sort = fc.option(fc.record({ age: fc.constantFrom(1 as const, -1 as const) }), { nil: undefined })
|
||||
|
||||
it('returns the same results in the same order', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.tuple(fc.constantFrom('1', '2', '3', '4', '5'), doc), { maxLength: 12 }),
|
||||
filter,
|
||||
sort,
|
||||
fc.option(fc.integer({ min: 1, max: 4 }), { nil: undefined }),
|
||||
async (writes, f, s, limit) => {
|
||||
const plain = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), { warnOnFullScan: false }).child('users')
|
||||
const indexed = new EventSourcedDocumentStorage(new KVEventStorage(new KVStorage(new InMemoryKVDriver())), {
|
||||
warnOnFullScan: false,
|
||||
indexes: { users: userIndexes.users!.map((i) => ({ ...i, unique: false })) },
|
||||
}).child('users')
|
||||
for (const [k, v] of writes) {
|
||||
await plain.set(k, v)
|
||||
await indexed.set(k, v)
|
||||
}
|
||||
const opts = { ...(s ? { sort: s } : {}), ...(limit ? { limit } : {}) }
|
||||
const a = await collect(plain.find(f, opts))
|
||||
const b = await collect(indexed.find(f, opts))
|
||||
if (s) {
|
||||
expect(b).toEqual(a)
|
||||
return
|
||||
}
|
||||
// Unsorted order depends on the plan: compare as sets (a limited result is any subset of that size).
|
||||
const all = new Set((await collect(plain.find(f))).map((r) => r.key))
|
||||
expect(b).toHaveLength(a.length)
|
||||
for (const r of b) expect(all.has(r.key)).toBe(true)
|
||||
},
|
||||
),
|
||||
{ numRuns: 300 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createIdGenerator } from '../../src/documents/ulid.js'
|
||||
import { matches } from '../../src/documents/filter.js'
|
||||
import { applyPatch, resolveUpdate } from '../../src/documents/update.js'
|
||||
|
||||
describe('ulid', () => {
|
||||
it('is strictly increasing within one millisecond and sorts by time', () => {
|
||||
let t = 1_700_000_000_000
|
||||
const gen = createIdGenerator(() => t)
|
||||
const ids = Array.from({ length: 1000 }, () => gen())
|
||||
expect([...ids].sort()).toEqual(ids)
|
||||
expect(new Set(ids).size).toBe(1000)
|
||||
t += 1
|
||||
const later = gen()
|
||||
expect(later > ids.at(-1)!).toBe(true)
|
||||
expect(later.slice(0, 10) > ids[0]!.slice(0, 10)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('filter semantics', () => {
|
||||
it('requires one array element to satisfy all positive operators of a condition', () => {
|
||||
expect(matches({ s: [1, 20] }, { s: { $gt: 5, $lt: 10 } })).toBe(false) // MongoDB would match
|
||||
expect(matches({ s: [1, 7] }, { s: { $gt: 5, $lt: 10 } })).toBe(true)
|
||||
expect(matches({ s: [1, 20] }, { $and: [{ s: { $gt: 5 } }, { s: { $lt: 10 } }] })).toBe(true)
|
||||
})
|
||||
|
||||
it('traverses arrays of sub-documents on dot paths', () => {
|
||||
expect(matches({ a: [{ b: 1 }, { b: 2 }] }, { 'a.b': 2 })).toBe(true)
|
||||
expect(matches({ a: [{ b: 1 }] }, { 'a.0.b': 1 })).toBe(true)
|
||||
expect(matches({ a: [{ b: 1 }] }, { 'a.b': { $exists: true } })).toBe(true)
|
||||
expect(matches({ a: [] }, { 'a.b': { $exists: true } })).toBe(false)
|
||||
})
|
||||
|
||||
it('treats null as matching missing', () => {
|
||||
expect(matches({}, { x: null })).toBe(true)
|
||||
expect(matches({ x: null }, { x: { $ne: null } })).toBe(false)
|
||||
expect(matches({ x: 0 }, { x: { $ne: null } })).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unknown operators', () => {
|
||||
expect(() => matches({}, { x: { $regex: 'a' } })).toThrow(/Unknown operator/)
|
||||
expect(() => matches({}, { $nor: [] } as never)).toThrow(/Unknown top-level/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('update resolution', () => {
|
||||
it('$pull by sub-document filter and by condition', () => {
|
||||
const state = {
|
||||
items: [
|
||||
{ k: 1, v: 'a' },
|
||||
{ k: 2, v: 'b' },
|
||||
],
|
||||
n: [1, 5, 9],
|
||||
}
|
||||
const patch = resolveUpdate(state, { $pull: { items: { k: 2 }, n: { $gte: 5 } } })
|
||||
expect(applyPatch(state, patch)).toEqual({ items: [{ k: 1, v: 'a' }], n: [1] })
|
||||
})
|
||||
|
||||
it('$unset of an array position nulls it', () => {
|
||||
expect(applyPatch({ a: [1, 2] }, resolveUpdate({ a: [1, 2] }, { $unset: { 'a.0': true } }))).toEqual({ a: [null, 2] })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { ConcurrencyError } from '../../src/utils/errors.js'
|
||||
import { collect, KVStorageCounting } from '../helpers.js'
|
||||
|
||||
function setup(kv = new KVStorageCounting()) {
|
||||
let t = 1000
|
||||
return { kv, events: new EventStorageKV(kv, { now: () => t++ }) }
|
||||
}
|
||||
|
||||
describe('EventStorageKV', () => {
|
||||
it('appends and reads in self and child forms', async () => {
|
||||
const orders = setup().events.child('orders')
|
||||
expect(await orders.append('o1', [{ type: 'created' }], { expectedVersion: 0 })).toEqual({ version: 1 })
|
||||
expect(await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])).toEqual({ version: 3 })
|
||||
expect(await collect(orders.read('o1'))).toEqual([
|
||||
{ version: 1, at: 1000, data: { type: 'created' } },
|
||||
{ version: 2, at: 1001, data: { type: 'paid' } },
|
||||
{ version: 3, at: 1001, data: { type: 'shipped' } },
|
||||
])
|
||||
expect(await collect(orders.child('o1').read({ from: 3 }))).toHaveLength(1)
|
||||
expect(await orders.version('o1')).toBe(3)
|
||||
expect(await orders.version('none')).toBe(0)
|
||||
})
|
||||
|
||||
it('stores one key per commit', async () => {
|
||||
const { kv, events } = setup()
|
||||
await events.child('s').append(['a'])
|
||||
await events.child('s').append(['b', 'c'])
|
||||
expect((await collect(kv.scan())).map(([k]) => k)).toEqual(['s/~e/000000000001', 's/~e/000000000002'])
|
||||
})
|
||||
|
||||
it('enforces expectedVersion and the arity rules', async () => {
|
||||
const { events } = setup()
|
||||
const s = events.child('s')
|
||||
await s.append(['a'])
|
||||
await expect(s.append(['b'], { expectedVersion: 0 })).rejects.toBeInstanceOf(ConcurrencyError)
|
||||
await expect(events.append(['x'])).rejects.toThrow(/root/)
|
||||
await expect(events.child('o').append(undefined as never, ['x'] as never)).rejects.toThrow(TypeError)
|
||||
await expect(events.child('o').version(undefined as never)).rejects.toThrow(TypeError)
|
||||
})
|
||||
|
||||
it('lists child streams', async () => {
|
||||
const orders = setup().events.child('orders')
|
||||
await orders.append('o1', ['a'])
|
||||
await orders.append('o2', ['b'])
|
||||
await orders.child('o1').append('notes', ['n'])
|
||||
expect(await collect(orders.keys())).toEqual(['o1', 'o2'])
|
||||
expect(await collect(orders.child('o1').keys())).toEqual(['notes'])
|
||||
})
|
||||
|
||||
it('a warm append is one writeIfAbsent; a cold one adds one reverse scan', async () => {
|
||||
const { kv, events } = setup()
|
||||
await events.child('s').append(['a'])
|
||||
kv.reset()
|
||||
await events.child('s').append(['b'])
|
||||
expect(kv.calls).toEqual({ writeIfAbsent: 1 })
|
||||
const cold = setup(kv).events
|
||||
kv.reset()
|
||||
await cold.child('s').append(['c'])
|
||||
expect(kv.calls).toEqual({ scan: 1, writeIfAbsent: 1 })
|
||||
})
|
||||
|
||||
it('a writer with a stale cache collides; with "any" it retries', async () => {
|
||||
const kv = new KVStorageInMemory()
|
||||
const a = setup(new KVStorageCounting(kv)).events.child('s')
|
||||
const b = setup(new KVStorageCounting(kv)).events.child('s')
|
||||
await a.append(['a1'])
|
||||
await b.append(['b1'])
|
||||
await expect(a.append(['a2'], { expectedVersion: 1 })).rejects.toMatchObject({ actualVersion: 2 })
|
||||
expect(await a.append(['a3'])).toEqual({ version: 3 })
|
||||
const results = await Promise.allSettled([a.append(['x'], { expectedVersion: 3 }), b.append(['y'], { expectedVersion: 3 })])
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reads any window correctly (property)', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 20 }),
|
||||
fc.record({
|
||||
from: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }),
|
||||
to: fc.option(fc.integer({ min: 0, max: 60 }), { nil: undefined }),
|
||||
limit: fc.option(fc.nat(10), { nil: undefined }),
|
||||
}),
|
||||
async (sizes, options) => {
|
||||
const s = setup().events.child('s')
|
||||
let n = 0
|
||||
for (const size of sizes) await s.append(Array.from({ length: size }, () => ++n))
|
||||
let expected = Array.from({ length: n }, (_, i) => i + 1).filter((v) => v >= (options.from ?? 1) && v <= (options.to ?? Infinity))
|
||||
if (options.limit !== undefined) expected = expected.slice(0, options.limit)
|
||||
expect((await collect(s.read(options))).map((e) => e.data)).toEqual(expected)
|
||||
},
|
||||
),
|
||||
{ numRuns: 200 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,200 +0,0 @@
|
||||
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 { KVEventStorage } from '../../src/events/kv.js'
|
||||
import { ConcurrencyError } from '../../src/errors.js'
|
||||
import { collect, CountingDriver, type Capability } from '../helpers.js'
|
||||
|
||||
function setup(hide: Capability[] = [], inner = new InMemoryKVDriver()) {
|
||||
const driver = new CountingDriver(inner, hide)
|
||||
let t = 1000
|
||||
const events = new KVEventStorage(new KVStorage(driver), { now: () => t++ })
|
||||
return { driver, events }
|
||||
}
|
||||
|
||||
describe('KVEventStorage', () => {
|
||||
it('appends and reads with versions and metadata', async () => {
|
||||
const { events } = setup()
|
||||
const orders = events.child('orders')
|
||||
expect(await orders.append('o1', [{ type: 'created', total: 40 }], { expectedVersion: 0 })).toEqual({ version: 1, commit: 1 })
|
||||
expect(await orders.child('o1').append([{ type: 'paid' }, { type: 'shipped' }])).toEqual({ version: 3, commit: 2 })
|
||||
expect(await collect(orders.read('o1'))).toEqual([
|
||||
{ version: 1, commit: 1, at: 1000, data: { type: 'created', total: 40 } },
|
||||
{ version: 2, commit: 2, at: 1001, data: { type: 'paid' } },
|
||||
{ version: 3, commit: 2, at: 1001, data: { type: 'shipped' } },
|
||||
])
|
||||
expect(await orders.version('o1')).toBe(3)
|
||||
expect(await orders.child('o1').version()).toBe(3)
|
||||
expect(await orders.version('nope')).toBe(0)
|
||||
})
|
||||
|
||||
it('stores one key per commit under <stream>/~e/<seq:12>', async () => {
|
||||
const { events, driver } = setup()
|
||||
await events.child('orders').append('o1', ['a'])
|
||||
await events.child('orders').append('o1', ['b', 'c'])
|
||||
expect(await collect(driver.inner.scan({ values: true }))).toEqual([
|
||||
['orders/o1/~e/000000000001', { firstVersion: 1, at: 1000, events: ['a'] }],
|
||||
['orders/o1/~e/000000000002', { firstVersion: 2, at: 1001, events: ['b', 'c'] }],
|
||||
])
|
||||
})
|
||||
|
||||
it('enforces expectedVersion', async () => {
|
||||
const { events } = setup()
|
||||
const s = events.child('s')
|
||||
await s.append(['a'], { expectedVersion: 0 })
|
||||
await expect(s.append(['b'], { expectedVersion: 0 })).rejects.toBeInstanceOf(ConcurrencyError)
|
||||
await expect(s.append(['b'], { expectedVersion: 5 })).rejects.toMatchObject({ expectedVersion: 5, actualVersion: 1 })
|
||||
expect(await s.append(['b'], { expectedVersion: 1 })).toEqual({ version: 2, commit: 2 })
|
||||
await expect(s.append([], { expectedVersion: 1 })).rejects.toBeInstanceOf(ConcurrencyError)
|
||||
expect(await s.append([])).toEqual({ version: 2, commit: 2 })
|
||||
})
|
||||
|
||||
it('applies the arity rules', async () => {
|
||||
const { events } = setup()
|
||||
const orders = events.child('orders')
|
||||
const id: string | undefined = undefined
|
||||
await expect(orders.append(id as never, ['x'] as never)).rejects.toThrow(TypeError)
|
||||
await expect(orders.version(id as never)).rejects.toThrow(TypeError)
|
||||
await expect(collect(orders.read(id as never))).rejects.toThrow(TypeError)
|
||||
await expect(events.append(['x'])).rejects.toThrow(/root/)
|
||||
await expect(orders.append('o1', 'x' as never)).rejects.toThrow(/array/)
|
||||
})
|
||||
|
||||
it('lists child streams and keeps nested streams separate', async () => {
|
||||
const { events } = setup()
|
||||
const orders = events.child('orders')
|
||||
await orders.append('o1', ['a'])
|
||||
await orders.append('o2', ['b'])
|
||||
await orders.child('o1').child('shipments').append('s1', ['ship'])
|
||||
await orders.child('o1').append('notes', ['n'])
|
||||
expect(await collect(orders.keys())).toEqual(['o1', 'o2'])
|
||||
expect(await collect(orders.child('o1').keys())).toEqual(['notes', 'shipments'])
|
||||
expect((await collect(orders.read('o1'))).map((e) => e.data)).toEqual(['a'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('read windows (property)', () => {
|
||||
it('matches brute force for any commit layout', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(
|
||||
fc.array(fc.integer({ min: 1, max: 4 }), { maxLength: 25 }),
|
||||
fc.record({
|
||||
from: fc.option(fc.integer({ min: -1, max: 70 }), { nil: undefined }),
|
||||
to: fc.option(fc.integer({ min: 0, max: 70 }), { nil: undefined }),
|
||||
reverse: fc.boolean(),
|
||||
limit: fc.option(fc.integer({ min: 0, max: 10 }), { nil: undefined }),
|
||||
}),
|
||||
async (sizes, opts) => {
|
||||
const { events } = setup()
|
||||
const s = events.child('s')
|
||||
const all: number[] = []
|
||||
for (const n of sizes) {
|
||||
const batch = Array.from({ length: n }, (_, i) => all.length + i + 1)
|
||||
await s.append(batch)
|
||||
all.push(...batch)
|
||||
}
|
||||
let exp = all.filter((v) => v >= Math.max(1, opts.from ?? 1) && v <= (opts.to ?? Infinity))
|
||||
if (opts.reverse) exp.reverse()
|
||||
if (opts.limit !== undefined) exp = exp.slice(0, opts.limit)
|
||||
const got = await collect(s.read(opts))
|
||||
expect(got.map((e) => e.data)).toEqual(exp)
|
||||
expect(got.map((e) => e.version)).toEqual(exp)
|
||||
},
|
||||
),
|
||||
{ numRuns: 300 },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('costs', () => {
|
||||
it('cached head: a warm append is a single setIfAbsent', async () => {
|
||||
const { events, driver } = setup()
|
||||
const s = events.child('s')
|
||||
await s.append(['a'])
|
||||
driver.reset()
|
||||
await s.append(['b'])
|
||||
await s.append(['c'], { expectedVersion: 2 })
|
||||
expect(driver.calls).toEqual({ setIfAbsent: 2 })
|
||||
})
|
||||
|
||||
it('cold head lookup is one reverse scan with limit 1', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
await setup([], inner).events.child('s').append(['a', 'b'])
|
||||
const { events, driver } = setup([], inner)
|
||||
expect(await events.child('s').append(['c'])).toEqual({ version: 3, commit: 2 })
|
||||
expect(driver.calls).toEqual({ scan: 1, setIfAbsent: 1 })
|
||||
})
|
||||
|
||||
it('read({ from }) finds single-event commits in one probe', async () => {
|
||||
const { events, driver } = setup()
|
||||
const s = events.child('s')
|
||||
for (let i = 0; i < 200; i++) await s.append([i])
|
||||
driver.reset()
|
||||
expect((await collect(s.read({ from: 150, limit: 1 }))).map((e) => e.version)).toEqual([150])
|
||||
expect(driver.calls).toEqual({ getMany: 1, scan: 1 })
|
||||
})
|
||||
|
||||
it('read({ from }) uses O(log commits) probes for multi-event commits', async () => {
|
||||
const { events, driver } = setup()
|
||||
const s = events.child('s')
|
||||
for (let i = 0; i < 1000; i++) await s.append(i % 3 === 0 ? [i, i] : [i])
|
||||
driver.reset()
|
||||
const [first] = await collect(s.read({ from: 777, limit: 1 }))
|
||||
expect(first?.version).toBe(777)
|
||||
// 1 guess probe + ceil(log8(1000)) search rounds, then the read scan. The guess hit an
|
||||
// existing commit, so no head scan was needed.
|
||||
expect(driver.calls.getMany).toBeLessThanOrEqual(1 + 4)
|
||||
expect(driver.calls.scan).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('concurrency', () => {
|
||||
it('a stale writer collides with the existing commit key', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
const a = setup([], inner).events.child('s')
|
||||
const b = setup([], inner).events.child('s')
|
||||
await a.append(['a1'])
|
||||
await b.append(['b1']) // a's cached head is now stale
|
||||
await expect(a.append(['a2'], { expectedVersion: 1 })).rejects.toMatchObject({ actualVersion: 2 })
|
||||
// 'any' retries after refreshing the head
|
||||
expect(await a.append(['a3'])).toEqual({ version: 3, commit: 3 })
|
||||
expect((await collect(b.read())).map((e) => e.data)).toEqual(['a1', 'b1', 'a3'])
|
||||
})
|
||||
|
||||
it('a stale cached head is refreshed before rejecting expectedVersion', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
const a = setup([], inner).events.child('s')
|
||||
const b = setup([], inner).events.child('s')
|
||||
await a.append(['a1'])
|
||||
await b.append(['b1'])
|
||||
expect(await a.append(['a2'], { expectedVersion: 2 })).toEqual({ version: 3, commit: 3 })
|
||||
})
|
||||
|
||||
it('interleaved writers from two instances produce a dense, non-overlapping log', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
const a = setup([], inner).events.child('s')
|
||||
const b = setup([], inner).events.child('s')
|
||||
await Promise.all([a.append(['a1']), b.append(['b1']), a.append(['a2', 'a3']), b.append(['b2'])])
|
||||
const log = await collect(a.read())
|
||||
expect(log.map((e) => e.version)).toEqual([1, 2, 3, 4, 5])
|
||||
expect(new Set(log.map((e) => e.data))).toEqual(new Set(['a1', 'a2', 'a3', 'b1', 'b2']))
|
||||
})
|
||||
|
||||
it('exactly one of several optimistic writers at the same version wins', async () => {
|
||||
const inner = new InMemoryKVDriver()
|
||||
const writers = [0, 1, 2, 3].map(() => setup([], inner).events.child('s'))
|
||||
const results = await Promise.allSettled(writers.map((w, i) => w.append([i], { expectedVersion: 0 })))
|
||||
expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1)
|
||||
expect(results.filter((r) => r.status === 'rejected').every((r) => (r as PromiseRejectedResult).reason instanceof ConcurrencyError)).toBe(true)
|
||||
})
|
||||
|
||||
it('without setIfAbsent, an in-process mutex serialises appends (single-writer mode)', async () => {
|
||||
const { events, driver } = setup(['setIfAbsent'])
|
||||
const s = events.child('s')
|
||||
await Promise.all(Array.from({ length: 20 }, (_, i) => s.append([i])))
|
||||
expect((await collect(s.read())).map((e) => e.version)).toEqual(Array.from({ length: 20 }, (_, i) => i + 1))
|
||||
expect(driver.calls.setIfAbsent).toBeUndefined()
|
||||
await expect(Promise.all([s.append(['x'], { expectedVersion: 20 }), s.append(['y'], { expectedVersion: 20 })])).rejects.toBeInstanceOf(ConcurrencyError)
|
||||
})
|
||||
})
|
||||
+28
-66
@@ -1,83 +1,45 @@
|
||||
import { BaseKVDriver, type BatchOp, type KVDriver, type ScanOptions } from '../src/kv/driver.js'
|
||||
import { InMemoryKVDriver } from '../src/kv/memory.js'
|
||||
import { BaseKVStorage, type ScanOptions } from '../src/kv-storage/base-kv-storage.js'
|
||||
import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
|
||||
|
||||
/** A minimal driver implementing only the four required methods, over a plain Map. */
|
||||
export class MinimalDriver extends BaseKVDriver {
|
||||
readonly map = new Map<string, string>()
|
||||
async getMany(keys: string[]) {
|
||||
return keys.map((k) => (this.map.has(k) ? JSON.parse(this.map.get(k)!) : undefined))
|
||||
}
|
||||
async setMany(entries: [string, unknown][]) {
|
||||
for (const [k, v] of entries) this.map.set(k, JSON.stringify(v))
|
||||
}
|
||||
async deleteMany(keys: string[]) {
|
||||
for (const k of keys) this.map.delete(k)
|
||||
}
|
||||
async *scan({ gte, lt, limit = Infinity, reverse, values }: ScanOptions): AsyncGenerator<[string, unknown]> {
|
||||
let keys = [...this.map.keys()].filter((k) => (gte === undefined || k >= gte) && (lt === undefined || k < lt)).sort()
|
||||
if (reverse) keys.reverse()
|
||||
keys = keys.slice(0, limit)
|
||||
for (const k of keys) {
|
||||
if (!this.map.has(k)) continue
|
||||
yield [k, values ? JSON.parse(this.map.get(k)!) : undefined]
|
||||
}
|
||||
}
|
||||
export async function collect<T>(iterable: AsyncIterable<T>): Promise<T[]> {
|
||||
const out: T[] = []
|
||||
for await (const item of iterable) out.push(item)
|
||||
return out
|
||||
}
|
||||
|
||||
export type Capability = 'setIfAbsent' | 'batch' | 'deleteRange'
|
||||
|
||||
/**
|
||||
* Wraps a driver, counting calls per method and optionally hiding optional capabilities.
|
||||
* `scan` counts each scan started (i.e. each seek).
|
||||
* Wraps another KV storage and counts primitive calls. Also shows the wrapper pattern
|
||||
* (e.g. an encryption layer): extend BaseKVStorage and delegate the five primitives.
|
||||
*/
|
||||
export class CountingDriver implements KVDriver {
|
||||
export class KVStorageCounting extends BaseKVStorage {
|
||||
calls: Record<string, number> = {}
|
||||
setIfAbsent?: KVDriver['setIfAbsent']
|
||||
batch?: KVDriver['batch']
|
||||
deleteRange?: KVDriver['deleteRange']
|
||||
|
||||
constructor(
|
||||
readonly inner: KVDriver = new InMemoryKVDriver(),
|
||||
hide: Capability[] = [],
|
||||
) {
|
||||
if (inner.setIfAbsent && !hide.includes('setIfAbsent')) this.setIfAbsent = (e: [string, unknown][]) => (this.#count('setIfAbsent'), inner.setIfAbsent!(e))
|
||||
if (inner.batch && !hide.includes('batch')) this.batch = (ops: BatchOp[]) => (this.#count('batch'), inner.batch!(ops))
|
||||
if (inner.deleteRange && !hide.includes('deleteRange'))
|
||||
this.deleteRange = (gte: string, lt: string) => (this.#count('deleteRange'), inner.deleteRange!(gte, lt))
|
||||
}
|
||||
|
||||
#count(m: string): void {
|
||||
this.calls[m] = (this.calls[m] ?? 0) + 1
|
||||
constructor(readonly inner: BaseKVStorage = new KVStorageInMemory()) {
|
||||
super()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.calls = {}
|
||||
}
|
||||
|
||||
get total(): number {
|
||||
return Object.values(this.calls).reduce((a, b) => a + b, 0)
|
||||
readMany(keys: string[]) {
|
||||
return this.count('readMany', () => this.inner.readMany(keys))
|
||||
}
|
||||
writeMany(entries: [string, unknown][]) {
|
||||
return this.count('writeMany', () => this.inner.writeMany(entries))
|
||||
}
|
||||
removeMany(keys: string[]) {
|
||||
return this.count('removeMany', () => this.inner.removeMany(keys))
|
||||
}
|
||||
writeIfAbsent(entries: [string, unknown][]) {
|
||||
return this.count('writeIfAbsent', () => this.inner.writeIfAbsent(entries))
|
||||
}
|
||||
scan(options?: ScanOptions) {
|
||||
return this.count('scan', () => this.inner.scan(options))
|
||||
}
|
||||
|
||||
getMany(keys: string[]) {
|
||||
this.#count('getMany')
|
||||
return this.inner.getMany(keys)
|
||||
}
|
||||
setMany(entries: [string, unknown][]) {
|
||||
this.#count('setMany')
|
||||
return this.inner.setMany(entries)
|
||||
}
|
||||
deleteMany(keys: string[]) {
|
||||
this.#count('deleteMany')
|
||||
return this.inner.deleteMany(keys)
|
||||
}
|
||||
scan(opts: ScanOptions) {
|
||||
this.#count('scan')
|
||||
return this.inner.scan(opts)
|
||||
private count<T>(name: string, fn: () => T): T {
|
||||
this.calls[name] = (this.calls[name] ?? 0) + 1
|
||||
return fn()
|
||||
}
|
||||
}
|
||||
|
||||
export async function collect<T>(it: AsyncIterable<T>): Promise<T[]> {
|
||||
const out: T[] = []
|
||||
for await (const x of it) out.push(x)
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { InMemoryKVDriver } from '../../src/kv/memory.js'
|
||||
import type { KVDriver } from '../../src/kv/driver.js'
|
||||
import { KVStorage } from '../../src/kv/node.js'
|
||||
import { KVEventStorage } from '../../src/events/kv.js'
|
||||
import { EventSourcedDocumentStorage } from '../../src/documents/event-sourced.js'
|
||||
import { createKVHttpHandler } from '../../src/http/handler.js'
|
||||
import { HttpKVDriver } from '../../src/http/client.js'
|
||||
import { runKVDriverConformance } from '../../src/testing/kv-conformance.js'
|
||||
import { collect, CountingDriver, MinimalDriver } from '../helpers.js'
|
||||
import { serve } from './node-server.js'
|
||||
|
||||
/** A client wired straight to a handler, no socket. */
|
||||
function inProcess(server: KVDriver = new InMemoryKVDriver(), caps?: ConstructorParameters<typeof HttpKVDriver>[0]['capabilities']) {
|
||||
const handler = createKVHttpHandler(server, { basePath: '/kv' })
|
||||
return new HttpKVDriver({ url: 'http://test/kv', capabilities: caps, fetch: async (input, init) => handler(new Request(input, init)) })
|
||||
}
|
||||
|
||||
runKVDriverConformance('HttpKVDriver (in-process)', () => inProcess())
|
||||
runKVDriverConformance('HttpKVDriver (minimal server driver, discovered capabilities)', () => {
|
||||
const handler = createKVHttpHandler(new MinimalDriver())
|
||||
return HttpKVDriver.connect({ url: 'http://test', fetch: async (input, init) => handler(new Request(input, init)) })
|
||||
})
|
||||
|
||||
describe('over a real socket', () => {
|
||||
let server: Awaited<ReturnType<typeof serve>>
|
||||
const backends: InMemoryKVDriver[] = []
|
||||
beforeAll(async () => {
|
||||
// One server; each driver gets a fresh backend selected by path.
|
||||
server = await serve(async (req) => {
|
||||
const m = /^\/db(\d+)/.exec(new URL(req.url).pathname)
|
||||
const backend = backends[Number(m?.[1])]
|
||||
if (!backend) return new Response('no db', { status: 404 })
|
||||
return createKVHttpHandler(backend, { basePath: `/db${m![1]}` })(req)
|
||||
})
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
const fresh = () => {
|
||||
backends.push(new InMemoryKVDriver())
|
||||
return HttpKVDriver.connect({ url: `${server.url}/db${backends.length - 1}` })
|
||||
}
|
||||
|
||||
runKVDriverConformance('HttpKVDriver (node:http)', fresh)
|
||||
|
||||
it('cancels a server-side scan when the client stops early', async () => {
|
||||
const d = await fresh()
|
||||
await d.setMany(Array.from({ length: 5000 }, (_, i) => [`k${String(i).padStart(5, '0')}`, i] as [string, number]))
|
||||
const first: string[] = []
|
||||
for await (const [k] of d.scan({})) {
|
||||
first.push(k)
|
||||
if (first.length === 3) break
|
||||
}
|
||||
expect(first).toEqual(['k00000', 'k00001', 'k00002'])
|
||||
})
|
||||
|
||||
it('runs the whole stack (KV indexes, events, documents) over HTTP', async () => {
|
||||
const kv = new KVStorage(await fresh(), { indexes: { users: { byAge: (u: { age: number }) => u.age } } })
|
||||
await kv.child('users').set('1', { age: 30 })
|
||||
expect(await collect(kv.child('users').findBy('byAge', { gte: 18 }))).toEqual([['1', { age: 30 }]])
|
||||
const docs = new EventSourcedDocumentStorage(new KVEventStorage(kv), { indexes: { people: [{ fields: ['email'], unique: true }] } })
|
||||
const id = await docs.child('people').insert({ email: 'a@x', n: 1 })
|
||||
await docs.child('people').update(id, { $inc: { n: 1 } })
|
||||
await expect(docs.child('people').insert({ email: 'a@x' })).rejects.toThrow(/Unique/)
|
||||
expect(await docs.child('people').findOne({ email: 'a@x' })).toEqual({ key: id, data: { email: 'a@x', n: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('handler', () => {
|
||||
it('reports capabilities and rejects unsupported operations with 501', async () => {
|
||||
const handler = createKVHttpHandler(new MinimalDriver())
|
||||
const caps = await (await handler(new Request('http://x/capabilities'))).json()
|
||||
expect(caps).toEqual({ setIfAbsent: false, batch: false, deleteRange: true })
|
||||
const res = await handler(new Request('http://x/batch', { method: 'POST', body: JSON.stringify({ ops: [] }) }))
|
||||
expect(res.status).toBe(501)
|
||||
})
|
||||
|
||||
it('maps driver errors to 500 and unknown routes to 404', async () => {
|
||||
const broken = new CountingDriver(new InMemoryKVDriver())
|
||||
broken.getMany = () => Promise.reject(new Error('boom'))
|
||||
const handler = createKVHttpHandler(broken)
|
||||
expect((await handler(new Request('http://x/getMany', { method: 'POST', body: '{"keys":["a"]}' }))).status).toBe(500)
|
||||
expect((await handler(new Request('http://x/nope'))).status).toBe(404)
|
||||
await expect(inProcess(new MinimalDriver(), { batch: true }).batch!([])).rejects.toThrow(/HTTP 501/)
|
||||
})
|
||||
|
||||
it('distinguishes null values from missing keys', async () => {
|
||||
const d = inProcess()
|
||||
await d.setMany([['n', null]])
|
||||
expect(await d.getMany(['n', 'missing'])).toEqual([null, undefined])
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { createServer, type IncomingHttpHeaders } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
/** Serves a `Request → Response` handler over a real socket (test helper). */
|
||||
export async function serve(handler: (req: Request) => Promise<Response>): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const c of req) chunks.push(c as Buffer)
|
||||
const headers = Object.entries(req.headers as IncomingHttpHeaders).flatMap(([k, v]) => (v === undefined ? [] : [[k, String(v)] as [string, string]]))
|
||||
const request = new Request(`http://localhost${req.url}`, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Buffer.concat(chunks),
|
||||
})
|
||||
const response = await handler(request)
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers))
|
||||
if (!response.body) return void res.end()
|
||||
const reader = response.body.getReader()
|
||||
res.on('close', () => void reader.cancel().catch(() => {}))
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
res.write(value)
|
||||
}
|
||||
} finally {
|
||||
res.end()
|
||||
}
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const { port } = server.address() as AddressInfo
|
||||
return {
|
||||
url: `http://127.0.0.1:${port}`,
|
||||
close: () => new Promise((resolve) => (server.closeAllConnections(), server.close(() => resolve()))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { KVStorageHttp } from '../../src/kv-storage/kv-storage-http.js'
|
||||
import { KVStorageHttpServer } from '../../src/kv-storage/kv-storage-http-server.js'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { EventStorageKV } from '../../src/event-storage/event-storage-kv.js'
|
||||
import { DocumentStorageKV } from '../../src/document-storage/document-storage-kv.js'
|
||||
import { EventSourcedDocumentStorage } from '../../src/document-storage/event-sourced-document-storage.js'
|
||||
import { runKVStorageConformance } from './kv-storage.conformance.js'
|
||||
import { serve } from './node-server.js'
|
||||
|
||||
runKVStorageConformance('KVStorageHttp (in-process)', () => {
|
||||
const server = new KVStorageHttpServer(new KVStorageInMemory(), '/kv')
|
||||
return new KVStorageHttp({ url: 'http://test/kv', fetch: async (input, init) => server.handle(new Request(input, init)) })
|
||||
})
|
||||
|
||||
describe('over a real socket', () => {
|
||||
let server: Awaited<ReturnType<typeof serve>>
|
||||
const stores: KVStorageHttpServer[] = []
|
||||
beforeAll(async () => {
|
||||
// One socket; each test storage gets its own backend under /<n>.
|
||||
server = await serve((request) => stores[Number(new URL(request.url).pathname.split('/')[1])]!.handle(request))
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
const fresh = () => {
|
||||
stores.push(new KVStorageHttpServer(new KVStorageInMemory(), `/${stores.length}`))
|
||||
return new KVStorageHttp({ url: `${server.url}/${stores.length - 1}` })
|
||||
}
|
||||
|
||||
runKVStorageConformance('KVStorageHttp (node:http)', fresh)
|
||||
|
||||
it('stops a long scan early', async () => {
|
||||
const db = fresh()
|
||||
await db.writeMany(Array.from({ length: 5000 }, (_, i) => [`k${String(i).padStart(5, '0')}`, i] as [string, number]))
|
||||
const seen: string[] = []
|
||||
for await (const [key] of db.scan()) if (seen.push(key) === 3) break
|
||||
expect(seen).toEqual(['k00000', 'k00001', 'k00002'])
|
||||
})
|
||||
|
||||
it('runs events and event-sourced documents over HTTP', async () => {
|
||||
const kv = fresh()
|
||||
const docs = new EventSourcedDocumentStorage(new EventStorageKV(kv), new DocumentStorageKV(kv.child('projections')))
|
||||
const id = await docs.child('people').insert({ email: 'a@x', n: 1 })
|
||||
await docs.child('people').update(id, { $inc: { n: 1 } })
|
||||
expect(await docs.child('people').findOne({ email: 'a@x' })).toEqual({ key: id, data: { email: 'a@x', n: 2 } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('server', () => {
|
||||
it('maps errors to 404 / 500', async () => {
|
||||
const server = new KVStorageHttpServer(new KVStorageInMemory())
|
||||
expect((await server.handle(new Request('http://x/nope', { method: 'POST', body: '{}' }))).status).toBe(404)
|
||||
expect((await server.handle(new Request('http://x/readMany', { method: 'POST', body: '{"keys": 5}' }))).status).toBe(500)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { KVStorageInMemory } from '../../src/kv-storage/kv-storage-in-memory.js'
|
||||
import type { BaseKVStorage } from '../../src/kv-storage/base-kv-storage.js'
|
||||
import { collect, KVStorageCounting } from '../helpers.js'
|
||||
import { runKVStorageConformance } from './kv-storage.conformance.js'
|
||||
|
||||
runKVStorageConformance('KVStorageInMemory', () => new KVStorageInMemory())
|
||||
runKVStorageConformance('wrapper (KVStorageCounting)', () => new KVStorageCounting())
|
||||
|
||||
describe('costs', () => {
|
||||
it('single-key operations are one primitive call', async () => {
|
||||
const db = new KVStorageCounting()
|
||||
const users = db.child('users')
|
||||
await users.set('1', 'x')
|
||||
await users.get('1')
|
||||
await users.child('1').get()
|
||||
await users.delete('1')
|
||||
expect(db.calls).toEqual({ writeMany: 1, readMany: 2, removeMany: 1 })
|
||||
})
|
||||
|
||||
it('keys() seeks once per child with descendants, not once per key', async () => {
|
||||
const db = new KVStorageCounting()
|
||||
const writes: [string, number][] = []
|
||||
for (let c = 0; c < 50; c++) for (let d = 0; d < 20; d++) writes.push([`n/c${c}/d${d}`, d])
|
||||
for (let c = 0; c < 50; c++) writes.push([`n/leaf${c}`, c])
|
||||
await db.writeMany(writes)
|
||||
db.reset()
|
||||
expect(await collect(db.child('n').keys())).toHaveLength(100)
|
||||
expect(db.calls).toEqual({ scan: 51 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('model-based: random writes vs a reference map', () => {
|
||||
const segment = fc.constantFrom('a', 'a-1', 'a.b', 'b', 'x/y', '%', '~e', '1', '10', '9', 'ab', 'a!', 'é')
|
||||
const path = fc.array(segment, { minLength: 1, maxLength: 3 })
|
||||
const op = fc.oneof(
|
||||
fc.record({ t: fc.constant('set' as const), path, value: fc.integer() }),
|
||||
fc.record({ t: fc.constant('delete' as const), path }),
|
||||
fc.record({ t: fc.constant('deleteTree' as const), path }),
|
||||
)
|
||||
const at = (db: BaseKVStorage, p: string[]) => p.reduce<BaseKVStorage>((n, s) => n.child(s), db)
|
||||
|
||||
it('get, keys and entries agree with the model', async () => {
|
||||
await fc.assert(
|
||||
fc.asyncProperty(fc.array(op, { maxLength: 30 }), fc.array(segment, { maxLength: 2 }), async (ops, parent) => {
|
||||
const db = new KVStorageInMemory()
|
||||
const model = new Map<string, number>()
|
||||
const isUnder = (p: string[], q: string[]) => q.length >= p.length && p.every((s, i) => q[i] === s)
|
||||
for (const o of ops) {
|
||||
if (o.t === 'set') (await at(db, o.path).set(o.value), model.set(JSON.stringify(o.path), o.value))
|
||||
else if (o.t === 'delete') (await at(db, o.path).delete(), model.delete(JSON.stringify(o.path)))
|
||||
else {
|
||||
await at(db, o.path).deleteTree()
|
||||
for (const k of [...model.keys()]) if (isUnder(o.path, JSON.parse(k))) model.delete(k)
|
||||
}
|
||||
}
|
||||
for (const [k, v] of model) expect(await at(db, JSON.parse(k)).get()).toBe(v)
|
||||
const children = new Map<string, number | undefined>()
|
||||
for (const [k, v] of model) {
|
||||
const p = JSON.parse(k) as string[]
|
||||
if (p.length <= parent.length || !isUnder(parent, p)) continue
|
||||
const own = p.length === parent.length + 1
|
||||
if (own || !children.has(p[parent.length]!)) children.set(p[parent.length]!, own ? v : children.get(p[parent.length]!))
|
||||
}
|
||||
const node = at(db, parent)
|
||||
const listed = await collect(node.keys())
|
||||
expect([...listed].sort()).toEqual([...children.keys()].sort())
|
||||
expect(await collect(node.keys({ reverse: true }))).toEqual([...listed].reverse())
|
||||
const withValues = listed.filter((k) => children.get(k) !== undefined)
|
||||
expect(await collect(node.entries())).toEqual(withValues.map((k) => [k, children.get(k)]))
|
||||
expect(await collect(node.entries({ reverse: true }))).toEqual(withValues.reverse().map((k) => [k, children.get(k)]))
|
||||
for (let i = 0; i < listed.length; i++) expect(await collect(node.keys({ after: listed[i]! }))).toEqual(listed.slice(i + 1))
|
||||
}),
|
||||
{ numRuns: 300 },
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
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<BaseKVStorage>): 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([
|
||||
['b', 2],
|
||||
['a/2', { n: 'a2' }],
|
||||
['c', [3]],
|
||||
['a', 1],
|
||||
['a/1', 'a1'],
|
||||
['a0', null],
|
||||
['10', 10],
|
||||
['9', 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('writeIfAbsent writes only absent keys, and only one concurrent claim wins', async () => {
|
||||
expect(
|
||||
await db.writeIfAbsent([
|
||||
['a', 'x'],
|
||||
['new', 'y'],
|
||||
]),
|
||||
).toEqual([false, true])
|
||||
expect(await db.readMany(['a', 'new'])).toEqual([1, 'y'])
|
||||
const results = await Promise.all([1, 2, 3].map((n) => db.writeIfAbsent([['claim', n]])))
|
||||
expect(results.flat().filter(Boolean)).toHaveLength(1)
|
||||
})
|
||||
|
||||
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('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('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([['n/~e/000000000001', {}]])
|
||||
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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createServer } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
|
||||
/** Serves a `Request → Response` handler over a real socket (test helper). */
|
||||
export async function serve(handle: (request: Request) => Promise<Response>): Promise<{ url: string; close: () => Promise<void> }> {
|
||||
const server = createServer(async (req, res) => {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of req) chunks.push(chunk as Buffer)
|
||||
const response = await handle(
|
||||
new Request(`http://localhost${req.url}`, { method: req.method, body: req.method === 'POST' ? Buffer.concat(chunks) : undefined }),
|
||||
)
|
||||
res.writeHead(response.status, Object.fromEntries(response.headers))
|
||||
if (!response.body) return void res.end()
|
||||
const reader = response.body.getReader()
|
||||
res.on('close', () => void reader.cancel().catch(() => {}))
|
||||
for (let next = await reader.read(); !next.done; next = await reader.read()) res.write(next.value)
|
||||
res.end()
|
||||
})
|
||||
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
return {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
close: () => new Promise((resolve) => (server.closeAllConnections(), server.close(() => resolve()))),
|
||||
}
|
||||
}
|
||||
@@ -1,256 +0,0 @@
|
||||
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 },
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,28 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { InMemoryKVDriver } from '../../src/kv/memory.js'
|
||||
import { runKVDriverConformance } from '../../src/testing/kv-conformance.js'
|
||||
import { CountingDriver, MinimalDriver } from '../helpers.js'
|
||||
|
||||
runKVDriverConformance('InMemoryKVDriver', () => new InMemoryKVDriver())
|
||||
runKVDriverConformance('BaseKVDriver (minimal)', () => new MinimalDriver())
|
||||
runKVDriverConformance('CountingDriver(InMemory)', () => new CountingDriver())
|
||||
|
||||
describe('InMemoryKVDriver', () => {
|
||||
it('keeps order after large bulk merges', async () => {
|
||||
const d = new InMemoryKVDriver()
|
||||
const keys = Array.from({ length: 5000 }, (_, i) => `k${(i * 7919) % 5000}`)
|
||||
await d.setMany(keys.slice(0, 2500).map((k) => [k, 1]))
|
||||
await d.setMany(keys.slice(2500).map((k) => [k, 2]))
|
||||
const seen: string[] = []
|
||||
for await (const [k] of d.scan({})) seen.push(k)
|
||||
expect(seen).toEqual([...new Set(keys)].sort())
|
||||
expect(d.size).toBe(5000)
|
||||
})
|
||||
|
||||
it('can skip cloning', async () => {
|
||||
const d = new InMemoryKVDriver({ clone: false })
|
||||
const v = { n: 1 }
|
||||
await d.setMany([['k', v]])
|
||||
expect((await d.getMany(['k']))[0]).toBe(v)
|
||||
})
|
||||
})
|
||||
@@ -1,354 +0,0 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { ChunkedSortedMap } from '../../src/kv/sorted.js'
|
||||
|
||||
const key = fc.string({ unit: fc.constantFrom('a', 'b', 'c', '/', '0'), minLength: 1, maxLength: 4 })
|
||||
type Op = { t: 'set'; k: string } | { t: 'del'; k: string } | { t: 'bulk'; ks: string[] }
|
||||
const op: fc.Arbitrary<Op> = fc.oneof(
|
||||
{ weight: 5, arbitrary: fc.record({ t: fc.constant('set' as const), k: key }) },
|
||||
{ weight: 3, arbitrary: fc.record({ t: fc.constant('del' as const), k: key }) },
|
||||
{ weight: 1, arbitrary: fc.record({ t: fc.constant('bulk' as const), ks: fc.array(key, { minLength: 60, maxLength: 120 }) }) },
|
||||
)
|
||||
|
||||
describe('ChunkedSortedMap (model-based, tiny chunks)', () => {
|
||||
it('matches a sorted reference for ranges in both directions', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.array(op, { maxLength: 80 }), fc.integer({ min: 2, max: 6 }), key, key, (ops, chunk, a, b) => {
|
||||
const m = new ChunkedSortedMap<number>(chunk)
|
||||
const ref = new Map<string, number>()
|
||||
ops.forEach((o, i) => {
|
||||
if (o.t === 'set') (m.set(o.k, i), ref.set(o.k, i))
|
||||
else if (o.t === 'del') expect(m.delete(o.k)).toBe(ref.delete(o.k))
|
||||
else (m.setMany(o.ks.map((k) => [k, i])), o.ks.forEach((k) => ref.set(k, i)))
|
||||
})
|
||||
const sorted = [...ref.keys()].sort()
|
||||
const [gte, lt] = a < b ? [a, b] : [b, a]
|
||||
expect(m.size).toBe(ref.size)
|
||||
expect([...m.keys({})]).toEqual(sorted)
|
||||
expect([...m.keys({ reverse: true })]).toEqual([...sorted].reverse())
|
||||
expect([...m.keys({ gte, lt })]).toEqual(sorted.filter((k) => k >= gte && k < lt))
|
||||
expect([...m.keys({ gte, lt, reverse: true })]).toEqual(sorted.filter((k) => k >= gte && k < lt).reverse())
|
||||
for (const k of sorted) expect(m.get(k)).toBe(ref.get(k))
|
||||
}),
|
||||
{ numRuns: 500 },
|
||||
)
|
||||
})
|
||||
|
||||
it('iterators resume correctly after mutations between steps', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uniqueArray(key, { minLength: 1, maxLength: 60 }),
|
||||
fc.array(fc.tuple(fc.boolean(), key), { maxLength: 40 }),
|
||||
fc.boolean(),
|
||||
(initial, muts, reverse) => {
|
||||
const m = new ChunkedSortedMap<number>(3)
|
||||
initial.forEach((k) => m.set(k, 0))
|
||||
const live = new Set(initial)
|
||||
const seen: string[] = []
|
||||
let step = 0
|
||||
for (const k of m.keys({ reverse })) {
|
||||
seen.push(k)
|
||||
const mut = muts[step++]
|
||||
if (mut) {
|
||||
const [add, mk] = mut
|
||||
if (add) (m.set(mk, 1), live.add(mk))
|
||||
else (m.delete(mk), live.delete(mk))
|
||||
}
|
||||
}
|
||||
// Strictly monotone, and every key present throughout the iteration was visited.
|
||||
for (let i = 1; i < seen.length; i++) expect(reverse ? seen[i]! < seen[i - 1]! : seen[i]! > seen[i - 1]!).toBe(true)
|
||||
const stable = initial.filter((k) => !muts.some(([, mk]) => mk === k))
|
||||
for (const k of stable) expect(seen).toContain(k)
|
||||
},
|
||||
),
|
||||
{ numRuns: 500 },
|
||||
)
|
||||
})
|
||||
})
|
||||
+39
-73
@@ -1,123 +1,89 @@
|
||||
import { describe, expectTypeOf, it } from 'vitest'
|
||||
import { KVStorage, type KVNode } from '../src/kv/node.js'
|
||||
import { InMemoryKVDriver } from '../src/kv/memory.js'
|
||||
import { KVEventStorage } from '../src/events/kv.js'
|
||||
import type { StoredEvent } from '../src/events/types.js'
|
||||
import { EventSourcedDocumentStorage } from '../src/documents/event-sourced.js'
|
||||
import type { FindResult } from '../src/documents/types.js'
|
||||
import type { AnySpec, Fields, Many } from '../src/types/schema.js'
|
||||
import type { BaseKVStorage } from '../src/kv-storage/base-kv-storage.js'
|
||||
import { KVStorageInMemory } from '../src/kv-storage/kv-storage-in-memory.js'
|
||||
import { EventStorageKV } from '../src/event-storage/event-storage-kv.js'
|
||||
import type { StoredEvent } from '../src/event-storage/base-event-storage.js'
|
||||
import { DocumentStorageInMemory } from '../src/document-storage/document-storage-in-memory.js'
|
||||
import { EventSourcedDocumentStorage } from '../src/document-storage/event-sourced-document-storage.js'
|
||||
import type { DocChange } from '../src/utils/document-update.js'
|
||||
import type { FindResult } from '../src/utils/document-query.js'
|
||||
import type { Fields, Many } from '../src/utils/schema.js'
|
||||
|
||||
type User = { username: string; age: number }
|
||||
type Post = { title: string; createdAt: string }
|
||||
type Schema = {
|
||||
children: {
|
||||
users: Many<User, { posts: Many<Post> }>
|
||||
profile: { children: { [id: string]: { children: Fields<User> } } } // field-per-key style
|
||||
profile: { children: { [id: string]: { children: Fields<User> } } }
|
||||
}
|
||||
}
|
||||
|
||||
const driver = new InMemoryKVDriver()
|
||||
describe('KV types', () => {
|
||||
const db = new KVStorageInMemory<Schema>()
|
||||
|
||||
describe('KV types (section 7)', () => {
|
||||
const db = new KVStorage<Schema>(driver)
|
||||
|
||||
it('reads child values at dynamic levels', () => {
|
||||
it('types reads at dynamic and named levels', () => {
|
||||
expectTypeOf(db.child('users').get('123')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(db.child('users').child('123').get()).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(db.child('users').getMany(['1', '2'])).toEqualTypeOf<Promise<(User | undefined)[]>>()
|
||||
})
|
||||
|
||||
it('navigates named levels', () => {
|
||||
expectTypeOf(db.child('users').child('123').child('posts')).toEqualTypeOf<KVNode<Many<Post>>>()
|
||||
// @ts-expect-error unknown named child
|
||||
db.child('users').child('123').child('postz')
|
||||
// @ts-expect-error unknown top-level child
|
||||
db.child('userz')
|
||||
})
|
||||
|
||||
it('reads field-per-key values', () => {
|
||||
expectTypeOf(db.child('profile').child('9').get('age')).toEqualTypeOf<Promise<number | undefined>>()
|
||||
expectTypeOf(db.child('profile').child('9').get('username')).toEqualTypeOf<Promise<string | undefined>>()
|
||||
expectTypeOf(db.child('users').child('123').child('posts')).toEqualTypeOf<BaseKVStorage<Many<Post>>>()
|
||||
// @ts-expect-error unknown child
|
||||
db.child('users').child('123').child('postz')
|
||||
// @ts-expect-error unknown field
|
||||
db.child('profile').child('9').get('agee')
|
||||
})
|
||||
|
||||
it('checks written values', () => {
|
||||
it('types writes', () => {
|
||||
db.child('users').set('1', { username: 'a', age: 1 })
|
||||
db.child('users').child('1').set({ username: 'a', age: 1 })
|
||||
// @ts-expect-error wrong value type
|
||||
// @ts-expect-error missing field
|
||||
db.child('users').set('1', { username: 'a' })
|
||||
db.child('profile')
|
||||
.child('9')
|
||||
.setMany([
|
||||
['age', 3],
|
||||
['username', 'x'],
|
||||
])
|
||||
db.child('profile')
|
||||
.child('9')
|
||||
.setMany([
|
||||
// @ts-expect-error value doesn't match its key
|
||||
['age', 'x'],
|
||||
['username', 3],
|
||||
])
|
||||
})
|
||||
|
||||
it('types listings', () => {
|
||||
expectTypeOf(db.child('users').keys()).toEqualTypeOf<AsyncGenerator<string>>()
|
||||
expectTypeOf(db.child('profile').child('9').keys()).toEqualTypeOf<AsyncGenerator<'username' | 'age'>>()
|
||||
expectTypeOf(db.child('users').entries()).toEqualTypeOf<AsyncGenerator<[string, User]>>()
|
||||
})
|
||||
|
||||
it('defaults to untyped and supports as<S>()', () => {
|
||||
const raw = new KVStorage(driver)
|
||||
expectTypeOf(raw.child('anything').child(5).get('x')).toEqualTypeOf<Promise<unknown>>()
|
||||
const users = raw.child('users').as<Many<User>>()
|
||||
expectTypeOf(users.get('1')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(raw).toMatchTypeOf<KVNode<AnySpec>>()
|
||||
it('is untyped by default', () => {
|
||||
expectTypeOf(new KVStorageInMemory().child('a').child(1).get('b')).toEqualTypeOf<Promise<unknown>>()
|
||||
})
|
||||
})
|
||||
|
||||
type OrderEvent = { type: 'created'; total: number } | { type: 'paid' }
|
||||
type EventSchema = { children: { orders: Many<OrderEvent, { shipments: Many<{ carrier: string }> }> } }
|
||||
describe('event and document types', () => {
|
||||
type OrderEvent = { type: 'created'; total: number } | { type: 'paid' }
|
||||
const orders = new EventStorageKV<{ children: { orders: Many<OrderEvent> } }>(new KVStorageInMemory()).child('orders')
|
||||
const users = new DocumentStorageInMemory<{ children: { users: Many<User, { posts: Many<Post> }> } }>().child('users')
|
||||
|
||||
describe('event types', () => {
|
||||
const events = new KVEventStorage<EventSchema>(new KVStorage(driver))
|
||||
const orders = events.child('orders')
|
||||
|
||||
it('checks appended events', () => {
|
||||
orders.append('o1', [{ type: 'created', total: 40 }])
|
||||
it('types events', () => {
|
||||
orders.append('o1', [{ type: 'created', total: 1 }])
|
||||
orders.child('o1').append([{ type: 'paid' }])
|
||||
// @ts-expect-error not an OrderEvent
|
||||
orders.append('o1', [{ type: 'refunded' }])
|
||||
orders
|
||||
.child('o1')
|
||||
.child('shipments')
|
||||
.append('s1', [{ carrier: 'x' }])
|
||||
expectTypeOf(orders.read('o1')).toEqualTypeOf<AsyncGenerator<StoredEvent<OrderEvent>>>()
|
||||
})
|
||||
|
||||
it('types read results', () => {
|
||||
expectTypeOf(orders.read('o1')).toEqualTypeOf<AsyncIterable<StoredEvent<OrderEvent>>>()
|
||||
expectTypeOf(orders.version('o1')).toEqualTypeOf<Promise<number>>()
|
||||
})
|
||||
})
|
||||
|
||||
type DocSchema = { children: { users: Many<User, { posts: Many<Post> }> } }
|
||||
|
||||
describe('document types', () => {
|
||||
const docs = new EventSourcedDocumentStorage<DocSchema>(new KVEventStorage(new KVStorage(driver)))
|
||||
const users = docs.child('users')
|
||||
|
||||
it('types reads and writes', () => {
|
||||
it('types documents', () => {
|
||||
expectTypeOf(users.get('1')).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.child('1').get()).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.insert({ username: 'a', age: 1 })).toEqualTypeOf<Promise<string>>()
|
||||
// @ts-expect-error missing field
|
||||
users.insert({ username: 'a' })
|
||||
expectTypeOf(users.update('1', { $inc: { age: 1 } })).toEqualTypeOf<Promise<User | undefined>>()
|
||||
expectTypeOf(users.child('1').child('posts').get('p')).toEqualTypeOf<Promise<Post | undefined>>()
|
||||
expectTypeOf(users.find()).toEqualTypeOf<AsyncIterable<FindResult<User>>>()
|
||||
// @ts-expect-error missing field
|
||||
users.insert({ username: 'a' })
|
||||
})
|
||||
|
||||
it('types queries', () => {
|
||||
expectTypeOf(users.find({ age: { $gte: 18 } })).toEqualTypeOf<AsyncGenerator<FindResult<User>>>()
|
||||
expectTypeOf(users.findOne()).toEqualTypeOf<Promise<FindResult<User> | undefined>>()
|
||||
it('keeps event-sourced methods on children', () => {
|
||||
const docs = new EventSourcedDocumentStorage<{ children: { users: Many<User> } }>(
|
||||
new EventStorageKV(new KVStorageInMemory()),
|
||||
new DocumentStorageInMemory(),
|
||||
)
|
||||
expectTypeOf(docs.child('users').history('1')).toEqualTypeOf<AsyncIterable<StoredEvent<DocChange>>>()
|
||||
expectTypeOf(docs.child('users').getAt('1', 3)).toEqualTypeOf<Promise<User | undefined>>()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { Keys } from '../../src/utils/keys.js'
|
||||
import { SortedMap } from '../../src/utils/sorted-map.js'
|
||||
import { Ulid } from '../../src/utils/ulid.js'
|
||||
import { DocumentQuery } from '../../src/utils/document-query.js'
|
||||
import { DocumentUpdate } from '../../src/utils/document-update.js'
|
||||
|
||||
describe('Keys', () => {
|
||||
it('round-trips any segment and never emits a character at or below the separator', () => {
|
||||
fc.assert(
|
||||
fc.property(fc.string({ unit: 'binary', minLength: 1 }), (s) => {
|
||||
const escaped = Keys.escape(s)
|
||||
expect(Keys.unescape(escaped)).toBe(s)
|
||||
expect([...escaped].every((c) => c > '/')).toBe(true)
|
||||
expect(Keys.isReserved(escaped)).toBe(false)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps every child subtree contiguous in key order', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.uniqueArray(fc.string({ unit: fc.constantFrom('a', '-', '.', '/', '~', ' ', '0'), minLength: 1, maxLength: 3 }), { minLength: 2 }),
|
||||
(segments) => {
|
||||
const keys = segments.flatMap((s) => [Keys.escape(s), Keys.escape(s) + '/x']).sort()
|
||||
const order = keys.map((k) => k.split('/')[0])
|
||||
// Each segment's keys appear as one consecutive run.
|
||||
expect(new Set(order).size).toBe(order.filter((s, i) => s !== order[i - 1]).length)
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('SortedMap', () => {
|
||||
it('matches a sorted reference, including iteration across mutations', () => {
|
||||
fc.assert(
|
||||
fc.property(
|
||||
fc.array(fc.tuple(fc.boolean(), fc.string({ unit: fc.constantFrom('a', 'b', 'c'), maxLength: 4 })), { maxLength: 200 }),
|
||||
fc.boolean(),
|
||||
(ops, reverse) => {
|
||||
const map = new SortedMap<number>(4)
|
||||
const ref = new Set<string>()
|
||||
for (const [add, key] of ops) add ? (map.set(key, 1), ref.add(key)) : (map.delete(key), ref.delete(key))
|
||||
const sorted = [...ref].sort()
|
||||
expect([...map.keys(undefined, undefined, reverse)]).toEqual(reverse ? sorted.reverse() : sorted)
|
||||
// Deleting each key as we pass it must not skip the rest.
|
||||
const seen = [...map.keys()].length
|
||||
let visited = 0
|
||||
for (const key of map.keys()) (visited++, map.delete(key))
|
||||
expect(visited).toBe(seen)
|
||||
},
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Ulid', () => {
|
||||
it('is strictly increasing within a millisecond and sorts by time', () => {
|
||||
let t = 1_700_000_000_000
|
||||
const ulid = new Ulid(() => t)
|
||||
const ids = Array.from({ length: 500 }, () => ulid.generate())
|
||||
expect([...ids].sort()).toEqual(ids)
|
||||
t++
|
||||
expect(ulid.generate() > ids.at(-1)!).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocumentQuery', () => {
|
||||
const q = (filter: object, doc: object) => new DocumentQuery(filter as never).matches(doc as never)
|
||||
it('matches equality, comparisons, $in, dot paths, arrays and null-as-missing', () => {
|
||||
expect(q({ a: 1 }, { a: 1 })).toBe(true)
|
||||
expect(q({ 'a.b': 'x' }, { a: { b: 'x' } })).toBe(true)
|
||||
expect(q({ n: { $gte: 2, $lt: 5 } }, { n: 4 })).toBe(true)
|
||||
expect(q({ n: { $gt: 2 } }, { n: '9' })).toBe(false)
|
||||
expect(q({ n: { $in: [1, 2] } }, { n: 2 })).toBe(true)
|
||||
expect(q({ tags: 'x' }, { tags: ['y', 'x'] })).toBe(true)
|
||||
expect(q({ tags: ['x'] }, { tags: ['x'] })).toBe(true)
|
||||
expect(q({ a: null }, {})).toBe(true)
|
||||
expect(() => q({ a: { $regex: 'x' } }, {})).toThrow(/Unsupported/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DocumentUpdate', () => {
|
||||
it('resolves operators to final values', () => {
|
||||
const state = { n: 5, tags: ['a', 'b'], nested: { x: 1 } }
|
||||
const change = DocumentUpdate.resolve(state, { $inc: { n: 1 }, $push: { tags: { $each: ['c'] } }, $unset: { 'nested.x': 1 }, $set: { 'nested.y': 2 } })
|
||||
expect(change).toEqual({ op: 'patch', set: { n: 6, tags: ['a', 'b', 'c'], 'nested.y': 2 }, unset: ['nested.x'] })
|
||||
expect(DocumentUpdate.apply(state, change)).toEqual({ n: 6, tags: ['a', 'b', 'c'], nested: { y: 2 } })
|
||||
expect(DocumentUpdate.apply(state, DocumentUpdate.resolve(state, { $pull: { tags: 'a' }, $addToSet: { more: { $each: [1, 1] } } }))).toMatchObject({
|
||||
tags: ['b'],
|
||||
more: [1],
|
||||
})
|
||||
expect(() => DocumentUpdate.resolve(state, { $set: { n: 1 }, $inc: { n: 1 } })).toThrow(/twice/)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user