feat(storage): storage hub with named backend registry and data-form mounts
ctx.storage is a pure registration hub: multiple named backends stay mounted side by side, data forms (domain first) mount via the merge-extensible StorageForms map. src/backend.ts is the normative KV-facet contract; tests/contract.ts is the shared conformance suite every backend runs. Backends expose data-shape facets (kv now, an append-log facet reserved for the future session-backend migration).
This commit is contained in:
102
packages/storage/storage/tests/contract.ts
Normal file
102
packages/storage/storage/tests/contract.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Shared KV-backend conformance suite. Each backend's spec file calls
|
||||
* {@link runKvBackendContract} with a factory bound to its own medium; the
|
||||
* suite asserts every clause of the `src/backend.ts` contract so both
|
||||
* backends are held to identical semantics.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KvUnitDescriptor, StorageBackend } from '../src/backend.ts'
|
||||
|
||||
/** One conformance run: a fresh backend plus a way to reopen the same medium (crash simulation). */
|
||||
export interface KvBackendContractHarness {
|
||||
/** The backend under test, freshly created over an empty medium. */
|
||||
backend: StorageBackend
|
||||
/** Open a NEW backend instance over the SAME medium, as after a process restart. */
|
||||
reopen(): Promise<StorageBackend>
|
||||
}
|
||||
|
||||
const DESCRIPTOR: KvUnitDescriptor = {
|
||||
name: 'contract_unit',
|
||||
version: 3,
|
||||
tables: ['alpha', 'beta'],
|
||||
hasGlobal: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the shared conformance suite against one backend implementation.
|
||||
* @param label - Suite label, e.g. `json` / `sqlite`.
|
||||
* @param create - Factory producing a fresh harness per test.
|
||||
*/
|
||||
export function runKvBackendContract(label: string, create: () => Promise<KvBackendContractHarness>) {
|
||||
describe(`kv backend contract: ${label}`, () => {
|
||||
it('opens a missing unit as empty and serves loadAll immediately', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables).toEqual({ alpha: {}, beta: {} })
|
||||
expect(snapshot.global).toBeNull()
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('round-trips records and global durably across reopen', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k1', { n: 1 })
|
||||
await unit.putRecord('alpha', 'k2', { n: 2 })
|
||||
await unit.putRecord('beta', 'weird key / with:stuff', { ok: true })
|
||||
await unit.setGlobal({ counter: 7 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit2.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({ k1: { n: 1 }, k2: { n: 2 } })
|
||||
expect(snapshot.tables['beta']).toEqual({ 'weird key / with:stuff': { ok: true } })
|
||||
expect(snapshot.global).toEqual({ counter: 7 })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('putRecord overwrites and deleteRecord is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 'old' })
|
||||
await unit.putRecord('alpha', 'k', { v: 'new' })
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'never-existed')
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({})
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a version mismatch on reopen without touching the data', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 1 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
await expect(reopened.kv!.open({ ...DESCRIPTOR, version: 4 })).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'version-mismatch',
|
||||
})
|
||||
// Original version still opens and still holds the data.
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
expect((await unit2.loadAll()).tables['alpha']).toEqual({ k: { v: 1 } })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('rejects operations after unit close, and close is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.close()
|
||||
await unit.close()
|
||||
await expect(unit.putRecord('alpha', 'k', {})).rejects.toMatchObject({ code: 'closed' })
|
||||
await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
|
||||
await backend.close()
|
||||
await backend.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
62
packages/storage/storage/tests/registry.spec.ts
Normal file
62
packages/storage/storage/tests/registry.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { BackendRegistry, Storage, apply } from '../src/index.ts'
|
||||
import type { StorageBackend } from '../src/index.ts'
|
||||
|
||||
const fakeBackend = (): StorageBackend => ({ close: async () => {} })
|
||||
|
||||
describe('BackendRegistry', () => {
|
||||
it('registers, resolves, and disposes names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
const backend = fakeBackend()
|
||||
const dispose = registry.register('json', backend)
|
||||
expect(registry.get('json')).toBe(backend)
|
||||
expect(registry.names()).toEqual(['json'])
|
||||
dispose()
|
||||
expect(registry.names()).toEqual([])
|
||||
expect(() => registry.get('json')).toThrowMatchingObject({ code: 'backend-not-found' })
|
||||
})
|
||||
|
||||
it('rejects duplicate names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
registry.register('json', fakeBackend())
|
||||
expect(() => registry.register('json', fakeBackend())).toThrowMatchingObject({ code: 'duplicate-backend' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage service', () => {
|
||||
it('mounts on the context and exposes registry plus form mounting', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin({ apply })
|
||||
expect(ctx.storage).toBeInstanceOf(Storage)
|
||||
|
||||
const facility = { marker: true }
|
||||
const dispose = ctx.storage.mount('domain' as never, facility as never)
|
||||
expect(ctx.storage.form('domain' as never)).toBe(facility)
|
||||
expect(() => ctx.storage.mount('domain' as never, facility as never)).toThrowMatchingObject({
|
||||
code: 'duplicate-mount',
|
||||
})
|
||||
dispose()
|
||||
expect(() => ctx.storage.form('domain' as never)).toThrowMatchingObject({ code: 'form-not-mounted' })
|
||||
})
|
||||
})
|
||||
|
||||
expect.extend({
|
||||
toThrowMatchingObject(received: () => unknown, expected: object) {
|
||||
try {
|
||||
received()
|
||||
} catch (error) {
|
||||
const pass = Object.entries(expected).every(
|
||||
(entry) => (error as Record<string, unknown>)[entry[0]] === entry[1],
|
||||
)
|
||||
return { pass, message: () => `expected thrown error to match ${JSON.stringify(expected)}, got ${String(error)}` }
|
||||
}
|
||||
return { pass: false, message: () => 'expected function to throw' }
|
||||
},
|
||||
})
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T> {
|
||||
toThrowMatchingObject(expected: object): T
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user