From 7c27107be455dc368329ae27e19ed3945eaeb916 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 24 Jul 2026 19:07:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(storage):=20domain=20data=20form=20?= =?UTF-8?q?=E2=80=94=20typed=20schemas=20over=20opaque=20KV=20units?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ctx.storage.domain opens declared domains: zod value schemas parsed at the durable boundary, one write chain per domain (update(fn) is the only read-modify-write), domain/changed emitted per record after durability (new snapshot + operation, no old value, per repo event convention). Domain-to-backend routing is configuration (default backend + per-domain overrides); unknown names and missing facets fail loud. Ships the MemoryStorageBackend test helper and a runtime invariant asserting every change event matches the in-memory state. --- packages/storage/domain/README.md | 21 ++ packages/storage/domain/package.json | 43 +++ packages/storage/domain/src/domain.ts | 322 ++++++++++++++++++ packages/storage/domain/src/error.ts | 53 +++ packages/storage/domain/src/events.ts | 36 ++ packages/storage/domain/src/index.ts | 180 ++++++++++ packages/storage/domain/src/invariant.ts | 62 ++++ packages/storage/domain/src/spec.ts | 102 ++++++ packages/storage/domain/tests/domain.spec.ts | 191 +++++++++++ .../domain/tests/helpers/memory-backend.ts | 140 ++++++++ packages/storage/domain/tsconfig.json | 27 ++ pnpm-lock.yaml | 88 +++++ 12 files changed, 1265 insertions(+) create mode 100644 packages/storage/domain/README.md create mode 100644 packages/storage/domain/package.json create mode 100644 packages/storage/domain/src/domain.ts create mode 100644 packages/storage/domain/src/error.ts create mode 100644 packages/storage/domain/src/events.ts create mode 100644 packages/storage/domain/src/index.ts create mode 100644 packages/storage/domain/src/invariant.ts create mode 100644 packages/storage/domain/src/spec.ts create mode 100644 packages/storage/domain/tests/domain.spec.ts create mode 100644 packages/storage/domain/tests/helpers/memory-backend.ts create mode 100644 packages/storage/domain/tsconfig.json diff --git a/packages/storage/domain/README.md b/packages/storage/domain/README.md new file mode 100644 index 0000000000..eb955a4735 --- /dev/null +++ b/packages/storage/domain/README.md @@ -0,0 +1,21 @@ +# @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`. + +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). + +## Configuration + +| key | meaning | +| --- | --- | +| `backend` | Default backend name for every domain (required; no universally correct medium exists). | +| `routes` | Per-domain overrides: domain name → backend name. | + +## 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. + +## 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. diff --git a/packages/storage/domain/package.json b/packages/storage/domain/package.json new file mode 100644 index 0000000000..91ebb26868 --- /dev/null +++ b/packages/storage/domain/package.json @@ -0,0 +1,43 @@ +{ + "name": "@deepseek-ai/dsh-domain", + "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-storage": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/storage/domain/src/domain.ts b/packages/storage/domain/src/domain.ts new file mode 100644 index 0000000000..9f36113dc9 --- /dev/null +++ b/packages/storage/domain/src/domain.ts @@ -0,0 +1,322 @@ +/** + * Runtime of one open domain: authoritative in-memory state, the single + * per-domain write chain, and change-event emission. Reads are synchronous + * from memory; every write queues on the chain, mutates memory, awaits + * backend durability, then emits `domain/changed` — so events carry values + * that equal the in-memory state at emission and arrive in write order. + * @module @deepseek-ai/dsh-domain/src/domain + */ + +import type { Context } from 'cordis' +import type { KvUnit } from '@deepseek-ai/dsh-storage' +import { DomainError } from './error.ts' +import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts' +import type { DomainChanged } from './events.ts' + +/** Handle on a domain's global singleton. */ +export interface DomainGlobal { + /** + * Current value, synchronously from the authoritative in-memory state. + * Before the first `set` this is the spec's `initial`. + * @returns the current global value. + */ + get(): G + + /** + * Replace the value durably. Queued on the domain's write chain; the first + * `set` is what materializes the global on the medium. + * @param value - New value; must satisfy the spec's schema (not re-checked + * here — validation happens at the durable read boundary). + * @returns resolution after durability and event emission. + */ + set(value: G): Promise +} + +/** + * Handle on one declared table. Records are plain immutable data: returned + * values are the stored objects themselves (no defensive copies) and must not + * be mutated in place — replace via `put`/`update`. + */ +export interface KvTable { + /** + * Read one record, synchronously from memory. + * @param key - Record key. + * @returns the record, or `undefined` when absent. + */ + get(key: K): V | undefined + + /** + * Snapshot iterator over `[key, record]` pairs. A snapshot, not a live + * view: iteration stays stable while queued writes land. + * @returns the pair iterator. + */ + entries(): IterableIterator<[K, V]> + + /** + * Snapshot iterator over keys. + * @returns the key iterator. + */ + keys(): IterableIterator + + /** Current record count. */ + readonly size: number + + /** + * Insert or overwrite one record durably. + * @param key - Record key. + * @param value - The full new record (no partial merge). + * @returns resolution after durability and event emission. + */ + put(key: K, value: V): Promise + + /** + * Delete one record durably. + * @param key - Record key. + * @returns `true` when the record existed, `false` when it was already + * absent (no write and no event in that case). + */ + delete(key: K): Promise + + /** + * Atomic read-modify-write on the domain's write chain: `fn` sees the + * value current at its queue slot, so concurrent updates never interleave. + * @param key - Record key; a missing key rejects with `missing-key`. + * @param fn - Synchronous pure transform from current to next record. + * @returns the stored next record. + */ + update(key: K, fn: (current: V) => V): Promise +} + +/** Global handle of a spec: typed when declared, `never` (inaccessible) when not. */ +export type DomainGlobalHandleOf = + S extends { readonly global: DomainGlobalSpec } ? DomainGlobal : never + +/** One open domain, typed by its spec. */ +export interface Domain { + /** Domain name from the spec. */ + readonly name: string + /** Global singleton handle; a spec without `global` has no usable handle (`never`). */ + readonly global: DomainGlobalHandleOf + /** + * Resolve one declared table handle. Handles are stable — repeated calls + * return the same instance. + * @param name - Declared table name. + * @returns the typed table handle. + */ + table(name: N): KvTable, TableValueOf> +} + +/** Internal seam handing table handles their domain-owned write machinery. */ +interface TableHost { + readonly domainName: string + readonly unit: KvUnit + /** Queue one job on the domain's single write chain. */ + enqueue(job: () => Promise): Promise + /** Throw `closed` once the domain has fully closed (reads stay valid while draining). */ + assertReadable(): void + /** Emit `domain/changed` for one durably landed write. */ + emitChanged(change: DomainChanged): void +} + +const noop = () => {} + +/** + * The single domain implementation behind the {@link Domain} interface. The + * facility constructs it from a validated `loadAll` snapshot and erases it to + * `Domain`; nothing outside this package constructs one. + */ +export class DomainImpl { + /** Domain name from the spec. */ + readonly name: string + + private readonly tables = new Map>() + private globalValue: unknown + private readonly globalHandle?: DomainGlobal + + /** Tail of the write chain; every link settles (rejections are observed by the caller's slice). */ + private chain: Promise = Promise.resolve() + /** Set when dispose begins: new writes reject while already-queued writes drain. */ + private disposing = false + /** Set when dispose finishes (chain drained, unit closed): reads reject from here on. */ + private closed = false + private disposal?: Promise + + /** + * @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 globalValue - Validated stored global, or the spec's `initial` + * when the medium held none; `undefined` when the spec declares no global. + */ + constructor( + private readonly ctx: Context, + spec: DomainSpec, + private readonly unit: KvUnit, + records: Map>, + globalValue: unknown, + ) { + this.name = spec.name + const host: TableHost = { + domainName: spec.name, + unit, + enqueue: (job) => this.enqueue(job), + 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())) + } + if (spec.global !== undefined) { + this.globalValue = globalValue + this.globalHandle = { + get: () => { + this.assertReadable() + return this.globalValue + }, + set: (value) => this.enqueue(async () => { + this.globalValue = value + await this.unit.setGlobal(value) + host.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value }) + }), + } + } + } + + /** Global singleton handle; accessing it on a spec that declares no global is a caller bug and throws. */ + get global(): DomainGlobal { + if (this.globalHandle === undefined) { + throw new Error(`domain '${this.name}' declares no global`) + } + return this.globalHandle + } + + /** + * Resolve one declared table handle; an undeclared name is a caller bug + * and throws. + * @param name - Declared table name. + * @returns the stable table handle. + */ + table(name: string): KvTable { + const table = this.tables.get(name) + if (table === undefined) { + throw new Error(`domain '${this.name}' declares no table '${name}'`) + } + return table + } + + /** + * Close this domain: reject new writes immediately, drain already-queued + * writes (their events still emit), then close the unit. Idempotent — + * repeated calls share one teardown. + * @returns resolution after the unit is released. + */ + dispose(): Promise { + this.disposal ??= this.runDispose() + return this.disposal + } + + private async runDispose(): Promise { + this.disposing = true + // Chain links never reject (each is settled via then(noop, noop)), so + // this await is a pure drain barrier. + await this.chain + await this.unit.close() + this.closed = true + } + + private enqueue(job: () => Promise): Promise { + if (this.disposing) { + return Promise.reject(new DomainError('closed', `domain '${this.name}' is closed`)) + } + const result = this.chain.then(job) + this.chain = result.then(noop, noop) + return result + } + + private assertReadable(): void { + if (this.closed) { + throw new DomainError('closed', `domain '${this.name}' is closed`) + } + } +} + +/** Table handle bound to one in-memory record map and its domain's write chain. */ +class KvTableImpl implements KvTable { + constructor( + private readonly host: TableHost, + private readonly tableName: string, + private readonly records: Map, + ) {} + + get(key: K): V | undefined { + this.host.assertReadable() + return this.records.get(key) as V | undefined + } + + entries(): IterableIterator<[K, V]> { + this.host.assertReadable() + return ([...this.records.entries()] as [K, V][])[Symbol.iterator]() + } + + keys(): IterableIterator { + this.host.assertReadable() + return ([...this.records.keys()] as K[])[Symbol.iterator]() + } + + get size(): number { + this.host.assertReadable() + return this.records.size + } + + put(key: K, value: V): Promise { + return this.host.enqueue(async () => { + this.records.set(key, value) + await this.host.unit.putRecord(this.tableName, key, value) + this.emitPut(key, value) + }) + } + + delete(key: K): Promise { + return this.host.enqueue(async () => { + // Existence is decided at this job's chain slot, not at call time: an + // earlier queued put of the same key makes this delete observe it. + if (!this.records.has(key)) return false + this.records.delete(key) + await this.host.unit.deleteRecord(this.tableName, key) + this.host.emitChanged({ + domain: this.host.domainName, + table: this.tableName, + key, + operation: 'deleted', + }) + return true + }) + } + + update(key: K, fn: (current: V) => V): Promise { + return this.host.enqueue(async () => { + if (!this.records.has(key)) { + throw new DomainError( + 'missing-key', + `domain '${this.host.domainName}' table '${this.tableName}' has no record '${key}' to update`, + ) + } + const next = fn(this.records.get(key) as V) + this.records.set(key, next) + await this.host.unit.putRecord(this.tableName, key, next) + this.emitPut(key, next) + return next + }) + } + + private emitPut(key: K, value: V): void { + this.host.emitChanged({ + domain: this.host.domainName, + table: this.tableName, + key, + operation: 'put', + value, + }) + } +} diff --git a/packages/storage/domain/src/error.ts b/packages/storage/domain/src/error.ts new file mode 100644 index 0000000000..15a76cd609 --- /dev/null +++ b/packages/storage/domain/src/error.ts @@ -0,0 +1,53 @@ +/** + * Error vocabulary of the domain data form. + * @module @deepseek-ai/dsh-domain/src/error + */ + +/** Discriminant codes carried by every {@link DomainError}. */ +export type DomainErrorCode = + | 'already-open' + | 'facet-unsupported' + | 'invalid-record' + | 'missing-key' + | 'closed' + +/** Location of the record that failed schema validation at the durable boundary. */ +export interface InvalidRecordDetail { + /** Table holding the rejected record; `''` for the global singleton. */ + readonly table: string + /** Key of the rejected record; `''` for the global singleton. */ + readonly key: string +} + +/** Construction options: standard `cause` plus the `invalid-record` location. */ +export interface DomainErrorOptions extends ErrorOptions { + /** Present exactly when `code` is `invalid-record`. */ + readonly detail?: InvalidRecordDetail +} + +/** + * Error thrown by the domain layer. The `code` is the stable contract + * consumers may switch on; `message` is diagnostic prose. Backend failures + * (`backend-not-found`, `version-mismatch`, …) pass through as + * `StorageError` — the domain layer does not rewrap them. + */ +export class DomainError extends Error { + override readonly name = 'DomainError' + + /** Present exactly when `code` is `invalid-record`. */ + readonly detail?: InvalidRecordDetail + + /** + * @param code - Stable discriminant for the failure class. + * @param message - Human-readable diagnostic detail. + * @param options - Standard error options plus the `invalid-record` location. + */ + constructor( + readonly code: DomainErrorCode, + message: string, + options?: DomainErrorOptions, + ) { + super(message, options) + if (options?.detail) this.detail = options.detail + } +} diff --git a/packages/storage/domain/src/events.ts b/packages/storage/domain/src/events.ts new file mode 100644 index 0000000000..abc5ae46d3 --- /dev/null +++ b/packages/storage/domain/src/events.ts @@ -0,0 +1,36 @@ +/** + * Change-event vocabulary of the domain data form. Every durable write emits + * one event after the backend resolves durability, carrying the new snapshot + * and an operation discriminant — never the old value (a diffing consumer + * keeps its own previous snapshot). This is the event source for cross-process + * change push (RPC frames) in a later phase. + * @module @deepseek-ai/dsh-domain/src/events + */ + +/** One durable domain change: a record upsert/delete or a global write. */ +export interface DomainChanged { + /** Owning domain name. */ + readonly domain: string + /** Table name; `''` for a global-singleton write. */ + readonly table: string + /** Record key; `''` for a global-singleton write. */ + readonly key: string + /** What happened: `put` covers insert and overwrite; `deleted` is a tombstone. */ + readonly operation: 'put' | 'deleted' + /** The new snapshot; absent for `deleted`. */ + readonly value?: unknown +} + +declare module 'cordis' { + interface Events { + /** + * A domain record or the global singleton changed, emitted once per write + * strictly after the backend acknowledged durability. Events of one + * domain arrive in its write-chain order. + * @param change - domain, table (`''` for global), key (`''` for global), + * operation discriminant, and the new snapshot (absent for deletions). + * @mode emit + */ + 'domain/changed'(change: DomainChanged): void + } +} diff --git a/packages/storage/domain/src/index.ts b/packages/storage/domain/src/index.ts new file mode 100644 index 0000000000..18eb2e7ed5 --- /dev/null +++ b/packages/storage/domain/src/index.ts @@ -0,0 +1,180 @@ +/** + * Domain data form (`ctx.storage.domain`): schema-validated, change-emitting + * KV domains over storage backends. The single implementation of the domain + * layer — consumers depend on this package and never touch backends directly. + * Plugin `Config` is schemastery; record schemas inside domain specs are zod + * (see `src/spec.ts` for the split rationale). + * @module @deepseek-ai/dsh-domain + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { DomainError } from './error.ts' +import { descriptorOf } from './spec.ts' +import type { DomainSpec } from './spec.ts' +import { DomainImpl } from './domain.ts' +import type { Domain } from './domain.ts' + +export { DomainError } from './error.ts' +export type { DomainErrorCode, DomainErrorOptions, InvalidRecordDetail } from './error.ts' +export { defineDomain, domainTable, descriptorOf } from './spec.ts' +export type { + DomainSpec, DomainGlobalSpec, DomainTableSpec, + TableKeyOf, TableValueOf, GlobalValueOf, +} from './spec.ts' +export type { DomainChanged } from './events.ts' +export type { Domain, DomainGlobal, DomainGlobalHandleOf, KvTable } from './domain.ts' + +declare module '@deepseek-ai/dsh-storage' { + interface StorageForms { + domain: DomainFacility + } +} + +/** Cordis plugin name. */ +export const name = 'domain' +/** The storage hub must be present before the form can mount. */ +export const inject = ['storage'] + +/** + * Plugin config. Which backend serves which domain is decided here, not + * globally on the hub: `backend` is the default route and `routes` overrides + * it per domain name. A route naming an unregistered backend fails loud at + * `open` with `backend-not-found`. + */ +export interface Config { + /** Default backend name for every domain without an explicit route. Required: there is no universally correct medium. */ + backend: string + /** Per-domain overrides: domain name → backend name. */ + routes?: Record +} + +export const Config: z = z.object({ + backend: z.string().required(), + routes: z.dict(z.string()).default({}), +}) + +/** + * The mounted domain facility. Opens declared domains over routed backends; + * one facility instance owns the open-domain table and enforces single-open + * per domain name. + */ +export class DomainFacility { + private readonly domains = new Map() + /** Names reserved by an in-flight or completed open, so concurrent opens of one name fail loud. */ + private readonly reserved = new Set() + + /** + * @param ctx - Context of the domain plugin; open-domain effects and change + * events attach here. + * @param config - Validated plugin config. + */ + constructor( + private readonly ctx: Context, + private readonly config: Config, + ) {} + + /** + * Open one declared domain. Steps, each failing the whole call: reject a + * name that is already open (`already-open`); resolve the backend route + * (`backend-not-found` passes through from the hub); require its `kv` facet + * (`facet-unsupported`); open the unit projected from the spec (backend + * `version-mismatch`/`malformed-medium` pass through); load and validate + * every stored record against the spec's zod schemas (`invalid-record` + * with the offending table and key); construct the domain and register its + * disposal effect (drain the write chain, close the unit). + * @param spec - The domain declaration, typically from `defineDomain`. + * @returns the opened domain handle, typed by the spec. + */ + async open(spec: S): Promise> { + if (this.reserved.has(spec.name)) { + throw new DomainError('already-open', `domain '${spec.name}' is already open`) + } + this.reserved.add(spec.name) + try { + const backendName = this.config.routes?.[spec.name] ?? this.config.backend + const backend = this.ctx.storage.backend.get(backendName) + if (!backend.kv) { + throw new DomainError( + 'facet-unsupported', + `backend '${backendName}' routed for domain '${spec.name}' has no kv facet`, + ) + } + const unit = await backend.kv.open(descriptorOf(spec)) + try { + const snapshot = await unit.loadAll() + const tables = new Map>() + for (const [table, tableSpec] of Object.entries(spec.tables)) { + const records = new Map() + for (const [key, raw] of Object.entries(snapshot.tables[table] ?? {})) { + records.set(key, parseRecord(spec.name, table, key, () => tableSpec.valueSchema.parse(raw))) + } + tables.set(table, records) + } + // A null stored global means "never written": serve `initial` without + // materializing it — the first `set` writes. + const globalValue = spec.global === undefined + ? undefined + : snapshot.global === null + ? 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) + this.ctx.effect(() => { + return async () => { + // Drain before unlisting: writes landing during the drain still + // emit domain/changed, and the domain must stay resolvable (the + // package invariant cross-checks each event) until fully closed. + await domain.dispose() + this.domains.delete(spec.name) + this.reserved.delete(spec.name) + } + }) + // The single type-erasure point: DomainImpl is the untyped runtime, + // Domain the spec-typed view; the unknown hop is required because + // S's conditional global-handle type stays unresolved here. + return domain as unknown as Domain + } catch (error) { + await unit.close() + throw error + } + } catch (error) { + if (!this.domains.has(spec.name)) this.reserved.delete(spec.name) + throw error + } + } + + /** + * Look up an open domain by name, untyped. Diagnostic surface (the package + * invariant cross-checks change events against live domain state); typed + * consumers hold the handle returned by {@link open}. + * @param name - Domain name. + * @returns the open domain runtime, or `undefined` when not open. + */ + get(name: string): DomainImpl | undefined { + return this.domains.get(name) + } +} + +/** Run one zod parse, translating failure to `invalid-record` with its location. */ +function parseRecord(domain: string, table: string, key: string, parse: () => T): T { + try { + return parse() + } catch (error) { + const slot = table === '' ? 'global' : `record '${key}' in table '${table}'` + throw new DomainError( + 'invalid-record', + `domain '${domain}': stored ${slot} does not match its schema`, + { detail: { table, key }, cause: error }, + ) + } +} + +/** + * Mount the domain data form on the storage hub. + * @param ctx - Plugin context. + * @param config - Validated plugin config. + */ +export function apply(ctx: Context, config: Config) { + ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config))) +} diff --git a/packages/storage/domain/src/invariant.ts b/packages/storage/domain/src/invariant.ts new file mode 100644 index 0000000000..f7da02e38b --- /dev/null +++ b/packages/storage/domain/src/invariant.ts @@ -0,0 +1,62 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-domain`: every + * `domain/changed` event must agree with the emitting domain's authoritative + * in-memory state (the owned event-stream ↔ mutable-data relationship of this + * package). Writes emit strictly after mutating memory and the write chain + * serializes them, so at emission time the event's snapshot equals the + * current read — any divergence means a write path skipped the chain or + * emitted a stale value. + * @module @deepseek-ai/dsh-domain/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { DomainChanged } from './events.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-domain' + +/** Cordis companion plugin name. */ +export const name = 'domain-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** Install the change-event ↔ memory-state agreement check. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + ctx.on('domain/changed', (change: DomainChanged) => { + const domain = ctx.storage.form('domain').get(change.domain) + if (domain === undefined) { + return fail(`domain/changed for '${change.domain}' emitted while that domain is not open`) + } + if (change.table === '') { + // Global write: the event snapshot must be the current global value. + if (domain.global.get() !== change.value) { + return fail(`domain/changed global value for '${change.domain}' differs from the in-memory global`) + } + return + } + const current = domain.table(change.table).get(change.key) + if (change.operation === 'deleted') { + if (current !== undefined) { + return fail( + `domain/changed deletion of '${change.domain}'.'${change.table}'['${change.key}'] ` + + 'emitted while the record is still in memory', + ) + } + return + } + if (current !== change.value) { + return fail( + `domain/changed value for '${change.domain}'.'${change.table}'['${change.key}'] ` + + 'differs from the in-memory record', + ) + } + }, { global: true }) +}, { inject: ['storage'] }) + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/storage/domain/src/spec.ts b/packages/storage/domain/src/spec.ts new file mode 100644 index 0000000000..783812174d --- /dev/null +++ b/packages/storage/domain/src/spec.ts @@ -0,0 +1,102 @@ +/** + * Domain declaration vocabulary. A spec object is the single source of a + * domain's identity, layout, and record schemas: the owning package defines + * it once with {@link defineDomain} and both the type surface and the runtime + * (validation, descriptor projection) derive from it. Record schemas are zod + * (`z.infer` keeps types un-duplicated and the same schemas later project to + * RPC wire schemas); plugin `Config` stays schemastery. + * @module @deepseek-ai/dsh-domain/src/spec + */ + +import type { ZodType } from 'zod' +import { UNIT_NAME_RE, type KvUnitDescriptor } from '@deepseek-ai/dsh-storage' + +/** Global singleton declaration: schema plus the value used before the first write. */ +export interface DomainGlobalSpec { + /** Validates the stored global at the durable boundary. */ + readonly schema: ZodType + /** Value served when the medium holds no global yet; not written until the first `set`. */ + readonly initial: G +} + +/** + * One table declaration. `K` is a phantom key type (typically a branded + * string) carried for compile-time projection only; keys are plain strings on + * the medium. + */ +export interface DomainTableSpec { + /** Validates every stored record at the durable boundary. */ + readonly valueSchema: ZodType + /** Phantom carrier for the key type; never present at runtime. */ + readonly __key?: K +} + +/** Static declaration of one domain: identity, version, and record layout. */ +export interface DomainSpec { + /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */ + readonly name: string + /** Domain format version; a medium stamped with a different version rejects at open. */ + readonly version: number + /** Optional global singleton slot. */ + readonly global?: DomainGlobalSpec + /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */ + readonly tables: Record +} + +/** Key type of one declared table, recovered from its phantom carrier. */ +export type TableKeyOf = + S['tables'][N] extends DomainTableSpec ? K : never + +/** Value type of one declared table. */ +export type TableValueOf = + S['tables'][N] extends DomainTableSpec ? V : never + +/** Global value type of a spec; `never` when the spec declares no global. */ +export type GlobalValueOf = + S['global'] extends DomainGlobalSpec ? G : never + +/** + * Declare one table. + * @param schema - zod schema validating every stored record of this table. + * @returns the table declaration, key-typed by `K`. + */ +export function domainTable(schema: ZodType): DomainTableSpec { + return { valueSchema: schema } +} + +/** + * Identity helper that pins a spec's literal types and validates its names. + * Misconfiguration fails loud: a domain or table name outside `UNIT_NAME_RE` + * or a version that is not a non-negative integer throws here, at the owning + * package's module load, before any medium is touched. + * @param spec - The domain declaration. + * @returns the same spec, narrowed to its literal type. + */ +export function defineDomain(spec: S): S { + if (!UNIT_NAME_RE.test(spec.name)) { + throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`) + } + if (!Number.isInteger(spec.version) || spec.version < 0) { + throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`) + } + for (const table of Object.keys(spec.tables)) { + if (!UNIT_NAME_RE.test(table)) { + throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`) + } + } + return spec +} + +/** + * Project a spec onto the backend-facing unit descriptor. + * @param spec - The domain declaration. + * @returns the descriptor handed to `KvFacet.open`. + */ +export function descriptorOf(spec: DomainSpec): KvUnitDescriptor { + return { + name: spec.name, + version: spec.version, + tables: Object.keys(spec.tables), + hasGlobal: spec.global !== undefined, + } +} diff --git a/packages/storage/domain/tests/domain.spec.ts b/packages/storage/domain/tests/domain.spec.ts new file mode 100644 index 0000000000..7d98337861 --- /dev/null +++ b/packages/storage/domain/tests/domain.spec.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { z } from 'zod' +import { apply as applyStorage } from '@deepseek-ai/dsh-storage' +import { DomainFacility, defineDomain, domainTable } from '../src/index.ts' +import type { Config } from '../src/index.ts' +import type { DomainChanged } from '../src/events.ts' +import { MemoryMediaPool, MemoryStorageBackend } from './helpers/memory-backend.ts' + +const itemSchema = z.object({ label: z.string(), count: z.number().int() }) +type Item = z.infer + +const settingsSchema = z.object({ theme: z.string() }) + +const spec = defineDomain({ + name: 'demo', + version: 1, + global: { schema: settingsSchema, initial: { theme: 'plain' } }, + tables: { items: domainTable(itemSchema) }, +}) + +const bareSpec = defineDomain({ + name: 'bare', + version: 1, + tables: { rows: domainTable(itemSchema) }, +}) + +/** Boot a context with the storage hub, one memory backend, and a facility over it. */ +async function harness(options?: { pool?: MemoryMediaPool; config?: Partial }) { + const ctx = new Context() + await ctx.plugin({ apply: applyStorage }) + const backend = new MemoryStorageBackend(options?.pool) + ctx.storage.backend.register('memory', backend) + const facility = new DomainFacility(ctx, { backend: 'memory', routes: {}, ...options?.config }) + // Mounted, not just constructed: the package invariant resolves the form + // through ctx.storage to cross-check every domain/changed emission. + ctx.storage.mount('domain', facility) + const changes: DomainChanged[] = [] + ctx.on('domain/changed', (change) => { changes.push(change) }) + return { ctx, backend, facility, changes } +} + +describe('defineDomain', () => { + it('rejects invalid names and versions loudly', () => { + expect(() => defineDomain({ name: 'Bad-Name', version: 1, tables: {} })).toThrow(/must match/) + expect(() => defineDomain({ name: 'ok', version: 1.5, tables: {} })).toThrow(/non-negative integer/) + expect(() => defineDomain({ + name: 'ok', version: 1, tables: { 'Bad Table': domainTable(itemSchema) }, + })).toThrow(/table name/) + }) +}) + +describe('DomainFacility.open', () => { + it('opens, reads back stored records, and rejects a second open of the same name', async () => { + const { facility } = await harness() + const domain = await facility.open(spec) + await domain.table('items').put('a', { label: 'first', count: 1 }) + await expect(facility.open(spec)).rejects.toMatchObject({ name: 'DomainError', code: 'already-open' }) + expect(domain.table('items').get('a')).toEqual({ label: 'first', count: 1 }) + }) + + it('routes per domain name and fails loud on an unregistered route target', async () => { + const { facility } = await harness({ config: { routes: { demo: 'nonexistent' } } }) + await expect(facility.open(spec)).rejects.toMatchObject({ + name: 'StorageError', + code: 'backend-not-found', + }) + // The failed open releases the name for a later attempt. + const { facility: healthy } = await harness() + await expect(healthy.open(spec)).resolves.toBeDefined() + }) + + it('rejects a backend without the kv facet', async () => { + const { ctx, facility } = await harness({ config: { backend: 'nokv' } }) + ctx.storage.backend.register('nokv', { close: async () => {} }) + await expect(facility.open(spec)).rejects.toMatchObject({ code: 'facet-unsupported' }) + }) + + it('rejects stored records that fail their schema, naming table and key', async () => { + const pool = new MemoryMediaPool() + { + const { facility } = await harness({ pool }) + await (await facility.open(spec)).table('items').put('bad', { label: 'x', count: 2 }) + } + pool.media.get('demo')!.tables.get('items')!.set('bad', { label: 'x', count: 'NaN' }) + const { facility } = await harness({ pool }) + await expect(facility.open(spec)).rejects.toMatchObject({ + code: 'invalid-record', + detail: { table: 'items', key: 'bad' }, + }) + }) + + it('rejects a stored global that fails its schema with the global marker', async () => { + const pool = new MemoryMediaPool() + pool.versions.set('demo', 1) + pool.media.set('demo', { tables: new Map(), global: { theme: 42 } }) + const { facility } = await harness({ pool }) + await expect(facility.open(spec)).rejects.toMatchObject({ + code: 'invalid-record', + detail: { table: '', key: '' }, + }) + }) + + it('passes through a backend version mismatch', async () => { + const pool = new MemoryMediaPool() + pool.versions.set('demo', 7) + const { facility } = await harness({ pool }) + await expect(facility.open(spec)).rejects.toMatchObject({ + name: 'StorageError', + code: 'version-mismatch', + }) + }) +}) + +describe('KvTable writes', () => { + it('serializes concurrent updates on one key without losing increments', async () => { + const { facility } = await harness() + const table = (await facility.open(spec)).table('items') + await table.put('counter', { label: 'c', count: 0 }) + await Promise.all(Array.from({ length: 50 }, () => + table.update('counter', (current) => ({ ...current, count: current.count + 1 })))) + expect(table.get('counter')).toEqual({ label: 'c', count: 50 }) + }) + + it('update rejects a missing key; delete reports prior existence', async () => { + const { facility } = await harness() + const table = (await facility.open(spec)).table('items') + await expect(table.update('ghost', (v) => v)).rejects.toMatchObject({ code: 'missing-key' }) + await table.put('a', { label: 'x', count: 1 }) + await expect(table.delete('a')).resolves.toBe(true) + await expect(table.delete('a')).resolves.toBe(false) + }) + + it('emits domain/changed per durable write, in order, with tombstones and global marker', async () => { + const { facility, changes } = await harness() + const domain = await facility.open(spec) + const table = domain.table('items') + await table.put('a', { label: 'x', count: 1 }) + await table.update('a', (current) => ({ ...current, count: 2 })) + await table.delete('a') + await table.delete('a') // no event: already absent + await domain.global.set({ theme: 'dark' }) + expect(changes).toEqual([ + { domain: 'demo', table: 'items', key: 'a', operation: 'put', value: { label: 'x', count: 1 } }, + { domain: 'demo', table: 'items', key: 'a', operation: 'put', value: { label: 'x', count: 2 } }, + { domain: 'demo', table: 'items', key: 'a', operation: 'deleted' }, + { domain: 'demo', table: '', key: '', operation: 'put', value: { theme: 'dark' } }, + ]) + }) +}) + +describe('global singleton', () => { + it('serves initial before first set without materializing, then persists the first set', async () => { + const pool = new MemoryMediaPool() + { + const { facility } = await harness({ pool }) + const domain = await facility.open(spec) + expect(domain.global.get()).toEqual({ theme: 'plain' }) + expect(pool.media.get('demo')!.global).toBeNull() // initial never touches the medium + await domain.global.set({ theme: 'dark' }) + expect(pool.media.get('demo')!.global).toEqual({ theme: 'dark' }) + } + const { facility } = await harness({ pool }) + expect((await facility.open(spec)).global.get()).toEqual({ theme: 'dark' }) + }) + + it('throws on access when the spec declares no global', async () => { + const { facility } = await harness() + const domain = await facility.open(bareSpec) + expect(() => (domain as { global: unknown }).global).toThrow(/declares no global/) + }) +}) + +describe('disposal', () => { + it('drains queued writes, closes the unit, then rejects reads and writes', async () => { + const pool = new MemoryMediaPool() + const { ctx, facility } = await harness({ pool }) + const domain = await facility.open(spec) + const table = domain.table('items') + const pending = Promise.all([ + table.put('a', { label: 'x', count: 1 }), + table.put('b', { label: 'y', count: 2 }), + ]) + await ctx.fiber.dispose() // effect disposer: drain chain, close unit + await pending // queued before dispose → still landed + // Durability is the drain contract: both queued writes reached the medium. + expect([...pool.media.get('demo')!.tables.get('items')!.keys()].sort()).toEqual(['a', 'b']) + await expect(table.put('c', { label: 'z', count: 3 })).rejects.toMatchObject({ code: 'closed' }) + expect(() => table.get('a')).toThrow(/closed/) + }) +}) diff --git a/packages/storage/domain/tests/helpers/memory-backend.ts b/packages/storage/domain/tests/helpers/memory-backend.ts new file mode 100644 index 0000000000..0c398d9ba1 --- /dev/null +++ b/packages/storage/domain/tests/helpers/memory-backend.ts @@ -0,0 +1,140 @@ +/** + * In-memory {@link StorageBackend} test double implementing the full KvUnit + * primitive set. Shared test infrastructure: the domain suite uses it to + * exercise open/route/write semantics without touching disk, and the + * workspace package's tests import it by relative path (it lives under + * `tests/`, never `src/`, so it stays out of the published surface). + * + * Fidelity to the backend contract (`dsh-storage` `src/backend.ts`): version + * stamping and `version-mismatch` on reopen, `malformed` never (memory cannot + * corrupt), per-call atomicity trivially, `closed` after close, delete + * idempotence. Media survive across backends through the shared `media` map + * passed into the constructor, which simulates process restarts; stamp + * `versions` directly to fabricate an on-medium version and force a + * `version-mismatch` without a prior open. + * @module + */ + +import { StorageError } from '@deepseek-ai/dsh-storage' +import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' + +/** One unit's medium: tables of records plus the global slot. */ +export interface MemoryMedium { + tables: Map> + global: unknown | null +} + +/** + * Shared media pool. Construct one and hand it to several + * {@link MemoryStorageBackend} instances to simulate reopening the same + * medium after a restart; `versions` holds the stamped unit versions and is + * writable by tests to inject a mismatching on-medium version. + */ +export class MemoryMediaPool { + /** Unit name → its records; a missing entry is a never-materialized unit. */ + readonly media = new Map() + /** Unit name → stamped version; tests may pre-stamp to force `version-mismatch`. */ + readonly versions = new Map() +} + +/** In-memory KV unit over one pooled medium. */ +class MemoryKvUnit implements KvUnit { + private closed = false + + constructor( + private readonly medium: MemoryMedium, + private readonly descriptor: KvUnitDescriptor, + private readonly onClose: () => void, + ) {} + + private assertOpen(): void { + if (this.closed) { + throw new StorageError('closed', `memory unit '${this.descriptor.name}' is closed`) + } + } + + async loadAll(): Promise<{ tables: Record>; global: unknown | null }> { + this.assertOpen() + const tables: Record> = {} + for (const table of this.descriptor.tables) { + tables[table] = Object.fromEntries(this.medium.tables.get(table) ?? []) + } + return { tables, global: this.medium.global } + } + + async putRecord(table: string, key: string, value: unknown): Promise { + this.assertOpen() + let records = this.medium.tables.get(table) + if (records === undefined) { + records = new Map() + this.medium.tables.set(table, records) + } + records.set(key, value) + } + + async deleteRecord(table: string, key: string): Promise { + this.assertOpen() + this.medium.tables.get(table)?.delete(key) + } + + async setGlobal(value: unknown): Promise { + this.assertOpen() + this.medium.global = value + } + + async close(): Promise { + if (this.closed) return + this.closed = true + this.onClose() + } +} + +/** + * In-memory storage backend with a `kv` facet. Pass a shared + * {@link MemoryMediaPool} to let a second instance reopen the same media; + * omit it for a throwaway isolated pool. + */ +export class MemoryStorageBackend implements StorageBackend { + readonly kv: KvFacet + private readonly openUnits = new Set() + private closed = false + + /** + * @param pool - Media shared across instances; a fresh private pool when omitted. + */ + constructor(readonly pool: MemoryMediaPool = new MemoryMediaPool()) { + this.kv = { + open: async (descriptor: KvUnitDescriptor): Promise => { + if (this.closed) { + throw new StorageError('closed', 'memory backend is closed') + } + // Double-open is a caller bug per the backend contract; no dedicated + // StorageError code exists for it, so a plain Error is correct. + if (this.openUnits.has(descriptor.name)) { + throw new Error(`memory unit '${descriptor.name}' is already open (double-open is a caller bug)`) + } + const stamped = this.pool.versions.get(descriptor.name) + if (stamped === undefined) { + this.pool.versions.set(descriptor.name, descriptor.version) + } else if (stamped !== descriptor.version) { + throw new StorageError( + 'version-mismatch', + `memory unit '${descriptor.name}' is stamped v${stamped}, descriptor wants v${descriptor.version}`, + ) + } + let medium = this.pool.media.get(descriptor.name) + if (medium === undefined) { + medium = { tables: new Map(), global: null } + this.pool.media.set(descriptor.name, medium) + } + this.openUnits.add(descriptor.name) + return new MemoryKvUnit(medium, descriptor, () => this.openUnits.delete(descriptor.name)) + }, + } + } + + async close(): Promise { + this.closed = true + this.openUnits.clear() + } +} diff --git a/packages/storage/domain/tsconfig.json b/packages/storage/domain/tsconfig.json new file mode 100644 index 0000000000..5a13b64de8 --- /dev/null +++ b/packages/storage/domain/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../storage" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index edd75466c5..b1890d548a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3167,6 +3167,66 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/storage/domain: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../storage + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/storage/storage: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/storage/storage-json: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../storage + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/storage/storage-sqlite: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../storage + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/subagent/subagent: devDependencies: '@deepseek-ai/dsh-agent': @@ -4273,6 +4333,34 @@ importers: specifier: ^4.19.2 version: 4.22.4 + packages/workspace/workspace: + dependencies: + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-domain': + specifier: workspace:^ + version: link:../../storage/domain + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session-persistence/session-persistence + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + python/sdk-runtime: dependencies: '@cordisjs/plugin-include':