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:
12
packages/storage/README.md
Normal file
12
packages/storage/README.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
91
packages/storage/domain/tests/invariant.spec.ts
Normal file
91
packages/storage/domain/tests/invariant.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
12
packages/storage/storage-sqlite/tests/invariant.spec.ts
Normal file
12
packages/storage/storage-sqlite/tests/invariant.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user