Plain operations: last write wins, hash-keyed events, simpler composite
CI / Node 22 (pull_request) Successful in 12s
CI / Node 26 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Successful in 14s

- KV: one write primitive, write(changes) with { key, value? }; writeIfAbsent removed.
  The server executes exactly what clients send and never reads values, so they can be
  encrypted end to end. Adds setFields/getFields on the base class.
- HTTP: no server history or event ids; SSE relays every change (own writes included) and
  the client emits resync when a dropped stream reconnects.
- Composite: writes apply locally, then go to the source (undone locally if refused);
  pushed changes are applied and announced only where they differ from local values;
  offline writes go to a queue store (any KV storage); sync() = replay, diff, replay.
  Removes KVReplicaSet, KVOutbox, KeyCounter, SerialQueue and RetryTimer.
- Events: id = <time>_<sha256(time + canonical data)>, so concurrent appends never
  collide and retries can't duplicate; no versions or ConcurrencyError.
- Event-sourced documents: read, resolve, append, apply; catchUp folds in foreign events;
  getAt takes an event id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
Claude
2026-10-23 13:33:07 +00:00
parent 1f82ec9110
commit 631c75428b
34 changed files with 814 additions and 1387 deletions
+1 -55
View File
@@ -1,9 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } 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', () => {
@@ -36,54 +33,3 @@ describe('KeyRange', () => {
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()
})
})