M8: HTTP driver (framework-agnostic handler + HttpKVDriver client) passing conformance end-to-end
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,92 @@
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
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()))),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user