Files
Claude 68d1a1c8e9
CI / Node 24 (push) Successful in 15s
CI / Node 26 (push) Successful in 16s
CI / Node 22 (pull_request) Successful in 13s
CI / Node 24 (pull_request) Failing after 3s
CI / Node 26 (pull_request) Failing after 4s
CI / Chromium (push) Failing after 1m8s
CI / Publish to staging registry (push) Skipped
CI / Chromium (pull_request) Failing after 1m3s
CI / Publish to staging registry (pull_request) Skipped
CI / Node 22 (push) Failing after 4s
Add encryption, Chromium tests and a README
- KVStorageEncrypted: AES-GCM per value (fresh IV, storage key as AAD), keys stay
  readable; decrypts and re-emits pushed changes and passes subscriptions through, so it
  can be a composite source. generateKey / exportKey / importKey / deriveKey (PBKDF2).
- KVStorageHttpServer: allowOrigins (CORS), including cache-control for SSE preflights.
- Chromium tests (Vitest browser mode + Playwright) against a real cross-origin server:
  conformance for IndexedDB, encrypted, HTTP and composite stores; signed requests and
  SSE; two devices syncing, offline writes surviving a reload. New CI job.
- Composite fixes found by those tests: sync() during a running sync schedules one more
  (changes between the first scan and the stream connecting were missed); a queued write
  the source refuses is dropped, restored and reported (onError) instead of blocking the
  queue forever.
- README with examples, mirrored in test/readme.test.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qxbkp4tA7G5SCWjzWuxmE
2026-10-23 14:25:22 +00:00

