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:
@@ -34,6 +34,8 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
|
||||
@@ -642,6 +642,20 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
summary: 'The storage hub service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void',
|
||||
jsDoc: '/**\n * Mount a data-form facility on the hub. Mounting is an effect: the\n * returned disposer unmounts the form.\n * @param form - Form key declared in {@link StorageForms}.\n * @param facility - The facility instance to expose.\n * @returns the disposer that unmounts the form.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'form<K extends keyof StorageForms>(form: K): StorageForms[K]',
|
||||
jsDoc: '/**\n * Resolve a mounted data form.\n * @param form - Form key declared in {@link StorageForms}.\n * @returns the mounted facility.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'subagents',
|
||||
summary: 'Named provider registry and capability-checked start surface.',
|
||||
@@ -846,6 +860,28 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
summary: 'The workspace registry service.',
|
||||
methods: [
|
||||
{
|
||||
signature: 'async create(path: string, title?: string): Promise<Workspace>',
|
||||
jsDoc: '/**\n * Create a workspace over an existing directory. The path is canonicalized\n * through `fs.realpath` first — a nonexistent path rejects with the\n * original `ENOENT`, a path resolving to anything but a directory rejects,\n * and a canonical path already owned by another workspace (including a\n * symlink resolving to it) rejects.\n * @param path - Directory the workspace points at; canonicalized before storing.\n * @param title - Display title; defaults to `basename` of the canonical path.\n * @returns the created workspace after durability.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'get(id: WorkspaceId): Workspace | undefined',
|
||||
jsDoc: '/**\n * Look up a workspace by id.\n * @param id - The workspace id.\n * @returns the workspace, or `undefined` when unknown.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'list(): Workspace[]',
|
||||
jsDoc: '/**\n * Snapshot of all workspaces, in load-then-creation order.\n * @returns a fresh array of the cached entities.\n */',
|
||||
},
|
||||
{
|
||||
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
|
||||
jsDoc: '/**\n * Resolve a workspace by directory path, through the same `fs.realpath`\n * canon as {@link create} (hence async). A path that does not exist rejects\n * with the original error — a missing directory has no canonical form to\n * compare (a workspace whose recorded directory vanished is only reachable\n * by id; see `Workspace.status`).\n * @param path - Directory path in any spelling (symlinks, `..`, trailing slash).\n * @returns the owning workspace, or `undefined` when none matches.\n */',
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
/** Every harness event, sorted by name. */
|
||||
@@ -997,6 +1033,13 @@ export const EVENT_API: readonly EventApiEntry[] = [
|
||||
jsDoc: '/**\n * A command was registered or unregistered. This is an unfiltered registry\n * notification because a global or scoped change may affect any UI view.\n * Observer failures are contained and cannot veto the registry mutation.\n * @mode emit\n */',
|
||||
summary: 'A command was registered or unregistered.',
|
||||
},
|
||||
{
|
||||
name: 'domain/changed',
|
||||
mode: 'emit',
|
||||
signature: '\'domain/changed\'(change: DomainChanged): void',
|
||||
jsDoc: '/**\n * A domain record or the global singleton changed, emitted once per write\n * strictly after the backend acknowledged durability. Events of one\n * domain arrive in its write-chain order.\n * @param change - domain, table (`\'\'` for global), key (`\'\'` for global),\n * operation discriminant, and on `put` the new snapshot.\n * @mode emit\n */',
|
||||
summary: 'A domain record or the global singleton changed, emitted once per write strictly after the backend acknowledged durability.',
|
||||
},
|
||||
{
|
||||
name: 'fs/edit-intent',
|
||||
mode: 'waterfall',
|
||||
@@ -1999,6 +2042,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SpillSource',
|
||||
declaration: 'export interface SpillSource {\n toolName: string;\n callId: CallId;\n label: string;\n}',
|
||||
},
|
||||
{
|
||||
name: 'StorageForms',
|
||||
declaration: 'export interface StorageForms {\n}',
|
||||
},
|
||||
{
|
||||
name: 'StreamChunk',
|
||||
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
|
||||
@@ -2335,6 +2382,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'WorkflowStopReason',
|
||||
declaration: 'export type WorkflowStopReason = \'completed\' | \'cancelled\' | \'error\';',
|
||||
},
|
||||
{
|
||||
name: 'Workspace',
|
||||
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
|
||||
},
|
||||
]
|
||||
|
||||
/** The inherited `ctx` surface (cordis core + loader/hmr/timer), in curated order. */
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
9
packages/workspace/README.md
Normal file
9
packages/workspace/README.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# workspace/ — the workspace entity
|
||||
|
||||
The workspace family owns the persistent workspace concept: a directory the user works in, with a title and the ordered list of sessions that belong to it. 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 |
|
||||
|---|---|---|
|
||||
| `workspace/` | `WorkspaceRegistry` service over the storage domain form: realpath-unique paths, session-ownership accounting, entity cache | `ctx.workspace` |
|
||||
|
||||
Ownership truth lives in the workspace record's `sessionIds` (ordered), never derived from session cwd; `attachSession` verifies the session header's cwd resolves to the workspace path, so one session structurally belongs to at most one workspace. Deletion (workspace and session cascade) is deliberately absent this phase and ships with the session-side primitives.
|
||||
@@ -16,7 +16,19 @@ Session persistence is an optional peer resolved with `ctx.get`: absent, attach
|
||||
|
||||
## 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.
|
||||
### Workspace records and session accounts
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. `ctx.workspace` serves workspace records to host-side consumers only: the package registers no tools, injects no prompts, and writes no session events, so no request field ever carries this package's data.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct tokens on every request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Independent of live requests: the package never touches a request prefix, so it cannot invalidate provider cache reuse.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
|
||||
56
packages/workspace/workspace/tests/invariant.spec.ts
Normal file
56
packages/workspace/workspace/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-domain'
|
||||
import * as WorkspaceInvariant from '../src/invariant.ts'
|
||||
import { WorkspaceId } from '../src/index.ts'
|
||||
|
||||
/** Boot the invariant service plus the companion over a stubbed registry knowing exactly `ids`. */
|
||||
async function setup(ids: string[]): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
ctx.provide('workspace', {
|
||||
get: (id: WorkspaceId) => (ids.includes(id) ? { id } : undefined),
|
||||
})
|
||||
await ctx.plugin(WorkspaceInvariant)
|
||||
return ctx
|
||||
}
|
||||
|
||||
type ChangeLocation = Partial<Pick<DomainChanged, 'domain' | 'table' | 'key'>>
|
||||
|
||||
const put = (overrides?: ChangeLocation): DomainChanged => ({
|
||||
domain: 'workspace',
|
||||
table: 'workspaces',
|
||||
key: 'w1',
|
||||
operation: 'put',
|
||||
value: {},
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const deleted = (): DomainChanged => ({
|
||||
domain: 'workspace',
|
||||
table: 'workspaces',
|
||||
key: 'w1',
|
||||
operation: 'deleted',
|
||||
})
|
||||
|
||||
describe('workspace cache/table invariant', () => {
|
||||
it('accepts a put whose record has a cached entity and ignores foreign events', async () => {
|
||||
const ctx = await setup(['w1'])
|
||||
expect(() => { ctx.emit('domain/changed', put()) }).not.toThrow()
|
||||
// Other domains and other tables are out of scope, whatever their shape.
|
||||
expect(() => { ctx.emit('domain/changed', put({ domain: 'other', key: 'missing' })) }).not.toThrow()
|
||||
expect(() => { ctx.emit('domain/changed', put({ table: 'other', key: 'missing' })) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('fails a deleted operation — this phase exposes no delete entry point', async () => {
|
||||
const ctx = await setup(['w1'])
|
||||
expect(() => { ctx.emit('domain/changed', deleted()) })
|
||||
.toThrow(/no delete entry point/)
|
||||
})
|
||||
|
||||
it('fails a put whose record the registry cache does not hold', async () => {
|
||||
const ctx = await setup([])
|
||||
expect(() => { ctx.emit('domain/changed', put()) }).toThrow(/diverged/)
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { basename, join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Storage from '@deepseek-ai/dsh-storage'
|
||||
import type { StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
import { DomainFacility } from '@deepseek-ai/dsh-domain'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-domain'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -24,10 +25,11 @@ const header = (id: string, cwd?: string): SessionHeader =>
|
||||
async function harness(options?: {
|
||||
pool?: MemoryMediaPool
|
||||
sessions?: SessionHeader[] | 'absent'
|
||||
backend?: StorageBackend
|
||||
}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(options?.pool))
|
||||
ctx.storage.backend.register('memory', options?.backend ?? new MemoryStorageBackend(options?.pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
let listed = options?.sessions === 'absent' ? undefined : options?.sessions ?? []
|
||||
if (listed !== undefined) {
|
||||
@@ -44,6 +46,36 @@ async function harness(options?: {
|
||||
}
|
||||
}
|
||||
|
||||
/** A memory backend whose next `putRecord` throws once when armed, for write-failure paths. */
|
||||
function failingBackend(): { backend: StorageBackend; arm: () => void } {
|
||||
const inner = new MemoryStorageBackend()
|
||||
let failNext = false
|
||||
return {
|
||||
arm: () => { failNext = true },
|
||||
backend: {
|
||||
kv: {
|
||||
open: async (descriptor) => {
|
||||
const unit = await inner.kv.open(descriptor)
|
||||
return {
|
||||
loadAll: () => unit.loadAll(),
|
||||
putRecord: async (table, key, value) => {
|
||||
if (failNext) {
|
||||
failNext = false
|
||||
throw new Error('medium write failed (injected)')
|
||||
}
|
||||
return unit.putRecord(table, key, value)
|
||||
},
|
||||
deleteRecord: (table, key) => unit.deleteRecord(table, key),
|
||||
setGlobal: value => unit.setGlobal(value),
|
||||
close: () => unit.close(),
|
||||
}
|
||||
},
|
||||
},
|
||||
close: () => inner.close(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A pool pre-stamped with one stored workspace record, simulating a prior run. */
|
||||
function pooledRecord(id: string, record: WorkspaceRecord): MemoryMediaPool {
|
||||
const pool = new MemoryMediaPool()
|
||||
@@ -134,6 +166,25 @@ describe('WorkspaceRegistry.create', () => {
|
||||
expect(await registry.resolveByPath(link)).toBe(workspace)
|
||||
expect(await registry.resolveByPath(await makeDir('unowned'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rolls the entity cache back when the durable write fails, leaving the path free to retry', async () => {
|
||||
const dir = await makeDir('rollback')
|
||||
const { backend, arm } = failingBackend()
|
||||
const { registry } = await harness({ backend })
|
||||
arm()
|
||||
await expect(registry.create(dir)).rejects.toThrow(/injected/)
|
||||
expect(registry.list()).toEqual([])
|
||||
const retried = await registry.create(dir)
|
||||
expect(retried.path).toBe(dir)
|
||||
})
|
||||
|
||||
it('rejects any table access before the registry has started', async () => {
|
||||
const dir = await makeDir('unstarted')
|
||||
const ctx = new Context()
|
||||
// Constructed directly, Service.init never ran: no domain, no table.
|
||||
const registry = new WorkspaceRegistry(ctx)
|
||||
await expect(registry.create(dir)).rejects.toThrow(/not started/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace.attachSession', () => {
|
||||
@@ -235,7 +286,24 @@ describe('consistency projections', () => {
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000002')
|
||||
const pool = pooledRecord(id, record(dir, ['maybe']))
|
||||
const { registry } = await harness({ pool, sessions: 'absent' })
|
||||
expect(registry.get(id)!.sessionIds).toEqual(['maybe'])
|
||||
const workspace = registry.get(id)!
|
||||
expect(workspace.sessionIds).toEqual(['maybe'])
|
||||
// Mutations must not prune either: unverifiable membership is kept as-is.
|
||||
await workspace.setTitle('still-unverified')
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['maybe'])
|
||||
})
|
||||
|
||||
it('prunes dead ids even when the triggering mutation is itself a no-op', async () => {
|
||||
const dir = await makeDir('prune-on-noop')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000007')
|
||||
const pool = pooledRecord(id, record(dir, ['ghost']))
|
||||
const { registry, changes } = await harness({ pool, sessions: [] })
|
||||
const workspace = registry.get(id)!
|
||||
// Detaching an id that was never on the account changes nothing by
|
||||
// itself, but the mutation slot still prunes the dead 'ghost' durably.
|
||||
await workspace.detachSession(SessionId('never-there'))
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual([])
|
||||
expect(changes).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects startup over a medium accounting one session twice', async () => {
|
||||
@@ -264,6 +332,20 @@ describe('consistency projections', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace mutation failures', () => {
|
||||
it('propagates a medium write failure from a mutation and keeps the old snapshot', async () => {
|
||||
const dir = await makeDir('write-fail')
|
||||
const { backend, arm } = failingBackend()
|
||||
const { registry } = await harness({ backend })
|
||||
const workspace = await registry.create(dir)
|
||||
arm()
|
||||
await expect(workspace.setTitle('lost')).rejects.toThrow(/injected/)
|
||||
expect(workspace.title).toBe('write-fail')
|
||||
await workspace.setTitle('kept')
|
||||
expect(workspace.title).toBe('kept')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace.status', () => {
|
||||
it('reports ok while the directory exists and missing-dir once it is gone, without mutating the record', async () => {
|
||||
const dir = await makeDir('vanishing')
|
||||
@@ -274,5 +356,8 @@ describe('Workspace.status', () => {
|
||||
expect(await workspace.status()).toBe('missing-dir')
|
||||
expect(workspace.path).toBe(dir)
|
||||
expect(registry.get(workspace.id)).toBe(workspace)
|
||||
// The path re-materializing as a non-directory is still missing-dir.
|
||||
await writeFile(dir, 'now a file')
|
||||
expect(await workspace.status()).toBe('missing-dir')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user