- KVReplicaSet: seed, apply, local scan, diff against the source, sync meta - KVOutbox: persisted offline queue with in-order replay (load() resumes it) - utils: KeyRange (also used for server authorization), KeyCounter, SerialQueue (also used by KVStorageHttp and the event-sourced store), RetryTimer (separate timers for sync and replay; previously one shared timer could cancel the other's retry) The composite now only orchestrates. Behaviour unchanged; new unit tests for the building blocks and the outbox. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
90 lines
3.4 KiB
TypeScript
90 lines
3.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import fc from 'fast-check'
|
|
import { KeyRange } from '../../src/utils/key-range.js'
|
|
import { KeyCounter } from '../../src/utils/key-counter.js'
|
|
import { SerialQueue } from '../../src/utils/serial-queue.js'
|
|
import { RetryTimer } from '../../src/utils/retry-timer.js'
|
|
import { Keys } from '../../src/utils/keys.js'
|
|
|
|
describe('KeyRange', () => {
|
|
it('contains a key and its subtree, not lookalikes', () => {
|
|
const range = new KeyRange('ab')
|
|
expect(['ab', 'ab/c', 'ab/c/d'].every((k) => range.contains(k))).toBe(true)
|
|
expect(['a', 'abc', 'ab0', 'ab~2D1', 'b'].some((k) => range.contains(k))).toBe(false)
|
|
expect(new KeyRange('').contains('anything')).toBe(true)
|
|
})
|
|
|
|
it('agrees with its scan bounds for escaped keys (property)', () => {
|
|
const path = fc.array(fc.string({ unit: fc.constantFrom('a', 'b', '-', '/', '~', '0', '.'), minLength: 1, maxLength: 3 }), { minLength: 1, maxLength: 3 })
|
|
fc.assert(
|
|
fc.property(path, path, (p, q) => {
|
|
const range = new KeyRange(Keys.join(p))
|
|
const key = Keys.join(q)
|
|
expect(range.contains(key)).toBe(key >= range.gte! && key < range.lt!)
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('clamps and checks scan ranges', () => {
|
|
const range = new KeyRange('ab')
|
|
expect(range.clamp({ limit: 5 })).toEqual({ limit: 5, gte: 'ab', lt: 'ab0' })
|
|
expect(range.clamp({ gte: 'ab/x', lt: 'zz' })).toEqual({ gte: 'ab/x', lt: 'ab0' })
|
|
expect(range.clamp({ gte: 'b' })).toBeUndefined()
|
|
expect(range.covers('ab/', 'ab0')).toBe(true)
|
|
expect(range.covers('a', 'ab0')).toBe(false)
|
|
expect(range.covers(undefined, 'ab0')).toBe(false)
|
|
expect(new KeyRange('').covers(undefined, undefined)).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('KeyCounter', () => {
|
|
it('counts per key and in total', () => {
|
|
const counter = new KeyCounter()
|
|
counter.add(['a', 'a', 'b'])
|
|
expect(counter.size).toBe(3)
|
|
expect(counter.take('a')).toBe(true)
|
|
expect(counter.has('a')).toBe(true)
|
|
expect(counter.take('a')).toBe(true)
|
|
expect(counter.has('a')).toBe(false)
|
|
expect(counter.take('a')).toBe(false)
|
|
expect(counter.size).toBe(1)
|
|
counter.clear()
|
|
expect(counter.size).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('SerialQueue', () => {
|
|
it('runs tasks in order per key, keys independently, and survives failures', async () => {
|
|
const queue = new SerialQueue()
|
|
const log: string[] = []
|
|
const task = (name: string, ms: number) => () => new Promise<void>((r) => setTimeout(() => (log.push(name), r()), ms))
|
|
const a1 = queue.run(task('a1', 20), 'a')
|
|
const failed = queue.run(() => Promise.reject(new Error('boom')), 'a')
|
|
const a2 = queue.run(task('a2', 1), 'a')
|
|
const b1 = queue.run(task('b1', 1), 'b')
|
|
await expect(failed).rejects.toThrow('boom')
|
|
await Promise.all([a1, a2, b1])
|
|
expect(log).toEqual(['b1', 'a1', 'a2'])
|
|
await queue.idle('a')
|
|
})
|
|
})
|
|
|
|
describe('RetryTimer', () => {
|
|
it('replaces a pending retry and stops cleanly', () => {
|
|
vi.useFakeTimers()
|
|
const timer = new RetryTimer({ baseDelay: 10, maxDelay: 10 })
|
|
const first = vi.fn()
|
|
const second = vi.fn()
|
|
timer.schedule(first)
|
|
timer.schedule(second)
|
|
vi.advanceTimersByTime(20)
|
|
expect(first).not.toHaveBeenCalled()
|
|
expect(second).toHaveBeenCalledOnce()
|
|
timer.schedule(first)
|
|
timer.stop()
|
|
vi.advanceTimersByTime(20)
|
|
expect(first).not.toHaveBeenCalled()
|
|
vi.useRealTimers()
|
|
})
|
|
})
|