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
- 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
193 lines
8.3 KiB
TypeScript
193 lines
8.3 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import type { BaseKVStorage, ScanOptions } from '../../src/kv-storage/base-kv-storage.js'
|
|
import { InvalidKeyError } from '../../src/utils/errors.js'
|
|
import { collect } from '../helpers.js'
|
|
|
|
/** Behaviour every KV storage must have. `make` returns an empty storage. */
|
|
export function runKVStorageConformance(name: string, make: () => BaseKVStorage | Promise<BaseKVStorage>): void {
|
|
describe(`KV storage conformance: ${name}`, () => {
|
|
let db: BaseKVStorage
|
|
beforeEach(async () => {
|
|
db = await make()
|
|
})
|
|
// Stores with open connections (e.g. a subscribed composite) release them; browsers allow
|
|
// only 6 HTTP/1.1 connections per host.
|
|
afterEach(async () => {
|
|
await (db as Partial<{ close(): Promise<void> }>).close?.()
|
|
})
|
|
const scanKeys = async (options: ScanOptions) => (await collect(db.scan(options))).map(([k]) => k)
|
|
|
|
describe('primitives', () => {
|
|
beforeEach(async () => {
|
|
await db.writeMany([
|
|
{ key: 'b', value: 2 },
|
|
{ key: 'a/2', value: { n: 'a2' } },
|
|
{ key: 'c', value: [3] },
|
|
{ key: 'a', value: 1 },
|
|
{ key: 'a/1', value: 'a1' },
|
|
{ key: 'a0', value: null },
|
|
{ key: '10', value: 10 },
|
|
{ key: '9', value: 9 },
|
|
])
|
|
})
|
|
|
|
it('reads in request order with undefined for missing keys and null preserved', async () => {
|
|
expect(await db.readMany(['c', 'missing', 'a/2', 'a0'])).toEqual([[3], undefined, { n: 'a2' }, null])
|
|
})
|
|
|
|
it('scans in key order with bounds, reverse, limit and optional values', async () => {
|
|
expect(await scanKeys({})).toEqual(['10', '9', 'a', 'a/1', 'a/2', 'a0', 'b', 'c'])
|
|
expect(await scanKeys({ gte: 'a/', lt: 'a0' })).toEqual(['a/1', 'a/2'])
|
|
expect(await scanKeys({ reverse: true, gte: 'a', lt: 'b' })).toEqual(['a0', 'a/2', 'a/1', 'a'])
|
|
expect(await scanKeys({ limit: 2, reverse: true })).toEqual(['c', 'b'])
|
|
expect(await scanKeys({ limit: 0 })).toEqual([])
|
|
expect(await collect(db.scan({ gte: 'a/', lt: 'a0', values: true }))).toEqual([
|
|
['a/1', 'a1'],
|
|
['a/2', { n: 'a2' }],
|
|
])
|
|
expect((await collect(db.scan({ gte: 'b' })))[0]).toEqual(['b', undefined])
|
|
})
|
|
|
|
it('removes keys', async () => {
|
|
await db.removeMany(['b', 'missing'])
|
|
expect(await db.readMany(['b', 'c'])).toEqual([undefined, [3]])
|
|
})
|
|
|
|
it('writes and removes in one batch', async () => {
|
|
await db.writeMany([{ key: 'b' }, { key: 'new', value: 'y' }, { key: 'a', value: 'x' }])
|
|
expect(await db.readMany(['a', 'b', 'new'])).toEqual(['x', undefined, 'y'])
|
|
})
|
|
|
|
it('keeps scanning correctly when keys change mid-scan', async () => {
|
|
// Keys removed mid-scan may or may not still appear (stores may read ahead), but every key
|
|
// present throughout must appear exactly once, in order.
|
|
const seen: string[] = []
|
|
for await (const [key] of db.scan({})) {
|
|
seen.push(key)
|
|
if (key === 'a') await db.removeMany(['a', 'a/1'])
|
|
}
|
|
expect(seen.filter((k) => k !== 'a/1')).toEqual(['10', '9', 'a', 'a/2', 'a0', 'b', 'c'])
|
|
})
|
|
|
|
it('emits change events for writes and removals', async () => {
|
|
const events: unknown[] = []
|
|
const off = db.on('change', (event) => events.push(event))
|
|
await db.writeMany([{ key: 'x', value: 1 }])
|
|
await db.removeMany(['x'])
|
|
await db.writeMany([])
|
|
off()
|
|
expect(events).toEqual([
|
|
{ changes: [{ key: 'x', value: 1 }], origin: 'local' },
|
|
{ changes: [{ key: 'x' }], origin: 'local' },
|
|
])
|
|
})
|
|
|
|
it('stores bigint and Uint8Array values', async () => {
|
|
await db.writeMany([{ key: 'bin', value: { n: 10n ** 20n, bytes: new Uint8Array([0, 255]) } }])
|
|
expect(await db.readMany(['bin'])).toEqual([{ n: 10n ** 20n, bytes: new Uint8Array([0, 255]) }])
|
|
})
|
|
|
|
it('does not share references with callers', async () => {
|
|
const [value] = (await db.readMany(['a/2'])) as [{ n: string }]
|
|
value.n = 'changed'
|
|
expect(await db.readMany(['a/2'])).toEqual([{ n: 'a2' }])
|
|
})
|
|
})
|
|
|
|
describe('nodes', () => {
|
|
it('reads and writes own and child values at the same key', async () => {
|
|
const users = db.child('users')
|
|
await users.set('123', { name: 'Ada' })
|
|
expect(await users.child('123').get()).toEqual({ name: 'Ada' })
|
|
await users.child('123').set({ name: 'Bob' })
|
|
expect(await users.get('123')).toEqual({ name: 'Bob' })
|
|
expect(await db.readMany(['users/123'])).toEqual([{ name: 'Bob' }])
|
|
await db.child('n').set(5, 'five')
|
|
expect(await db.child('n').get('5')).toBe('five')
|
|
})
|
|
|
|
it('batch reads, writes and deletes children', async () => {
|
|
const n = db.child('n')
|
|
await n.setMany([
|
|
['a', 1],
|
|
['b', 2],
|
|
])
|
|
expect(await n.getMany(['b', 'x', 'a'])).toEqual([2, undefined, 1])
|
|
await n.deleteMany(['a'])
|
|
expect(await n.getMany(['a', 'b'])).toEqual([undefined, 2])
|
|
})
|
|
|
|
it('sets and gets direct children as fields', async () => {
|
|
const user = db.child('users').child('1')
|
|
await user.setFields({ name: 'Ada', hobby: 'chess', skipped: undefined })
|
|
await user.child('address').set('city', 'Paris')
|
|
expect(await user.get('hobby')).toBe('chess')
|
|
expect(await user.getFields()).toEqual({ hobby: 'chess', name: 'Ada' })
|
|
})
|
|
|
|
it('keeps own values and children independent', async () => {
|
|
const user = db.child('users').child('1')
|
|
await user.set('own')
|
|
await user.set('name', 'Ada')
|
|
await user.delete()
|
|
expect(await user.get()).toBeUndefined()
|
|
expect(await user.get('name')).toBe('Ada')
|
|
})
|
|
|
|
it('rejects passed-but-undefined keys, root values and undefined values', async () => {
|
|
const users = db.child('users')
|
|
const id: string | undefined = undefined
|
|
await expect(users.get(id as never)).rejects.toThrow(InvalidKeyError)
|
|
await expect(users.delete(null as never)).rejects.toThrow(TypeError)
|
|
await expect(users.set(id as never, 1 as never)).rejects.toThrow(TypeError)
|
|
await expect(users.set('x', undefined)).rejects.toThrow(/undefined/)
|
|
await expect(db.get()).rejects.toThrow(/root/)
|
|
expect(() => db.child('')).toThrow(InvalidKeyError)
|
|
})
|
|
|
|
it('escapes keys so special characters round-trip', async () => {
|
|
const n = db.child('a/b')
|
|
for (const key of ['x/y', '50%', '~e', 'a-1', 'a.b', 'a b', 'é']) await n.set(key, key)
|
|
for (const key of ['x/y', '50%', '~e', 'a-1', 'a.b', 'a b', 'é']) expect(await n.get(key)).toBe(key)
|
|
expect((await collect(n.keys())).sort()).toEqual(['50%', 'a b', 'a-1', 'a.b', 'x/y', '~e', 'é'].sort())
|
|
})
|
|
|
|
it('lists direct children (including ones with only descendants), skipping reserved keys', async () => {
|
|
const n = db.child('n')
|
|
await n.set('a', 1)
|
|
await n.child('b').set('deep', 1)
|
|
await n.set('c', 3)
|
|
await n.child('c').child('x').set('y', 1)
|
|
await n.set('a-1', 4)
|
|
await db.writeMany([{ key: 'n/~e/000000000001', value: {} }])
|
|
expect(await collect(n.keys())).toEqual(['a', 'a-1', 'b', 'c'])
|
|
expect(await collect(n.keys({ reverse: true }))).toEqual(['c', 'b', 'a-1', 'a'])
|
|
expect(await collect(n.keys({ after: 'a', limit: 2 }))).toEqual(['a-1', 'b'])
|
|
expect(await collect(n.keys({ after: 'c', reverse: true }))).toEqual(['b', 'a-1', 'a'])
|
|
expect(await collect(n.entries())).toEqual([
|
|
['a', 1],
|
|
['a-1', 4],
|
|
['c', 3],
|
|
])
|
|
expect(await collect(n.entries({ reverse: true, limit: 2 }))).toEqual([
|
|
['c', 3],
|
|
['a-1', 4],
|
|
])
|
|
expect(await collect(db.keys())).toEqual(['n'])
|
|
})
|
|
|
|
it('deleteTree removes a subtree but not lookalike siblings', async () => {
|
|
const n = db.child('n')
|
|
await n.set('a', 1)
|
|
await n.child('a').child('b').set('c', 3)
|
|
await n.set('a!', 2)
|
|
await n.set('a0', 3)
|
|
await n.deleteTree('a')
|
|
expect(await collect(n.keys())).toEqual(['a0', 'a!']) // 'a!' is stored as 'a~21'
|
|
await db.deleteTree()
|
|
expect(await collect(db.scan())).toEqual([])
|
|
})
|
|
})
|
|
})
|
|
}
|