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:
imccyu
2026-07-24 19:06:48 +08:00
parent f7b36bd36d
commit e90b0d51df
12 changed files with 564 additions and 0 deletions

View File

@@ -0,0 +1,18 @@
# @deepseek-ai/dsh-storage
Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
## Shape
- `ctx.storage.backend` — name → backend table. Multiple backends stay mounted side by side (`json`, `sqlite`); which backend serves a consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. `register()` returns the disposer; duplicate names and unknown lookups fail loud.
- `ctx.storage.mount(form, facility)` / `ctx.storage.form(form)` — data-form mounting. `StorageForms` is merge-extensible; the domain layer merges `domain` and is reached as `ctx.storage.domain`.
- A backend owns one medium (file-tree root, database file) and exposes optional data-shape **facets**`kv` today; an append-log facet is reserved for the future session-backend migration. `src/backend.ts` is the normative contract text; `tests/contract.ts` exports the shared conformance suite every backend runs.
## Packages in this group
| Package | Role |
| --- | --- |
| `dsh-storage` | The hub service + backend vocabulary + shared conformance suite |
| `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite |
| `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row |
| `dsh-domain` | Domain data form (`ctx.storage.domain`): typed schemas, write chain, change events |

View File

@@ -0,0 +1,37 @@
{
"name": "@deepseek-ai/dsh-storage",
"description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,104 @@
/**
* Backend-facing vocabulary of the storage hub: a backend owns one medium
* (a file-tree root, a database file) and exposes data-shape facets over it.
* This module is the normative contract text for backend implementers; the
* shared conformance suite in `tests/contract.ts` asserts every clause.
* @module @deepseek-ai/dsh-storage/src/backend
*/
/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */
export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/
/**
* One registered backend. A backend owns exactly one medium and shares its
* lifecycle across all facets; facets are optional members — a backend that
* cannot serve a shape simply omits it, and resolution fails loud instead.
*/
export interface StorageBackend {
/** Key-value data shape; absent when this backend cannot serve it. */
readonly kv?: KvFacet
/**
* Drain in-flight writes across all open units and release the medium.
* Idempotent; concurrent and repeated calls resolve once teardown finishes.
* @returns resolution after the medium is released.
*/
close(): Promise<void>
}
/** The key-value data shape: whole-unit snapshots plus per-record durable writes. */
export interface KvFacet {
/**
* Open one unit, creating it when the medium holds no trace of it yet
* (materialization may defer to the first write, but {@link KvUnit.loadAll}
* must immediately serve the empty shape). A version already stamped on the
* medium that differs from `descriptor.version` rejects with
* `version-mismatch`; a medium that cannot be parsed as this unit rejects
* with `malformed-medium`. Opening the same unit name twice without closing
* is a caller bug and rejects.
* @param descriptor - Static identity and shape of the unit to open.
* @returns the opened unit.
*/
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
}
/** Static identity and shape of one KV unit, projected from its owner's spec. */
export interface KvUnitDescriptor {
/** Unit name; must match {@link UNIT_NAME_RE}. Also the file-name / SQL-identifier segment. */
readonly name: string
/** Unit format version; a non-negative integer stamped on the medium at first materialization. */
readonly version: number
/** Table names; each must match {@link UNIT_NAME_RE}. */
readonly tables: readonly string[]
/** Whether this unit carries the global singleton slot. */
readonly hasGlobal: boolean
}
/**
* One opened unit. Values are opaque JSON to this layer: no schema, no
* events, no domain meaning. The unit does NOT serialize concurrent writes —
* write ordering is the caller's responsibility (the domain layer runs one
* write chain per unit); the unit only guarantees that each single call is
* atomic on the medium and durable once resolved (a crash after resolution
* followed by a re-open observes the write). Any call after {@link close}
* rejects with `closed`.
*/
export interface KvUnit {
/**
* Read the full current snapshot.
* @returns every table's records keyed by table name, plus the global
* singleton (`null` when never written or not declared).
*/
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }>
/**
* Upsert one record durably. Overwrite semantics: an existing key is replaced.
* @param table - Declared table name.
* @param key - Record key; any string is safe (keys never reach file paths).
* @param value - Opaque JSON-serializable record.
* @returns resolution after durability.
*/
putRecord(table: string, key: string, value: unknown): Promise<void>
/**
* Delete one record durably. Idempotent: a missing key is a no-op.
* @param table - Declared table name.
* @param key - Record key.
* @returns resolution after durability.
*/
deleteRecord(table: string, key: string): Promise<void>
/**
* Write the global singleton durably. Only valid when the descriptor
* declared `hasGlobal`.
* @param value - Opaque JSON-serializable value.
* @returns resolution after durability.
*/
setGlobal(value: unknown): Promise<void>
/**
* Drain this unit's in-flight writes and release it. Idempotent.
* @returns resolution after the unit is released.
*/
close(): Promise<void>
}

View File

