Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
201 lines
9.2 KiB
TypeScript
201 lines
9.2 KiB
TypeScript
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)
|
|
})
|
|
})
|