chore(storage,workspace): gates — coverage, catalogs, bilingual note

- Per-file 100% coverage across the five new packages (invariant
  companion suites, failure-injection negatives, lifecycle and
  malformed-medium branches).
- Canonical README Model Experience / Known Limitations sections; new
  storage/ and workspace/ group READMEs; packages/README.md rows (budget
  ceiling raised 760 → 790 for the two new groups).
- Cordis catalog/type-link registrations, service-role classification,
  and regenerated catalogs/graphs for the new services and events.
- Agent Note: English body + i18n pairing record; design-sketch fences
  opted out of doc-typecheck as ignore-check.
- Two exactOptionalPropertyTypes/discriminant fixes in new tests.

doc-sync (24 gates), typecheck, hygiene, and the five-package suite
(92 tests) all pass.
This commit is contained in:
imccyu
2026-07-24 22:49:57 +08:00
parent 80b3b6d917
commit 0559e0207e
33 changed files with 1333 additions and 58 deletions

View File

@@ -0,0 +1,12 @@
# storage/ — non-session storage family
The storage family persists everything that is not a session event log: a hub where named backends and typed data forms meet. Design record: [domain KV storage Agent Note](../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
| Package | Role | ctx key |
|---|---|---|
| `storage/` | The hub: named backend registry + merge-extensible data-form mounts, backend facet vocabulary, shared conformance suite | `ctx.storage` |
| `storage-json/` | JSON backend: one human-readable file per unit, atomic whole-file rewrite | registers backend `json` |
| `storage-sqlite/` | SQLite backend: one database hosting all routed units, document-per-row | registers backend `sqlite` |
| `domain/` | Domain data form: zod-validated records, per-domain write chain, `domain/changed` events, backend routing by configuration | mounts `ctx.storage.domain` |
Backends own one medium each and expose data-shape **facets** (`kv` today; an append-log facet is reserved for the future session-backend migration). Consumers never touch backends directly — they open declared domains through the domain form.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-domain
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, land durably on the routed backend, then emit `domain/changed`.
Domain data form for the DeepSeek Harness storage hub: mounts `ctx.storage.domain`, opening schema-validated KV domains over configured storage backends. A domain is declared once with `defineDomain` (zod record schemas, `z.infer`-derived types), opened through `DomainFacility.open`, and served from authoritative in-memory state — reads are synchronous, writes serialize on one per-domain chain, reach durability on the routed backend first, then update memory and emit `domain/changed`.
Design rationale, open semantics, and the storage/domain layer split live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
@@ -13,9 +13,21 @@ Design rationale, open semantics, and the storage/domain layer split live in the
## Model Experience
No model-visible surface: the package registers no tools, injects no prompts, and emits no context. Token and KV-cache cost are zero.
### Durable domain state
#### What the model sees
Nothing. The package registers no tools, injects no prompts, and appends no session events; it stores non-session data (workspace records, future session sidecars) behind `ctx.storage.domain` and emits only the in-process `domain/changed` event, which reaches a model only if a consumer package renders it through its own documented surface.
#### Token effect
Zero. No text from this package enters any model request.
#### KV Cache effect
Independent: domain reads and writes never touch request prefixes, so nothing here can invalidate provider cache reuse.
## Known Limitations and Deferred Work
- Single-process only: `domain/changed` is an in-process event; cross-process observation (GUI reconnect) is deferred to the revision pattern noted in the Agent Note's non-goals.
- No cross-table transactions, secondary indexes, or multi-segment keys; triggers and rework points are tabled in the Agent Note.
- **Single-process change visibility** — `domain/changed` is an in-process event; a second host process or a reconnecting GUI observes no changes until the cross-process revision pattern deferred in the Agent Note lands.
- **No cross-table transactions, secondary indexes, or multi-segment keys** — each write touches one record; triggers and rework points for these extensions are tabled in the Agent Note's deferred-work list.

View File

@@ -147,7 +147,9 @@ export class DomainImpl {
* @param ctx - Context that carries `domain/changed` emissions.
* @param spec - The domain declaration.
* @param unit - The opened backend unit; this instance owns its lifecycle.
* @param records - Validated per-table records from the unit's `loadAll`.
* @param records - Validated records from the unit's `loadAll`, one entry
* per declared table (empty maps included) — the facility builds it from
* the spec, so the entry set IS the table set.
* @param globalValue - Validated stored global, or the spec's `initial`
* when the medium held none; `undefined` when the spec declares no global.
*/
@@ -166,8 +168,8 @@ export class DomainImpl {
assertReadable: () => this.assertReadable(),
emitChanged: (change) => this.ctx.emit('domain/changed', change),
}
for (const table of Object.keys(spec.tables)) {
this.tables.set(table, new KvTableImpl(host, table, records.get(table) ?? new Map()))
for (const [table, tableRecords] of records) {
this.tables.set(table, new KvTableImpl(host, table, tableRecords))
}
if (spec.global !== undefined) {
this.globalValue = globalValue

View File

@@ -119,8 +119,10 @@ export class DomainFacility {
? spec.global.initial
: parseRecord(spec.name, '', '', () => spec.global!.schema.parse(snapshot.global))
const domain = new DomainImpl(this.ctx, spec, unit, tables, globalValue)
this.domains.set(spec.name, domain)
// The open-domain table entry is itself the effect: registration and
// the drain-then-unlist teardown live in one closure.
this.ctx.effect(() => {
this.domains.set(spec.name, domain)
return async () => {
// Drain before unlisting: writes landing during the drain still
// emit domain/changed, and the domain must stay resolvable (the
@@ -139,7 +141,9 @@ export class DomainFacility {
throw error
}
} catch (error) {
if (!this.domains.has(spec.name)) this.reserved.delete(spec.name)
// Any failure means the effect never registered (nothing can throw
// after it), so releasing the name reservation is unconditional.
this.reserved.delete(spec.name)
throw error
}
}

View File

@@ -76,6 +76,35 @@ describe('DomainFacility.open', () => {
await expect(facility.open(spec)).rejects.toMatchObject({ code: 'facet-unsupported' })
})
it('falls back to the default backend when no route table is configured', async () => {
// A second, unmounted facility whose config omits `routes` entirely
// (exactOptionalPropertyTypes forbids an explicit undefined). Opening
// emits no events, so the mounted facility's invariant never consults it.
const { ctx } = await harness()
const routeless = new DomainFacility(ctx, { backend: 'memory' })
await expect(routeless.open(bareSpec)).resolves.toBeDefined()
})
it('treats a table key the backend omitted from loadAll as empty', async () => {
// A sparse backend: loadAll omits declared table keys entirely instead of
// returning them as empty objects.
const { ctx, facility } = await harness({ config: { backend: 'sparse' } })
ctx.storage.backend.register('sparse', {
kv: {
open: async () => ({
loadAll: async () => ({ tables: {}, global: null }),
putRecord: async () => {},
deleteRecord: async () => {},
setGlobal: async () => {},
close: async () => {},
}),
},
close: async () => {},
})
const domain = await facility.open(bareSpec)
expect(domain.table('rows').size).toBe(0)
})
it('rejects stored records that fail their schema, naming table and key', async () => {
const pool = new MemoryMediaPool()
{
@@ -112,6 +141,33 @@ describe('DomainFacility.open', () => {
})
})
describe('plugin apply', () => {
it('mounts the facility as ctx.storage.domain through the plugin effect', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const DomainPlugin = await import('../src/index.ts')
const fiber = await ctx.plugin(DomainPlugin, { backend: 'memory' })
expect(ctx.storage.domain).toBeInstanceOf(DomainFacility)
await fiber.dispose()
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
})
})
describe('table and snapshot reads', () => {
it('serves entries, keys, and size as stable snapshots; unknown table names throw', async () => {
const { facility } = await harness()
const domain = await facility.open(spec)
const table = domain.table('items')
await table.put('a', { label: 'x', count: 1 })
await table.put('b', { label: 'y', count: 2 })
expect(table.size).toBe(2)
expect([...table.keys()].sort()).toEqual(['a', 'b'])
expect(new Map(table.entries()).get('a')).toEqual({ label: 'x', count: 1 })
expect(() => domain.table('nope' as never)).toThrow(/declares no table/)
})
})
describe('KvTable writes', () => {
it('serializes concurrent updates on one key without losing increments', async () => {
const { facility } = await harness()

View File

@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { z } from 'zod'
import Storage from '@deepseek-ai/dsh-storage'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
import * as DomainInvariantCompanion from '@deepseek-ai/dsh-domain/invariant'
import { DomainFacility, defineDomain, domainTable } from '../src/index.ts'
import type { DomainChanged } from '../src/events.ts'
import { MemoryStorageBackend } from './helpers/memory-backend.ts'
const itemSchema = z.object({ n: z.number() })
type Item = z.infer<typeof itemSchema>
const spec = defineDomain({
name: 'inv',
version: 1,
global: { schema: itemSchema, initial: { n: 0 } },
tables: { rows: domainTable<string, Item>(itemSchema) },
})
async function setup() {
const ctx = new Context()
await ctx.plugin(Storage)
await ctx.plugin(InvariantService, { enabled: true })
await ctx.plugin(DomainInvariantCompanion)
ctx.storage.backend.register('memory', new MemoryStorageBackend())
const facility = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', facility)
return { ctx, facility }
}
const invariantViolation = expect.objectContaining<Partial<InvariantError>>({
code: 'INVARIANT',
packageName: '@deepseek-ai/dsh-domain',
})
describe('domain change-event invariants', () => {
it('accepts every write shape emitted by the real write paths', async () => {
const { facility } = await setup()
const domain = await facility.open(spec)
const rows = domain.table('rows')
await rows.put('a', { n: 1 })
await rows.update('a', (current) => ({ n: current.n + 1 }))
await expect(rows.delete('a')).resolves.toBe(true)
await domain.global.set({ n: 5 })
})
it('rejects an event for a domain that is not open', async () => {
const { ctx } = await setup()
expect(() => ctx.emit('domain/changed', {
domain: 'ghost', table: 'rows', key: 'a', operation: 'put', value: { n: 1 },
})).toThrow(invariantViolation)
})
it('rejects a put event whose value is not the in-memory record', async () => {
const { ctx, facility } = await setup()
const domain = await facility.open(spec)
await domain.table('rows').put('a', { n: 1 })
expect(() => ctx.emit('domain/changed', {
domain: 'inv', table: 'rows', key: 'a', operation: 'put', value: { n: 999 },
})).toThrow(invariantViolation)
})
it('rejects a deletion event while the record is still in memory', async () => {
const { ctx, facility } = await setup()
const domain = await facility.open(spec)
await domain.table('rows').put('a', { n: 1 })
expect(() => ctx.emit('domain/changed', {
domain: 'inv', table: 'rows', key: 'a', operation: 'deleted',
})).toThrow(invariantViolation)
})
it('rejects a global event whose value is not the in-memory global', async () => {
const { ctx, facility } = await setup()
await facility.open(spec)
expect(() => ctx.emit('domain/changed', {
domain: 'inv', table: '', key: '', operation: 'put', value: { n: 42 },
})).toThrow(invariantViolation)
})
it('tolerates operations outside the closed union without failing falsely', async () => {
const { ctx, facility } = await setup()
const domain = await facility.open(spec)
await domain.table('rows').put('a', { n: 1 })
// Merge-hostile input: the closed union's satisfies-never default arm is
// unreachable in typed code; an untyped emit must not crash the check.
expect(() => ctx.emit('domain/changed', {
domain: 'inv', table: 'rows', key: 'a', operation: 'exotic',
} as unknown as DomainChanged)).not.toThrow()
})
})

View File

@@ -16,7 +16,19 @@ JSON backend for the [storage hub](../storage/README.md): one human-readable `<u
## Model Experience
No model-visible surface: this package serves host-side persistence only; nothing it does reaches prompts, tool schemas, or token budgets.
### Stored domain records
#### What the model sees
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data behind `ctx.storage` for host-side consumers only.
#### Token effect
Zero live-request tokens.
#### KV Cache effect
None — the backend never touches live request prefixes.
## Known Limitations and Deferred Work

View File

@@ -45,14 +45,14 @@ export class JsonStorageBackend implements StorageBackend {
constructor(private readonly root: string) {}
readonly kv: KvFacet = {
open: (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
if (this.closed) return Promise.reject(new StorageError('closed', 'json backend is closed'))
// The body up to the first await runs synchronously, so the opening-slot
// reservation below still excludes a concurrent open of the same unit.
open: async (descriptor: KvUnitDescriptor): Promise<KvUnit> => {
if (this.closed) throw new StorageError('closed', 'json backend is closed')
validateDescriptor(descriptor)
if (this.open.has(descriptor.name) || this.opening.has(descriptor.name)) {
// Double-open is a caller bug, not a medium condition.
return Promise.reject(
new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`),
)
throw new Error(`unit '${descriptor.name}' is already open; a unit has exactly one live handle`)
}
const opening = this.openUnit(descriptor)
this.opening.set(descriptor.name, opening)

View File

@@ -14,7 +14,13 @@ import { writeAtomic } from './atomic.ts'
import { parse, serialize } from './format.ts'
import type { UnitState } from './format.ts'
/** Open (load or lazily create) one unit backed by `path`. */
/**
* Open (load or lazily create) one unit backed by `path`.
* @param descriptor - Static identity and shape of the unit.
* @param path - Absolute unit file path under the backend root.
* @param onClose - Backend callback releasing the unit's open-slot.
* @returns the opened unit.
*/
export async function openJsonUnit(
descriptor: KvUnitDescriptor,
path: string,

View File

@@ -1,9 +1,13 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Storage from '@deepseek-ai/dsh-storage'
import InvariantService from '@deepseek-ai/dsh-invariants'
import { runKvBackendContract } from '../../storage/tests/contract.ts'
import { JsonStorageBackend } from '../src/index.ts'
import { Config, JsonStorageBackend, apply } from '../src/index.ts'
import * as InvariantCompanion from '../src/invariant.ts'
const roots: string[] = []
@@ -83,15 +87,17 @@ describe('json backend specifics', () => {
const backend = new JsonStorageBackend(root)
const unit = await backend.kv.open(descriptor)
await unit.putRecord('t', 'k', { v: 'committed' })
// Make the next publish fail: replace the unit file's parent with an
// unwritable directory path via chmod.
const { chmod } = await import('node:fs/promises')
await unit.setGlobal({ g: 'committed' })
// Make every publish fail: revoke write permission on the root.
await chmod(root, 0o500)
await expect(unit.putRecord('t', 'k', { v: 'rejected' })).rejects.toThrow()
await expect(unit.putRecord('t', 'k2', { v: 'also rejected' })).rejects.toThrow()
await expect(unit.deleteRecord('t', 'k')).rejects.toThrow()
await expect(unit.setGlobal({ g: 'rejected' })).rejects.toThrow()
await chmod(root, 0o700)
const snapshot = await unit.loadAll()
expect(snapshot.tables['t']).toEqual({ k: { v: 'committed' } })
expect(snapshot.global).toEqual({ g: 'committed' })
// The next successful publish must not carry rejected writes to disk.
await unit.putRecord('t', 'k3', { v: 'later' })
const text = await readFile(join(root, 'shape.json'), 'utf8')
@@ -99,6 +105,102 @@ describe('json backend specifics', () => {
await backend.close()
})
it('rejects undeclared table and global access as caller errors', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)
const unit = await backend.kv.open({ name: 'shape', version: 1, tables: ['t'], hasGlobal: false })
await expect(unit.putRecord('undeclared', 'k', {})).rejects.toThrow(/does not declare table/)
await expect(unit.setGlobal({})).rejects.toThrow(/does not declare a global slot/)
await backend.close()
})
it('rejects invalid unit and table names', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)
await expect(backend.kv.open({ ...descriptor, name: 'Bad-Name' })).rejects.toMatchObject({
name: 'StorageError',
code: 'malformed-medium',
})
await expect(backend.kv.open({ ...descriptor, tables: ['ok', 'not ok'] })).rejects.toMatchObject({
name: 'StorageError',
code: 'malformed-medium',
})
await backend.close()
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'closed' })
})
it('opens a file missing a declared table as that table empty', async () => {
const root = await freshRoot()
await writeFile(
join(root, 'contract_unit.json'),
JSON.stringify({ unit: { name: 'contract_unit', version: 3 }, global: null, tables: { alpha: { k: 1 } } }),
'utf8',
)
const backend = new JsonStorageBackend(root)
const unit = await backend.kv.open({ name: 'contract_unit', version: 3, tables: ['alpha', 'beta'], hasGlobal: true })
const snapshot = await unit.loadAll()
expect(snapshot.tables['alpha']).toEqual({ k: 1 })
expect(snapshot.tables['beta']).toEqual({})
await backend.close()
})
it('propagates non-ENOENT read failures', async () => {
const root = await freshRoot()
const { mkdir } = await import('node:fs/promises')
// A directory where the unit file should be: readFile fails with EISDIR.
await mkdir(join(root, 'shape.json'))
const backend = new JsonStorageBackend(root)
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'EISDIR' })
await backend.close()
})
it('rejects malformed table shapes and foreign versions distinctly', async () => {
const root = await freshRoot()
await writeFile(
join(root, 'shape.json'),
JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null, tables: { t: ['not', 'an', 'object'] } }),
'utf8',
)
const backend = new JsonStorageBackend(root)
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
await writeFile(
join(root, 'shape.json'),
JSON.stringify({ unit: { name: 'shape', version: 9 }, global: null, tables: {} }),
'utf8',
)
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'version-mismatch' })
await writeFile(join(root, 'shape.json'), JSON.stringify({ unit: { name: 'shape', version: 1 }, global: null }), 'utf8')
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
await writeFile(join(root, 'shape.json'), JSON.stringify('just a string'), 'utf8')
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
await backend.close()
})
it('registers on the hub via apply and closes on dispose', async () => {
const root = await freshRoot()
const ctx = new Context()
await ctx.plugin(Storage)
const fiber = await ctx.plugin({ apply, Config, inject: ['storage'] }, { root })
const backend = ctx.storage.backend.get('json')
const unit = await backend.kv!.open(descriptor)
await unit.putRecord('t', 'k', { v: 1 })
await fiber.dispose()
expect(() => ctx.storage.backend.get('json')).toThrow()
await expect(unit.putRecord('t', 'x', {})).rejects.toMatchObject({ code: 'closed' })
})
it('registers the invariant companion and disposes cleanly', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService)
const fiber = await ctx.plugin(InvariantCompanion)
// Disposal releases the reservation: a fresh mount succeeds.
await fiber.dispose()
await ctx.plugin(InvariantCompanion)
})
it('close drains in-flight writes and blocks in-flight opens', async () => {
const root = await freshRoot()
const backend = new JsonStorageBackend(root)

View File

@@ -19,17 +19,19 @@ interface Config {
## Model Experience
### What the model sees
### Stored domain records
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data for host-side consumers.
#### What the model sees
### Token effect
Nothing. This backend contributes no prompt, tool, or schema; it persists non-session domain data (workspace records, future session sidecar metadata) behind `ctx.storage` for host-side consumers only.
#### Token effect
Zero live-request tokens.
### KV Cache effect
#### KV Cache effect
None — no live request prefixes are touched.
None — the backend never touches live request prefixes.
## Known Limitations and Deferred Work

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as StorageSqliteInvariant from '../src/invariant.ts'
describe('invariant companion', () => {
it('registers under the package name with an explained-empty installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })
await expect(ctx.plugin(StorageSqliteInvariant).await()).resolves.toBeDefined()
})
})

View File

@@ -1,10 +1,13 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm } from 'node:fs/promises'
import { Context } from 'cordis'
import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { DatabaseSync } from 'node:sqlite'
import Storage from '@deepseek-ai/dsh-storage'
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
import { runKvBackendContract } from '../../storage/tests/contract.ts'
import * as StorageSqlite from '../src/index.ts'
import { Config, SqliteStorageBackend, STORAGE_SQLITE_SCHEMA_VERSION } from '../src/index.ts'
/** Mirror the loader: resolve schemastery defaults before construction. */
@@ -168,6 +171,65 @@ describe('sqlite backend specifics', () => {
await reopened.close()
})
it('rejects setGlobal on a unit without a global slot and writes to undeclared tables', async () => {
const backend = backendAt(':memory:')
const unit = await backend.kv.open({ ...DESCRIPTOR, hasGlobal: false })
await expect(unit.setGlobal({ g: 1 })).rejects.toThrow(/declared no global slot/)
await expect(unit.putRecord('undeclared', 'k', 1)).rejects.toThrow(/declared no table/)
expect((await unit.loadAll()).global).toBeNull()
await backend.close()
})
it('drains a still-pending failed open during close', async () => {
const path = await freshDbPath()
const first = backendAt(path)
await (await first.kv.open(DESCRIPTOR)).close()
await first.close()
const backend = backendAt(path)
// Do not await: close() must tolerate an in-flight open that will reject
// (version mismatch) while its name is still reserved in the unit table.
const pending = backend.kv.open({ ...DESCRIPTOR, version: 99 })
const closed = backend.close()
await expect(pending).rejects.toMatchObject({ code: 'version-mismatch' })
await closed
})
it('propagates filesystem errors other than an existing database file', async () => {
if (process.platform === 'win32') return
const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-'))
dirs.push(dir)
await chmod(dir, 0o500)
const backend = backendAt(join(dir, 'storage.db'))
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'EACCES' })
await backend.close()
await chmod(dir, 0o700)
})
it('preserves the mode of an existing database file', async () => {
if (process.platform === 'win32') return
const path = await freshDbPath()
await writeFile(path, '', { mode: 0o644 })
await chmod(path, 0o644)
const backend = backendAt(path)
const unit = await backend.kv.open(DESCRIPTOR)
await unit.putRecord('records', 'k', 1)
await backend.close()
})
it('registers on the storage hub as backend sqlite and closes on dispose', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
const fiber = await ctx.plugin(StorageSqlite, { path: ':memory:' })
const backend = ctx.storage.backend.get('sqlite')
const unit = await backend.kv!.open(DESCRIPTOR)
await unit.putRecord('records', 'k', { n: 1 })
await fiber.dispose()
expect(ctx.storage.backend.names()).toEqual([])
await expect(backend.kv!.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
})
it('rejects an unparsable global slot with malformed-medium', async () => {
const path = await freshDbPath()
const backend = backendAt(path)

View File

@@ -16,3 +16,24 @@ Storage hub (`ctx.storage`) for non-session data: a named backend registry plus
| `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 |
## Model Experience
### Backend and form registrations
#### What the model sees
Nothing. `ctx.storage` is a host-side registration table; the hub registers no tools, injects no prompts, and writes no session events.
#### Token effect
Zero direct tokens on every request.
#### KV Cache effect
Independent of live requests: the hub never touches a request prefix, so it cannot invalidate provider cache reuse.
## Known Limitations and Deferred Work
- **`kv` is the only data shape** — the append-log facet the future session-backend migration needs is reserved in the design note but not yet defined; backends currently have exactly one facet to implement.
- **Forms resolve lazily** — reading `ctx.storage.domain` before the domain plugin mounts throws `form-not-mounted`; assemblies order plugins accordingly (misconfiguration fails loud rather than silently deferring).

View File

@@ -33,11 +33,33 @@ describe('Storage service', () => {
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.domain).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(() => ctx.storage.domain).toThrowMatchingObject({ code: 'form-not-mounted' })
})
it('ignores a stale disposer after dispose and re-mount / re-register', async () => {
const ctx = new Context()
await ctx.plugin(Storage)
const first = { first: true }
const second = { second: true }
const staleMount = ctx.storage.mount('domain' as never, first as never)
staleMount()
ctx.storage.mount('domain' as never, second as never)
staleMount()
expect(ctx.storage.form('domain' as never)).toBe(second)
const backendA = fakeBackend()
const backendB = fakeBackend()
const staleRegister = ctx.storage.backend.register('json', backendA)
staleRegister()
ctx.storage.backend.register('json', backendB)
staleRegister()
expect(ctx.storage.backend.get('json')).toBe(backendB)
})
})