138 lines
6.1 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import {
DocumentStorageKV,
Ed25519Signer,
Ed25519Verifier,
EventStorageKV,
KVStorageComposite,
KVStorageEncrypted,
KVStorageHttp,
KVStorageHttpServer,
KVStorageInMemory,
type Fields,
type Many,
} from '../src/index.js'
import { collect } from './helpers.js'
// The README's examples, run as written (imports from the package entry point), so they stay correct.
const closers: (() => Promise<void>)[] = []
afterEach(async () => {
for (const close of closers.splice(0)) await close()
})
describe('README', () => {
it('KV storage', async () => {
const db = new KVStorageInMemory()
const users = db.child('users')
await users.set('1', { name: 'Ada' })
expect(await users.child('1').get()).toEqual({ name: 'Ada' })
expect(await users.get('1')).toEqual({ name: 'Ada' })
await users.setMany([
['2', { name: 'Bob' }],
['3', { name: 'Cy' }],
])
expect(await users.getMany(['1', 'missing'])).toEqual([{ name: 'Ada' }, undefined])
expect(await collect(users.keys({ limit: 10 }))).toEqual(['1', '2', '3'])
expect((await collect(users.entries({ reverse: true }))).map(([key]) => key)).toEqual(['3', '2', '1'])
await users.delete('3')
await users.deleteTree()
expect(await collect(db.scan())).toEqual([])
const settings = db.child('settings')
await settings.setFields({ theme: 'dark', fontSize: 14 })
expect(await settings.get('theme')).toBe('dark')
expect(await settings.getFields()).toEqual({ fontSize: 14, theme: 'dark' })
const changes: unknown[] = []
db.on('change', ({ changes: c, origin }) => changes.push([origin, c]))
await users.set('1', { name: 'Ada' })
expect(changes).toEqual([['local', [{ key: 'users/1', value: { name: 'Ada' } }]]])
})
it('types', async () => {
type User = { name: string }
type Schema = { children: { users: Many<User>; settings: { children: Fields<{ theme: string; fontSize: number }> } } }
const typed = new KVStorageInMemory<Schema>()
await typed.child('users').set('1', { name: 'Ada' })
// @ts-expect-error not a User
await typed.child('users').set('2', { nom: 'x' })
await typed.child('settings').set('theme', 'dark')
const theme: string | undefined = await typed.child('settings').get('theme')
expect(theme).toBe('dark')
})
it('event storage', async () => {
const events = new EventStorageKV(new KVStorageInMemory())
const orders = events.child('orders')
const [id] = await orders.append('o1', [{ type: 'created', total: 30 }])
await orders.child('o1').append([{ type: 'paid' }])
expect((await collect(orders.read('o1'))).map((e) => e.data)).toEqual([{ type: 'created', total: 30 }, { type: 'paid' }])
expect((await collect(orders.read('o1', { after: id }))).map((e) => e.data)).toEqual([{ type: 'paid' }])
expect(await collect(orders.keys())).toEqual(['o1'])
})
it('document storage', async () => {
const docs = new DocumentStorageKV(new KVStorageInMemory())
const people = docs.child('people')
const id = await people.insert({ name: 'Ada', age: 36, tags: ['math'] })
await people.set('grace', { name: 'Grace', age: 45, tags: ['navy', 'cobol'] })
await people.update(id, { $inc: { age: 1 }, $push: { tags: 'engines' } })
expect((await collect(people.find({ age: { $gte: 37 } }, { sort: { age: -1 }, limit: 10 }))).map((r) => r.key)).toEqual(['grace', id])
expect((await people.findOne({ tags: 'cobol' }))?.key).toBe('grace')
expect(await people.get(id)).toEqual({ name: 'Ada', age: 37, tags: ['math', 'engines'] })
expect(await people.delete('grace')).toBe(true)
})
it('sync, offline and encryption (server and client)', async () => {
const server = new KVStorageHttpServer(new KVStorageInMemory(), {
auth: { verifiers: [new Ed25519Verifier()] },
basePath: '/kv',
allowOrigins: ['https://app.example.com'],
})
const fetch = (url: string, init: RequestInit) => server.handle(new Request(url, init))
const signer = await Ed25519Signer.generate()
const saved = await signer.exportPrivateKey()
expect((await Ed25519Signer.fromPrivateKey(saved)).publicKey).toBe(signer.publicKey)
const salt = crypto.getRandomValues(new Uint8Array(16))
const key = await KVStorageEncrypted.deriveKey('correct horse battery staple', salt, 1000)
const local = new KVStorageInMemory() // stands in for KVStorageIndexedDB
const db = new KVStorageComposite({
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
replicas: [new KVStorageInMemory(), local],
queue: local.child('~sync'),
prefix: signer.publicKey,
})
closers.push(() => db.close())
const me = db.child(signer.publicKey)
await me.child('notes').set('1', { text: 'Works offline' })
expect(await me.child('notes').get('1')).toEqual({ text: 'Works offline' })
type Todo = { title: string; done: boolean }
const todos = new DocumentStorageKV<{ children: { todos: Many<Todo> } }>(me).child('todos')
await todos.insert({ title: 'Ship it', done: false })
expect((await collect(todos.find({ done: false }))).map((r) => r.data.title)).toEqual(['Ship it'])
// Another device with the same keys sees the data; the server only has ciphertext.
const other = new KVStorageComposite({
source: new KVStorageEncrypted(new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch }), { key }),
replicas: [new KVStorageInMemory()],
prefix: signer.publicKey,
})
closers.push(() => other.close())
expect(await other.child(signer.publicKey).child('notes').get('1')).toEqual({ text: 'Works offline' })
const http = new KVStorageHttp({ url: 'https://api.example.com/kv', signer, fetch })
closers.push(() => http.close())
const [stored] = await http.readMany([`${signer.publicKey}/notes/1`])
expect(stored).toBeInstanceOf(Uint8Array)
const pushed = http.waitFor('change', () => true)
await http.child(signer.publicKey).child('notes').subscribe()
await me.child('notes').set('2', { text: 'live' })
expect((await pushed).changes[0]!.key).toBe(`${signer.publicKey}/notes/2`)
})
})