Add KVStorageComposite, IndexedDB storage and SSE change streams
- Every KV storage emits change events (base class owns writeMany/removeMany/ writeIfAbsent; implementations provide protected apply* methods) - KVStorageHttpServer streams changes over SSE (GET /events) with a resumable history; KVStorageHttp.watch() subscribes and resumes via Last-Event-ID - KVStorageIndexedDB for local persistence - KVStorageComposite keeps replicas of a source in sync: local reads, resume instead of full sync, offline outbox replayed in order, echo tracking - Values may contain bigint/Uint8Array (Extended JSON over the network) - Vendor sse-session, event-emitter and extended-json (no runtime deps) - Freeze child nodes so shadowed state on children fails loudly - CI on Node 22, 24 and 26 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { EventEmitter } from '../../src/utils/event-emitter.js'
|
||||
|
||||
type Events = { tick: number; done: void }
|
||||
|
||||
describe('EventEmitter', () => {
|
||||
it('delivers payloads as-is (no cloning or freezing) and supports off', () => {
|
||||
const emitter = new EventEmitter<Events & { obj: { n: number } }>()
|
||||
const seen: unknown[] = []
|
||||
const payload = { n: 1 }
|
||||
const off = emitter.on('obj', (p) => seen.push(p))
|
||||
expect(emitter.emit('obj', payload)).toBe(true)
|
||||
off()
|
||||
expect(emitter.emit('obj', payload)).toBe(false)
|
||||
expect(seen[0]).toBe(payload)
|
||||
expect(Object.isFrozen(payload)).toBe(false)
|
||||
})
|
||||
|
||||
it('once fires a single time; listener errors are logged, not thrown', () => {
|
||||
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const emitter = new EventEmitter<Events>()
|
||||
const once = vi.fn()
|
||||
emitter.once('tick', once)
|
||||
emitter.on('tick', () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
emitter.emit('tick', 1)
|
||||
emitter.emit('tick', 2)
|
||||
expect(once).toHaveBeenCalledTimes(1)
|
||||
expect(error).toHaveBeenCalledTimes(2)
|
||||
error.mockRestore()
|
||||
})
|
||||
|
||||
it('debounces and cancels pending debounced calls on off', async () => {
|
||||
vi.useFakeTimers()
|
||||
const emitter = new EventEmitter<Events>()
|
||||
const listener = vi.fn()
|
||||
const off = emitter.on('tick', listener, 50)
|
||||
emitter.emit('tick', 1)
|
||||
emitter.emit('tick', 2)
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(listener).toHaveBeenCalledExactlyOnceWith(2)
|
||||
emitter.emit('tick', 3)
|
||||
off()
|
||||
vi.advanceTimersByTime(60)
|
||||
expect(listener).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('waitFor resolves on a matching payload and rejects on timeout', async () => {
|
||||
const emitter = new EventEmitter<Events>()
|
||||
const waiting = emitter.waitFor('tick', (n) => n > 1)
|
||||
emitter.emit('tick', 1)
|
||||
emitter.emit('tick', 2)
|
||||
expect(await waiting).toBe(2)
|
||||
await expect(emitter.waitFor('done', () => true, 5)).rejects.toThrow(/Timed out waiting for "done"/)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import fc from 'fast-check'
|
||||
import { ExtendedJson } from '../../src/utils/extended-json.js'
|
||||
|
||||
describe('ExtendedJson', () => {
|
||||
it('round-trips bigint and Uint8Array, nested anywhere', () => {
|
||||
const value = { a: 1n, b: [new Uint8Array([0, 1, 255]), -(2n ** 70n)], c: { d: new Uint8Array(0), e: 'plain', f: null } }
|
||||
expect(ExtendedJson.stringify({ n: 5n, bytes: new Uint8Array([171, 205]) })).toBe('{"n":"<bigint: 5n>","bytes":"<uint8array: abcd>"}')
|
||||
expect(ExtendedJson.parse(ExtendedJson.stringify(value))).toEqual(value)
|
||||
})
|
||||
|
||||
it('round-trips arbitrary data (property)', () => {
|
||||
const data = fc.letrec((tie) => ({
|
||||
value: fc.oneof(
|
||||
{ depthSize: 'small' },
|
||||
fc.bigInt(),
|
||||
fc.uint8Array(),
|
||||
fc.string(),
|
||||
fc.double({ noNaN: true, noDefaultInfinity: true }),
|
||||
fc.boolean(),
|
||||
fc.constant(null),
|
||||
fc.array(tie('value')),
|
||||
fc.dictionary(fc.string(), tie('value')),
|
||||
),
|
||||
})).value
|
||||
fc.assert(
|
||||
fc.property(data, (v) => {
|
||||
expect(ExtendedJson.parse(ExtendedJson.stringify(v))).toEqual(v)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SSEEventParser } from '../../src/utils/sse-session/sse-event-parser.js'
|
||||
import { SSESession } from '../../src/utils/sse-session/sse-session.js'
|
||||
|
||||
const encode = (text: string) => new TextEncoder().encode(text)
|
||||
|
||||
describe('vendored sse-session', () => {
|
||||
it('parses events across arbitrary chunk boundaries', () => {
|
||||
const parser = new SSEEventParser()
|
||||
const text = 'id: 1\nevent: change\ndata: {"a":1}\n\n: comment\ndata: line1\ndata: line2\n\n'
|
||||
const events = []
|
||||
for (let i = 0; i < text.length; i += 3) events.push(...parser.parseEvents(encode(text.slice(i, i + 3))))
|
||||
expect(events).toEqual([{ id: '1', event: 'change', data: '{"a":1}' }, { data: 'line1\nline2' }])
|
||||
})
|
||||
|
||||
it('connects, delivers messages and reconnects when persistent', async () => {
|
||||
let connections = 0
|
||||
const session = new SSESession('http://test/events', {
|
||||
persistent: true,
|
||||
fetch: async () => {
|
||||
connections++
|
||||
return new Response(encode(`data: hello ${connections}\n\n`))
|
||||
},
|
||||
})
|
||||
const received: string[] = []
|
||||
session.on('message', (m) => received.push(m.data))
|
||||
await session.connect()
|
||||
await session.waitFor('message', (m) => m.data === 'hello 2')
|
||||
await session.disconnect()
|
||||
expect(received.slice(0, 2)).toEqual(['hello 1', 'hello 2'])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user