@@ -0,0 +1,35 @@
/**
* Error vocabulary for the storage hub and its backends.
* @module @deepseek-ai/dsh-storage/src/error
*/
/** Discriminant codes carried by every {@link StorageError}. */
export type StorageErrorCode =
| 'backend-not-found'
| 'form-not-mounted'
| 'duplicate-backend'
| 'duplicate-mount'
| 'version-mismatch'
| 'malformed-medium'
| 'closed'
/**
* Error thrown by the hub and by backend implementations. The `code` is the
* stable contract consumers may switch on; `message` is diagnostic prose.
*/
export class StorageError extends Error {
override readonly name = 'StorageError'
/**
* @param code - Stable discriminant for the failure class.
* @param message - Human-readable diagnostic detail.
* @param options - Standard error options (`cause`).
*/
constructor(
readonly code: StorageErrorCode,
message: string,
options?: ErrorOptions,
) {
super(message, options)
}
}

View File

@@ -0,0 +1,86 @@
/**
* Storage hub (`ctx.storage`): a named backend registry plus mounted
* data-form facilities. The hub itself performs no IO — backends own media,
* data forms (the domain layer first) own semantics.
* @module @deepseek-ai/dsh-storage
*/
import { Context, Service } from 'cordis'
import { StorageError } from './error.ts'
import { BackendRegistry } from './registry.ts'
export { BackendRegistry } from './registry.ts'
export { StorageError } from './error.ts'
export type { StorageErrorCode } from './error.ts'
export { UNIT_NAME_RE } from './backend.ts'
export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts'
declare module 'cordis' {
interface Context {
storage: Storage
}
}
/**
* Data forms mountable on the hub, keyed by form name. Form owners extend
* this map via declaration merging (the domain layer merges
* `domain: DomainFacility`) and mount the facility in their `apply`.
*/
export interface StorageForms {}
/**
* The storage hub service. Backends register under `backend`; data forms
* mount under their `StorageForms` key and are reached as `ctx.storage.<form>`.
*/
export class Storage extends Service {
/** Named backend table; multiple backends stay mounted side by side. */
readonly backend = new BackendRegistry()
private readonly forms = new Map<keyof StorageForms, unknown>()
constructor(ctx: Context) {
super(ctx, 'storage')
}
/**
* Mount a data-form facility on the hub. Mounting is an effect: the
* returned disposer unmounts the form.
* @param form - Form key declared in {@link StorageForms}.
* @param facility - The facility instance to expose.
* @returns the disposer that unmounts the form.
*/
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void {
if (this.forms.has(form)) {
throw new StorageError('duplicate-mount', `storage form '${String(form)}' is already mounted`)
}
this.forms.set(form, facility)
return () => {
this.forms.delete(form)
}
}
/**
* Resolve a mounted data form.
* @param form - Form key declared in {@link StorageForms}.
* @returns the mounted facility.
*/
form<K extends keyof StorageForms>(form: K): StorageForms[K] {
if (!this.forms.has(form)) {
throw new StorageError('form-not-mounted', `storage form '${String(form)}' is not mounted`)
}
return this.forms.get(form) as StorageForms[K]
}
/** Domain data form; present once the domain layer plugin is loaded. */
get domain(): StorageForms extends { domain: infer D } ? D : never {
return this.form('domain' as keyof StorageForms) as StorageForms extends { domain: infer D } ? D : never
}
}
/**
* Mount the storage hub service.
* @param ctx - Plugin context.
*/
export function apply(ctx: Context) {
ctx.plugin(Storage)
}

View File

@@ -0,0 +1,32 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-storage`.
* @module @deepseek-ai/dsh-storage/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-storage'
/** Cordis companion plugin name. */
export const name = 'storage-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: the hub is a pure registration table (names →
* backends, forms → facilities) whose consistency is fully enforced at the
* call sites (duplicate/missing entries fail loud synchronously); it owns no
* event stream or mutable medium to cross-check.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,58 @@
/**
* Named backend registry of the storage hub.
* @module @deepseek-ai/dsh-storage/src/registry
*/
import type { StorageBackend } from './backend.ts'
import { StorageError } from './error.ts'
/**
* Mutable name → backend table. Multiple backends stay mounted side by side;
* which backend serves which consumer is the consumer's configuration
* (e.g. the domain layer's route table), never a hub-global choice.
*/
export class BackendRegistry {
private readonly backends = new Map<string, StorageBackend>()
/**
* Register a named backend. Registration is an effect: the returned
* disposer removes the name. Disposal does NOT close the backend — the
* owning plugin closes it after unregistering.
* @param name - Backend name, e.g. `json` or `sqlite`.
* @param backend - The backend instance.
* @returns the disposer that unregisters the name.
*/
register(name: string, backend: StorageBackend): () => void {
if (this.backends.has(name)) {
throw new StorageError('duplicate-backend', `storage backend '${name}' is already registered`)
}
this.backends.set(name, backend)
return () => {
this.backends.delete(name)
}
}
/**
* Resolve a backend by name.
* @param name - Registered backend name.
* @returns the backend.
*/
get(name: string): StorageBackend {
const backend = this.backends.get(name)
if (!backend) {
throw new StorageError(
'backend-not-found',
`storage backend '${name}' is not registered (registered: ${[...this.backends.keys()].join(', ') || 'none'})`,
)
}
return backend
}
/**
* Registered backend names, for diagnostics.
* @returns a snapshot array of names.
*/
names(): string[] {
return [...this.backends.keys()]
}
}

View 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()
})
})
}

View 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
}
}

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../support/invariants"
}
]
}