mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge pull request #615 from deepseek-harness/worktree-wspace-storage
Storage hub, domain KV form, and the workspace entity
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-24-domain-kv-storage-and-workspace.md: cd666a47a3cba4dea8846cd0f1373224e6fc456f
|
||||
2026-07-24-domain-kv-storage-and-workspace.zh.md: 81adf1eb6bc32aa3ca8b9ef4c352fb94f95ace91
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-07-24-domain-kv-storage-and-workspace.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The host's only persistence surface is the session event log (`packages/session-persistence`: append-only, one file per session). Anything that does not belong to a single session has nowhere to live, and two real needs exist today:
|
||||
|
||||
- **The workspace entity.** The GUI needs workspace as a real object: path, title, and the list of owned sessions. Ownership belongs to the workspace — "which sessions belong to this workspace" is not any single session's fact, so writing it into the session log is semantically wrong. Until now workspace was only a sidebar visual grouping derived from cwd, with no entity (that conclusion has been overturned).
|
||||
- **Dynamic session metadata** (the foreseeable second consumer). Cold session listings read only the first log line (an immutable creation-time snapshot); title, terminal status, and anything that evolves with the session is unavailable. The fix direction is a sidecar metadata table — exactly a KV table with high-frequency per-key updates.
|
||||
|
||||
Separately, workspace deletion will eventually need to delete its owned sessions, and `SessionPersistence` has no delete primitive nor does the host expose a `session.delete` endpoint — that gap's design is settled in this note, but its implementation is marked future work: this phase touches no session-side code.
|
||||
|
||||
## Proposal
|
||||
|
||||
Create the `packages/storage/` group — the `ctx.storage` hub (backend registry + data-form mounts), two backends, the domain data form — plus the workspace consumer package; extend `SessionPersistence` with a delete primitive.
|
||||
|
||||
| Package | Path | ctx surface | This phase |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage` (the hub) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | registers backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | registers backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | mounts `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` extension + cascade orchestration | `packages/session-persistence/*` | new method on the existing seam | ✗ future work (session side untouched this phase) |
|
||||
| `workspace.*` / `session.delete` RPC, GUI wiring, boot assembly | — | — | ✗ next phase |
|
||||
|
||||
(workspace lives in its own group rather than `packages/host/`: the host group's naming rule requires the `dsh-host-*` prefix while this package is named `dsh-workspace`; and the workspace entity is a domain concept, not bound to the host assembly tier. Unrelated to the existing `workspace-context` package — that is an AGENTS.md instruction loader.)
|
||||
|
||||
Dependency direction: `dsh-workspace` → `dsh-domain` → `dsh-storage` ← the two backends. `dsh-workspace` additionally depends on the read-only face of `ctx.sessionPersistence` (attach's cwd check reads the session header; when the service is absent, attach rejects outright — no verification, no bookkeeping). The `ctx.sessions` running-check for session deletion moves into future work together with the cascade.
|
||||
|
||||
### `dsh-storage`: the storage hub
|
||||
|
||||
A pure registration hub, no IO of its own, no Config. The `Storage` service mounts at `ctx.storage` with two faces: `backend` (a `BackendRegistry`: `register(name, backend)` returns the disposer, duplicate names throw; `get(name)` throws `backend-not-found` for unknown names) and data-form mounting (`mount(form, facility)` over the merge-extensible `StorageForms` map, into which `dsh-domain` merges the `domain` key; unmounted access throws `form-not-mounted`). The signature text lives in `packages/storage/storage/src/index.ts` and `src/registry.ts`.
|
||||
|
||||
**Multiple backends stay mounted side by side**; which backend serves a domain is `dsh-domain`'s configuration (below), never a global either-or. Disposer semantics = remove the name from the table; closing the backend itself belongs to the backend package's effect closure, unregister first then close.
|
||||
|
||||
A backend is one **medium owner** (a file-tree root / one db file) exposing primitives through **data-shape facets** — only `kv` this phase; the session migration adds `log` (see the migration section). A facet is an optional member: absence means the backend cannot serve that shape, and resolution fails loud. The `kv` facet's primitive surface: `open(descriptor)` (descriptor = name/version/table list/global flag, with names and table names restricted to `^[a-z][a-z0-9_]*$` doubling as file-name and SQL-identifier segments) returns a unit exposing `loadAll` / `putRecord` / `deleteRecord` (missing key is a no-op) / `setGlobal` / `close` (idempotent); values are opaque JSON to the backend. The normative text (with per-method JSDoc) is `packages/storage/storage/src/backend.ts`.
|
||||
|
||||
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
|
||||
|
||||
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
|
||||
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
|
||||
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
|
||||
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
|
||||
5. `deleteRecord` is idempotent; `putRecord` overwrites.
|
||||
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
|
||||
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
|
||||
|
||||
The error vocabulary is `StorageError` with a code discriminant: `backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed` (`packages/storage/storage/src/error.ts`).
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config is `root` only (required, no default, schemastery); apply registers backend `json` inside `ctx.effect()`, and the disposer unregisters the name before `backend.close()`.
|
||||
|
||||
- Layout `<root>/<unitName>.json`, one file per unit; directory 0o700, files 0o600.
|
||||
- File format (version stamp in the header; the file is always the current net state, `JSON.stringify(…, null, 2)` human-readable — that legibility is this backend's reason to exist):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- Writes: every write primitive = full serialization of the in-memory state → temp write + fsync → atomic rename publish (the Windows variant follows session-persistence-jsonl's win32 path). Memory is authoritative, disk is its projection.
|
||||
- `loadAll`: parse the whole file at open; a missing `unit` header, non-object tables, etc. → `malformed-medium`. A missing file = an empty unit, materialized on first write.
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config is `path` (required, `':memory:'` allowed) plus `journalMode` (enum, default `wal`); apply mirrors json, registering backend `sqlite`.
|
||||
|
||||
- `node:sqlite` `DatabaseSync`; the open sequence follows session-persistence-sqlite: mkdir 0o700 → `open(path,'wx',0o600)` exclusive create when missing → `PRAGMA foreign_keys=ON` → journal_mode → version check → create tables.
|
||||
- Physical layout version `STORAGE_SQLITE_SCHEMA_VERSION = 1` in `PRAGMA user_version`: 0 → stamp; ≠ → `version-mismatch`.
|
||||
- DDL (all STRICT; table names concatenated from the restricted character set with the `u_` prefix, no external input ever reaches DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
|
||||
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
|
||||
|
||||
### `dsh-domain`: the domain data form
|
||||
|
||||
A single implementation, not abstracted; consumers depend on this layer only and never touch backends directly.
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(Facility unmount order: dispose each domain first (drain its write chain), then remove the name from the hub — in-flight writes still emit `domain/changed` during the drain, and the event-consistency invariant resolves domains back through the facility, so the name must stay resolvable at that point.)
|
||||
|
||||
Domain declarations (the spec object is defined and exported by the package that owns the domain — the single source of type and runtime truth; schemas use zod with `z.infer` deriving the types without re-declaration — the record model projects into RPC wire schemas next phase and the wire boundary is all zod; schemastery still owns plugin Config only):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` exact semantics (sequential; any failing step fails the whole open):
|
||||
|
||||
1. A domain with this name already open → `DomainError('already-open')`.
|
||||
2. Backend name = `config.routes[spec.name] ?? config.backend`; `ctx.storage.backend.get(name)` (an unmounted name propagates `backend-not-found` — misconfiguration fails loud).
|
||||
3. Backend lacks the `kv` facet → `DomainError('facet-unsupported')`.
|
||||
4. `kv.open(descriptorOf(spec))` (the descriptor is a direct projection of the spec).
|
||||
5. `loadAll()`; every record passes `valueSchema.parse`, the global passes its schema (null takes `initial`, not persisted — first write materializes). A failure → `DomainError('invalid-record', { table, key })` (the durable boundary must validate; the write side does not re-validate).
|
||||
6. Construct the `Domain` and register `ctx.effect()`: the disposer drains the write chain → `unit.close()`.
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
|
||||
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
|
||||
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
|
||||
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
|
||||
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
|
||||
|
||||
### Future work: session-side deletion (design settled, not implemented this phase)
|
||||
|
||||
This section is the settled construction spec; the implementation phase changes code only, not semantics. No session-persistence file is modified this phase.
|
||||
|
||||
```ts ignore-check
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL backend: unlink the session's file (including the `.zstd` variant); neither file nor intent → reject.
|
||||
- SQLite backend: one transaction `DELETE FROM events…; DELETE FROM sessions…`; zero rows hit and no intent → reject.
|
||||
- After a successful delete, emit `'session-persistence/deleted'(id: SessionId)` (`@mode emit`; the session-persistence event surface, unrelated to `domain/changed`). Derived data (the session-query full-text index and the like) subscribes and cleans itself; the persistence layer never reaches into indexes, and the crash window is covered by derived indexes being droppable-and-rebuildable.
|
||||
|
||||
Orchestration rules (implemented together with the cascade; the `session.delete` RPC and the workspace cascade reuse the same rules):
|
||||
|
||||
| Check (in order) | On failure |
|
||||
| --- | --- |
|
||||
| No target (the whole subtree when recursive) is running in `ctx.sessions` | throw, delete nothing; callers cancel first then delete — the persistence layer never reaches back into the runtime |
|
||||
| Non-recursive: the target has no descendants (descendants = the `parentSessionId` transitive closure, derived from `list()` headers) | throw: by default only leaves are deletable; `recursive: true` opts into recursion |
|
||||
| Recursive order is bottom-up (leaves → root) | — a mid-way crash leaves only "half the subtree deleted, ancestors intact"; re-running the same delete converges, and no dangling parent exists at any moment |
|
||||
| Some id in the cascade is already gone from disk | skip (idempotent resumption); any other error aborts |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
The package owns the `WorkspaceId` brand and exposes `ctx.workspace`. The record key is a generated uuid — path is not the key: normalization rewrites it, and reference anchors must be stable.
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
}
|
||||
```
|
||||
|
||||
- **Path canon**: the stored value = `fs.realpath(input)` (trailing slashes, `..`, and symlinks all resolved); uniqueness = string equality after normalization (a symlink resolving to the same directory counts as a collision). A missing directory makes create reject outright (realpath fails — a workspace must point at an existing directory; "Create new = make the directory" is upper-layer interaction: mkdir first, then create). The session cwd in attach checks follows the same canon. Single-valued cwd + unique path ⇒ one session structurally belongs to at most one workspace; double bookkeeping is impossible on the write side.
|
||||
- **Title**: a display name, defaults to `basename(path)`, mutable, duplicates allowed. Ownership is never derived from cwd as a fallback — cwd cannot express ordering, and ownership is a workspace-side fact; sessions started headless belong to no workspace.
|
||||
- Consumers see only the `Workspace` interface; `WorkspaceEntity` stays inside the package (a single implementation does not pre-split a seam). Entities are unique per id (registry cache); the record snapshot is swapped in place after each write, and the outside sees getters only. Every write funnels through the entity's internal `mutate(fn)` → `table.update`, with `updatedAt` refreshed inside mutate. Domain objects never cross RPC; next phase the wire layer projects records into zod wire schemas.
|
||||
- **Workspace deletion is future work as a whole** (settled 2026-07-24): the registry ships no delete method this phase — the half-measure "delete the record, keep the sessions" is not exposed; deletion and the session cascade (`recursive` parameter, running checks, bottom-up order, crash-rerun convergence) land as one complete semantic together with the session delete primitive; the order then is delete sessions one by one → prune the ledger → delete the workspace record.
|
||||
|
||||
Consistency doctrine (the ledger = the only ownership authority; the implementation and test baseline):
|
||||
|
||||
| Situation | Behavior |
|
||||
| --- | --- |
|
||||
| A ledger id has no session on disk | filtered at `list()`/entity projection; pruned by the next mutate; no error (a normal product of deletion crash-consistency) |
|
||||
| A session's cwd matches a workspace but is not in the ledger | not owned: no merging, no adoption. The GUI may later build an "orphan sessions" area (orphans = the complement of all ledgers) |
|
||||
| One session in two ledgers | structurally blocked on the write side (attach check); detected at load → throw (externally hand-edited data, never masked) |
|
||||
| The workspace directory does not exist | record and ledger stay; `status()` = `'missing-dir'`; the storage layer never auto-deletes (the directory may only be temporarily moved) |
|
||||
|
||||
### Reuse and the session-backend migration outlook
|
||||
|
||||
**Long-term direction**: the pure medium operations inside session-persistence's JSONL/SQLite backends sink into `dsh-storage` backends (the session packages stay; the `SessionPersistence` seam and coordinator semantics do not move — only the file/db operation layer beneath them does). The motive for reuse: the medium layer is all filesystem operations, database calls, and cross-platform grit (Windows permission and atomic-publish variants, fsync semantics, exclusive file creation…), which should be written once; business semantics (how a session appends, when, and what) stay above — while "did this append complete correctly underneath" (durability/atomicity/platform correctness) is the lower layer's responsibility, and the responsibility boundary is the facet primitive contract. The backend interface is therefore designed as **medium owner + data-shape facets**: a session log is an append-only stream, a different shape from KV — forcing them into one set of primitives would deform both, so facets split them (`kv` this phase, `log` at migration) while sharing the medium and its lifecycle.
|
||||
|
||||
The current reuse audit (an account already legible before the migration):
|
||||
|
||||
| Existing session-persistence logic | Nature | Disposition |
|
||||
| --- | --- | --- |
|
||||
| JSONL: temp write + fsync + link/unlink atomic publish, 0o700/0o600 permissions, Windows variant (win32.ts) | pure medium | copied by `dsh-storage-json` this phase (whole-file atomic rewrite is the same protocol); becomes the shared implementation at migration |
|
||||
| JSONL: line-append, first-line header fast read, zstd per-frame compression | log shape | stays put; moves into the `log` facet at migration |
|
||||
| SQLite: openDatabase (mkdir/exclusive create/PRAGMA sequence/user_version check) | pure medium | copied by `dsh-storage-sqlite` this phase — the two openDatabase copies are already near line-identical and this group is the third user; copy now, extract at migration |
|
||||
| SQLite: events/sessions schema, same-transaction materialization | log shape | stays put; moves into the `log` facet at migration |
|
||||
| coordinator (per-id write chain, lazy materialization, crash repair, flush barrier) | session semantics | never sinks — event-log domain logic whose counterpart here is the domain layer's write chain; each owns its own |
|
||||
| encodeSegment (id-to-path escaping) | medium utility | unused on the domain side (keys never reach paths); sinks together with the `log` facet (one file per session) at migration |
|
||||
|
||||
**This phase does not touch session-persistence's medium code** (only the delete primitive is added); the table above is the migration-phase work list and the design evidence that the backend interface must accommodate the log shape.
|
||||
|
||||
### Test matrix
|
||||
|
||||
| Suite | Coverage | Backends |
|
||||
| --- | --- | --- |
|
||||
| backend contract (shared suite, written once, run on both) | the seven contract clauses + version rejection + close idempotence | json, sqlite (`:memory:` + temp dirs) |
|
||||
| registry/mount | duplicate registration, unmounted access, disposer removal | — |
|
||||
| domain layer | the six open steps, schema rejection, update serialization (concurrent interleaving stress), `domain/changed` per record, global initial-value lazy materialization, routing and `facet-unsupported` | either (json) |
|
||||
| workspace | create/uniqueness/realpath, attach checks (including rejection when sessionPersistence is absent), the four consistency-doctrine cases | mock domain or json |
|
||||
| session delete contract (future work, joins runPersistenceContract at implementation) | unknown id, deleted-id reuse, un-materialized intent, serialization with in-flight appends, the deleted event | jsonl, sqlite |
|
||||
|
||||
Snapshots: no model-visible or assembly surface this phase, none added; next phase's RPC wiring brings them with the `workspace.*` domain.
|
||||
|
||||
### Out-of-scope list
|
||||
|
||||
| Not doing | Trigger | Rework point | Groundwork |
|
||||
| --- | --- | --- | --- |
|
||||
| The full deletion suite (`SessionPersistence.delete`, the deleted event, `registry.delete` cascade, recursive delete, running checks) | future work starts (before the GUI needs delete interactions) | implement per the future-work section above: the session primitive + `registry.delete(id, { recursive? })` land as one | orchestration rules and rejection table settled in this note; no deletion entry exists this phase, so no half-semantics to stay compatible with |
|
||||
| The `log` facet and the session-backend migration | any phase after this one | sink the medium operations (the reuse audit table is the work list) | the facet structure is in place; both backends' medium code is organized in sinkable shape already |
|
||||
| Multi-process write protection | two host processes writing one medium | JSON backend file locks; SQLite WAL is natively multi-process | all writes already funnel through the domain's single point; locking touches backends only |
|
||||
| Cross-process change observation | GUI reconnect awareness | the revision pattern (copy session-persistence) | `domain/changed` already exists in-process |
|
||||
| Data migration | model changes after the first tagged release | version-driven per-domain migration | versions are on the medium from day one |
|
||||
| Large-table performance | a thousand-record domain routed to json | point `routes` at sqlite, migrate the data by hand once | routing is configuration; consumers unchanged |
|
||||
| Multi-segment keys | a real two-segment consumer appears (per-workspace per-session dimension data) | key generics become tuples, SQLite composite primary keys, JSON nested levels | single-level tables are the one-segment special case; no arbitrary-depth nesting; no string-concatenated keys |
|
||||
| The scope dimension | a "one per workspace" domain appears and composite keys cannot express it | DomainSpec gains a scope declaration + a scope segment in file names (encodeSegment) | the name character set is already restricted; file names cannot collide |
|
||||
| Cross-table atomic transactions | one business operation touching two tables of one domain atomically | `domain.transact(fn)`; JSON whole-unit rewrite is naturally atomic, SQLite wraps a transaction | — |
|
||||
| Secondary indexes / conditional queries | in-memory filtering stops scaling (tens of thousands of records) | SQLite JSON1 over the value column, a read-only query facet on the seam | the JSON backend does not follow |
|
||||
| Moving a session across workspaces | a product need appears | relax the attach check into a "detach first, then attach" orchestration | — |
|
||||
| RPC/GUI/boot | next phase | `workspace.*` + `session.delete` endpoints, wire schemas, boot mounting, sidebar on real data | this phase's model and semantics are the direct source of the wire projection |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Reusing session-persistence's coordinator/backends**: event-log semantics (append-only, turn crash repair, lazy materialization) do not match KV overwrite semantics; only the layering idea is borrowed (a coordination layer owns write ordering, backends implement minimal primitives).
|
||||
- **A workspace-specific storage package, seam extracted later**: the second consumer (the session sidecar) is already foreseeable; generalizing later means touching the interface twice.
|
||||
- **Merging domain and storage into one layer**: backends would be forced to touch schema validation, change events, and write serialization — domain concerns; split apart, storage backends implement only opaque primitives (the smallest replaceable surface) while the single domain implementation concentrates all domain logic (zod/events/serialization written once, not doubled per backend).
|
||||
- **JSON backend as jsonl append + tombstones + compaction**: temp+fsync+rename crash safety is equivalent to append; rewriting keeps the file the net current state, human-readable, with no folding/compaction/torn-line tolerance; at domain scale a full rewrite costs the same as appending a line.
|
||||
- **JSON one file per table**: under whole-file rewrites the file granularity does not affect write cost; merging per domain means fewer files and gives the global singleton a home.
|
||||
- **SQLite storing a whole domain as one blob row**: any single-record change rewrites the whole domain, forfeiting per-key precise updates — SQLite's only edge over JSON reduced to zero.
|
||||
- **SQLite generating typed columns from the schema**: a DDL generator is over-engineering; document-per-row suffices, revisit when real query needs appear.
|
||||
- **One sqlite db file per domain**: contrary to the repository's one-database-many-tables convention.
|
||||
- **A single whole-store backend choice (the session-persistence single-slot pattern)**: the initial design; changed to coexisting backends + configured routing because the hub will carry multiple data forms whose backend preferences (human-readable vs high-frequency point updates) are bound to diverge — a single slot forces the coarse "swap everything + hand-migrate data" move. The cost is one extra name lookup, backed by fail-loud.
|
||||
- **path as the workspace key**: normalization/symlink resolution rewrites the path; reference anchors must be stable.
|
||||
- **Ownership derived from cwd (or merged with the ledger)**: two sources of truth; cwd cannot express ordering; ownership is a workspace-side fact to begin with.
|
||||
- **Change events carrying the old value**: the repository's change-event convention is "new snapshot + operation discriminant" (the sole exception, fs's before/after, is a method return value rather than an event, because the old value is unrecoverable afterwards and has a diff consumer); consumers needing diffs hold their own previous snapshot.
|
||||
- **Delete auto-cancelling a running session**: the persistence/orchestration layer reaching back into the runtime dirties the layering; cancel already exists, callers compose it.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- This phase's four test suites all green: the shared backend contract suite on both json/sqlite, registry/mount disposer semantics, the domain layer (including the six open steps and fail-loud routing), and full workspace semantics (create/attach checks/consistency doctrine).
|
||||
- `ctx.workspace` completes the create → attach → list lifecycle under a test assembly (deletion is future work).
|
||||
- Zero diff in the session-persistence packages (the acceptance line for not touching the session side this phase).
|
||||
- No new snapshots this phase (no model-visible or assembly surface); added next phase with the RPC wiring.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The repository's first push-mode change event on a persistence surface** (session-persistence polls revisions): the shape has the `goal/changed` template, but "the storage layer emits events" is a new precedent, validated only when next phase's RPC consumes it.
|
||||
- **The JSON backend's whole-unit rewrite scale premise**: if the second consumer (the session sidecar) lands on the JSON backend at thousand-record scale before being routed to SQLite, the rewrite cost surfaces earlier than expected; the mitigation is exactly `routes` pointing at sqlite.
|
||||
- **The deletion orchestration's weak dependency on `ctx.sessions`**: a headless assembly without the runtime registry treats it as "no hot sessions", leaving a window (an external process running the session); multi-process is already out of scope, accepted.
|
||||
- **Facet generalization designed against the future `log` facet without implementing it this phase**: a "reserved shape does not fit" risk; mitigated by organizing both backends' medium code in the sinkable shape from the reuse audit, so when the `log` facet lands only the facet layer moves.
|
||||
@@ -0,0 +1,329 @@
|
||||
# Agent Note: Domain KV storage capability seam and the workspace entity
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-07-24-domain-kv-storage-and-workspace.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
host 侧唯一的持久化面是 session 事件日志(`packages/session-persistence`:append-only、一 session 一文件)。凡是"不属于某个 session"的信息就没有落盘处,眼下有两个真实需求:
|
||||
|
||||
- **workspace 实体**。GUI 要把 workspace 做成真实对象:路径、标题、关联 session 清单。归属关系由 workspace 持有——"哪些 session 属于这个 workspace"不是任何单个 session 自己的事实,塞进 session log 语义不成立。此前 workspace 只是 sidebar 上按 cwd 分组的视觉概念,没有实体(该结论已被推翻)。
|
||||
- **session 动态元信息**(可预见的第二个消费者)。冷会话列表只读日志首行 header(创建时的不可变快照),title、结束状态这类随会话推进变化的信息拿不到;补齐方向是 sidecar 元数据表——正是一张按 key 高频点更新的 KV 表。
|
||||
|
||||
另外,workspace 删除最终需要删除其关联 session,而 `SessionPersistence` 没有删除原语,host 也没有 `session.delete` 端点——该空白的设计随本 Note 定案,但实施标记为 future work:本期不动 session 侧任何代码。
|
||||
|
||||
## Proposal
|
||||
|
||||
新建 `packages/storage/` 组——`ctx.storage` 存储枢纽(后端注册面 + 数据形式挂载面)、两个后端、domain 领域数据形式——及 workspace 消费者包;给 `SessionPersistence` 扩删除原语。
|
||||
|
||||
| 包 | 路径 | ctx 面 | 本期 |
|
||||
| --- | --- | --- | --- |
|
||||
| `@deepseek-ai/dsh-storage` | `packages/storage/storage/` | `ctx.storage`(枢纽) | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-json` | `packages/storage/storage-json/` | 注册 backend `json` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-sqlite` | `packages/storage/storage-sqlite/` | 注册 backend `sqlite` | ✓ |
|
||||
| `@deepseek-ai/dsh-storage-domain` | `packages/storage/storage-domain/` | 挂载 `ctx.storage.domain` | ✓ |
|
||||
| `@deepseek-ai/dsh-workspace` | `packages/workspace/workspace/` | `ctx.workspace` | ✓ |
|
||||
| `SessionPersistence.delete` 扩面 + 级联删编排 | `packages/session-persistence/*` | 既有 seam 新方法 | ✗ future work(本期不动 session 侧) |
|
||||
| `workspace.*` / `session.delete` RPC、GUI 接线、boot 组装 | — | — | ✗ 下期 |
|
||||
|
||||
(workspace 放独立组不放 `packages/host/`:host 组命名规则要求 `dsh-host-*` 前缀,而包名定为 `dsh-workspace`;且 workspace 实体是领域概念,不绑定 host 装配层。与既有 `workspace-context` 包无关——那是 AGENTS.md 指令加载器。)
|
||||
|
||||
依赖方向:`dsh-workspace` → `dsh-domain` → `dsh-storage` ← 两后端。`dsh-workspace` 另依赖 `ctx.sessionPersistence` 的只读面(attach 的 cwd 校验读 session header;服务缺席时 attach 直接拒绝——无法校验即不写账)。session 删除相关的 `ctx.sessions` 运行中检查随级联删一并归入 future work。
|
||||
|
||||
### `dsh-storage`:存储枢纽
|
||||
|
||||
纯注册枢纽,自身不做 IO,无 Config。`Storage` service 挂 `ctx.storage`,两个面:`backend`(`BackendRegistry`:`register(name, backend)` 返回 disposer、重名 throw;`get(name)` 未知名 throw `backend-not-found`)与数据形式挂载(`mount(form, facility)` 配 merge-extensible 的 `StorageForms` map,`dsh-domain` merge 进 `domain` 键;未挂载访问 throw `form-not-mounted`)。签名正文见 `packages/storage/storage/src/index.ts` 与 `src/registry.ts`。
|
||||
|
||||
**多后端同时挂载**;域→后端的选择是 `dsh-domain` 的配置(见下),不是全局二选一。disposer 语义 = 从表中摘名;后端自身的 close 由后端包的 effect 闭包负责,顺序先摘名后 close。
|
||||
|
||||
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`;session 迁移期加 `log`(见迁移节)。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud。`kv` facet 的原语面:`open(descriptor)`(descriptor = 名字/版本/表名清单/有无 global,名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unit,unit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op)/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文(含逐方法 JSDoc)在 `packages/storage/storage/src/backend.ts`。
|
||||
|
||||
backend 契约(共享契约测试逐条断言,两后端同套件):
|
||||
|
||||
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
|
||||
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
|
||||
3. 持久性:写原语 resolve 后进程崩溃再 open,`loadAll` 必须反映该写入。
|
||||
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;后端只保证单次调用原子(JSON 整文件替换 / SQLite 单语句)。
|
||||
5. `deleteRecord` 幂等;`putRecord` 覆写。
|
||||
6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。
|
||||
7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。
|
||||
|
||||
错误词汇是带 code 判别的 `StorageError`,码表:`backend-not-found` / `form-not-mounted` / `duplicate-backend` / `duplicate-mount` / `version-mismatch` / `malformed-medium` / `closed`(`packages/storage/storage/src/error.ts`)。
|
||||
|
||||
### `dsh-storage-json`
|
||||
|
||||
Config 仅 `root`(必填无默认,schemastery);apply 在 `ctx.effect()` 里注册后端 `json`,disposer 先摘名再 `backend.close()`。
|
||||
|
||||
- 布局 `<root>/<unitName>.json`,一 unit 一文件;目录 0o700、文件 0o600。
|
||||
- 文件格式(版本戳在头,文件即当前净值,`JSON.stringify(…, null, 2)` 肉眼可读——这是该后端的存在理由):
|
||||
|
||||
```json
|
||||
{
|
||||
"unit": { "name": "workspace", "version": 1 },
|
||||
"global": null,
|
||||
"tables": { "workspaces": { "<key>": {} } }
|
||||
}
|
||||
```
|
||||
|
||||
- 写入:任何一次写原语 = 内存态全量序列化 → temp 写 + fsync → rename 原子发布(Windows 变体照抄 session-persistence-jsonl 的 win32 路径)。内存态是权威,盘是投影。
|
||||
- `loadAll`:open 时整文件 parse;缺 `unit` 头、tables 非对象等 → `malformed-medium`。文件不存在 = 空单元,首写才落盘。
|
||||
|
||||
### `dsh-storage-sqlite`
|
||||
|
||||
Config 为 `path`(必填,`':memory:'` 允许)+ `journalMode`(枚举,默认 `wal`);apply 同 json,注册后端 `sqlite`。
|
||||
|
||||
- `node:sqlite` `DatabaseSync`;打开序列照抄 session-persistence-sqlite:mkdir 0o700 → 不存在则 `open(path,'wx',0o600)` 独占建文件 → `PRAGMA foreign_keys=ON` → journal_mode → 版本检查 → 建表。
|
||||
- 物理布局版本 `STORAGE_SQLITE_SCHEMA_VERSION = 1` 存 `PRAGMA user_version`:0 → 盖章;≠ → `version-mismatch`。
|
||||
- DDL(全 STRICT;表名由受限字符集拼接加 `u_` 前缀,杜绝外部输入进 DDL):
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS units (name TEXT PRIMARY KEY, version INTEGER NOT NULL) STRICT;
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name), value TEXT NOT NULL) STRICT;
|
||||
-- 每 unit 每表:
|
||||
CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
|
||||
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = 记录 JSON 文档
|
||||
```
|
||||
|
||||
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
|
||||
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
|
||||
|
||||
### `dsh-domain`:领域数据形式
|
||||
|
||||
单实现不抽象;消费者只依赖这层,不直接触后端。
|
||||
|
||||
```ts ignore-check
|
||||
export const Config = z.object({
|
||||
backend: z.string().required(), // 默认后端名,必填
|
||||
routes: z.dict(z.string()).default({}), // per-domain 覆盖:{ workspace: 'sqlite' }
|
||||
})
|
||||
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
ctx.effect(() => ctx.storage.mount('domain', new DomainFacility(ctx, config)))
|
||||
}
|
||||
```
|
||||
|
||||
(facility 卸载顺序:先 dispose 各域(排空写链)再从枢纽摘名——排空期间在途写仍发 `domain/changed`,事件一致性 invariant 经 facility 反查域,要求此时域名仍可解析。)
|
||||
|
||||
域声明(spec 对象由拥有该域的包定义导出,是类型与运行时的单一来源;schema 用 zod,`z.infer` 推导类型不重复声明——记录模型下期要投影成 RPC wire schema,wire 边界全是 zod;schemastery 仍只管插件 Config):
|
||||
|
||||
```ts ignore-check
|
||||
export interface DomainGlobalSpec<G> { readonly schema: ZodType<G>; readonly initial: G }
|
||||
export interface DomainTableSpec<K extends string, V> { readonly valueSchema: ZodType<V> }
|
||||
|
||||
export interface DomainSpec {
|
||||
readonly name: string // ^[a-z][a-z0-9_]*$
|
||||
readonly version: number
|
||||
readonly global?: DomainGlobalSpec<unknown>
|
||||
readonly tables: Record<string, DomainTableSpec<string, unknown>>
|
||||
}
|
||||
|
||||
export function defineDomain<S extends DomainSpec>(spec: S): S
|
||||
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V>
|
||||
```
|
||||
|
||||
`DomainFacility.open(spec)` 精确语义(顺序执行,任一步失败即整体失败):
|
||||
|
||||
1. 同名域已打开 → `DomainError('already-open')`。
|
||||
2. 后端名 = `config.routes[spec.name] ?? config.backend`;`ctx.storage.backend.get(name)`(未挂载穿透 `backend-not-found`——misconfiguration fails loud)。
|
||||
3. 后端无 `kv` facet → `DomainError('facet-unsupported')`。
|
||||
4. `kv.open(descriptorOf(spec))`(descriptor 由 spec 直接投影)。
|
||||
5. `loadAll()`;每条记录 `valueSchema.parse`,global 过 schema(null 取 `initial`,不落盘,首写才落盘)。失败 → `DomainError('invalid-record', { table, key })`(durable 边界必须校验;写侧不重复校验)。
|
||||
6. 构造 `Domain` 并注册 `ctx.effect()`:disposer 排空写链 → `unit.close()`。
|
||||
|
||||
```ts ignore-check
|
||||
export interface Domain</* 由 spec 推导 */> {
|
||||
readonly name: string
|
||||
readonly global: { get(): G; set(value: G): Promise<void> } // 仅当 spec.global 声明
|
||||
table<N extends keyof S['tables']>(name: N): KvTable<KeyOf<N>, ValueOf<N>>
|
||||
}
|
||||
|
||||
export interface KvTable<K extends string, V> {
|
||||
get(key: K): V | undefined // 内存快照,同步
|
||||
entries(): IterableIterator<[K, V]>
|
||||
keys(): IterableIterator<K>
|
||||
readonly size: number
|
||||
put(key: K, value: V): Promise<void>
|
||||
delete(key: K): Promise<boolean> // false = 本就不存在
|
||||
/** Atomic read-modify-write on the domain's single write chain; fn is sync-pure. */
|
||||
update(key: K, fn: (current: V) => V): Promise<V> // 缺 key → DomainError('missing-key')
|
||||
}
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
|
||||
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
|
||||
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
|
||||
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
|
||||
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。
|
||||
|
||||
### Future work:session 侧删除(设计定案,本期不实施)
|
||||
|
||||
本节是定案的施工规范,实施期不动语义只动代码;本期 session-persistence 的任何文件都不修改。
|
||||
|
||||
```ts ignore-check
|
||||
export abstract class SessionPersistence extends Service {
|
||||
/**
|
||||
* Permanently delete one session's stored log.
|
||||
* Queued on the per-id write chain (serialized with in-flight appends).
|
||||
* Unknown id → reject; un-materialized create intent → cancel it and resolve.
|
||||
* After deletion the id behaves as unknown for every subsequent operation.
|
||||
*/
|
||||
abstract delete(id: SessionId): Promise<void>
|
||||
}
|
||||
```
|
||||
|
||||
- JSONL 后端:unlink 该 session 文件(含 `.zstd` 变体);文件与 intent 均无 → reject。
|
||||
- SQLite 后端:单事务 `DELETE FROM events…; DELETE FROM sessions…`;0 行命中且无 intent → reject。
|
||||
- 删除成功后 emit `'session-persistence/deleted'(id: SessionId)`(`@mode emit`;session-persistence 层事件面,与 `domain/changed` 无关)。派生数据(session-query 全文索引等)订阅自清;持久层不直连索引,崩溃窗口靠派生索引可丢弃重建兜底。
|
||||
|
||||
编排层规则(随级联删一起实施;`session.delete` RPC 与 workspace 级联复用同一规则):
|
||||
|
||||
| 检查(按序) | 不满足时 |
|
||||
| --- | --- |
|
||||
| 目标(递归时含整棵子树)无一在 `ctx.sessions` 运行 | throw,什么都不删;调用方先 cancel 再删,持久层不反向牵动运行时 |
|
||||
| 非递归时目标无后代(后代 = `parentSessionId` 传递闭包,由 `list()` header 求得) | throw:默认只能删叶子,`recursive: true` 显式递归 |
|
||||
| 递归序自底向上(叶→根) | ——中途崩溃只留"子树删一半、祖先在",重跑收敛,任何时刻无悬空 parent |
|
||||
| 级联中某 id 已不在盘上 | 跳过(幂等续删);其余错误中止 |
|
||||
|
||||
### `dsh-workspace`
|
||||
|
||||
包拥有 `WorkspaceId` brand,暴露 `ctx.workspace`。记录 key 为生成的 uuid——path 不做 key:规范化会改写它,引用锚点必须稳定。
|
||||
|
||||
```ts ignore-check
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
export function WorkspaceId(id: string): WorkspaceId
|
||||
|
||||
const workspaceRecord = z.object({
|
||||
path: z.string(), // realpath,见下
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(), // ISO
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace', version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
|
||||
declare module 'cordis' { interface Context { workspace: WorkspaceRegistry } }
|
||||
|
||||
export interface Workspace {
|
||||
readonly id: WorkspaceId
|
||||
readonly path: string
|
||||
readonly title: string
|
||||
readonly sessionIds: readonly SessionId[] // 唯一真相且有序:数组序即展示序
|
||||
setTitle(title: string): Promise<void>
|
||||
/** Record a session under this workspace (idempotent). Rejects when the session
|
||||
* header's cwd (realpath) differs from this workspace's path. */
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
/** Live directory check, uncached. */
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
|
||||
export class WorkspaceRegistry extends Service {
|
||||
constructor(ctx: Context) // super(ctx, 'workspace')
|
||||
// start(): this.domain = await ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// 实体缓存 Map<WorkspaceId, WorkspaceEntity> 重建
|
||||
create(path: string, title?: string): Promise<Workspace> // realpath 后撞已有 → reject
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
list(): Workspace[]
|
||||
resolveByPath(path: string): Promise<Workspace | undefined> // 同 realpath 口径,故 async
|
||||
// delete:future work(与 session 级联删一起做,见下);本期不提供任何删除入口
|
||||
}
|
||||
```
|
||||
|
||||
- **path 规范**:落盘值 = `fs.realpath(输入)`(尾斜杠、`..`、符号链接全解析);唯一性 = 规范化后字符串相等(符号链接指向同一目录算撞)。目录不存在时 create 直接 reject(realpath 失败——workspace 必须指向存在目录;"Create new = 建目录"是上层交互,先 mkdir 再 create)。attach 校验的 session cwd 同口径。cwd 单值 + path 唯一 ⇒ 一个 session 结构上最多归属一个 workspace,双重记账写侧不可能。
|
||||
- **title**:显示名,默认 `basename(path)`,可改,允许重复。归属不用 cwd 派生兜底——cwd 表达不了排序,归属是 workspace 侧事实;headless 直开的 session 不属于任何 workspace。
|
||||
- 消费者只见 `Workspace` 接口,`WorkspaceEntity` 不出包(单实现不预拆 seam);实体按 id 唯一(registry 缓存),记录快照写后原地换新,外部只见 getter;所有写收敛到实体内 `mutate(fn)` → `table.update`,`updatedAt` 在 mutate 内统一刷。领域对象不过 RPC,下期 wire 层把记录投影成 zod wire schema。
|
||||
- **workspace 删除整体为 future work**(2026-07-24 拍板):本期 registry 不提供 delete 方法——半截的"只删记录留 session"语义不对外暴露,删除与 session 级联(`recursive` 参数、运行中检查、自底向上、崩溃重跑收敛)作为一个完整语义随 session 删除原语一起落地;届时顺序为逐个删 session → 摘账 → 删记录。
|
||||
|
||||
一致性口径(账 = 归属唯一依据;实现与测试基准):
|
||||
|
||||
| 情形 | 行为 |
|
||||
| --- | --- |
|
||||
| 账中 id 盘上无 session | `list()`/实体投影时过滤;下次任何 mutate 顺手摘除;不报错(删除崩溃一致性的正常产物) |
|
||||
| session cwd 匹配某 workspace 但未上账 | 不属于:不合并不收编。GUI 将来可做"游离 session"专区(游离 = 全部账的补集) |
|
||||
| 同一 session 上两本账 | 写侧结构性堵死(attach 校验);load 检出 → throw(外部手改数据,不掩盖) |
|
||||
| workspace 目录不存在 | 记录与账保留,`status()` = `'missing-dir'`;存储层不自动删(目录可能只是暂时挪走) |
|
||||
|
||||
### 复用与 session 后端迁移展望
|
||||
|
||||
**长期方向**:session-persistence 的 JSONL/SQLite 后端里"纯介质操作"下沉到 `dsh-storage` 后端(session 包不删,`SessionPersistence` seam 与 coordinator 语义不动;动的只是它们脚下的文件/db 操作层)。复用的动机:介质层全是文件系统操作、数据库调用与跨平台兼容的脏活(Windows 权限与原子发布变体、fsync 语义、独占建文件……),这些只应写一遍;业务语义(session 怎么 append、何时 append、append 什么)留在上层——而"底下这次 append 是否正常完成"(持久性/原子性/平台正确性)是底层的责任,责任界面就是 facet 原语的契约。为此后端接口按**介质 owner + 数据形状 facet** 设计:session 日志是 append-only 流,与 KV 形状不同——强行统一进 KV 原语会两头变形,所以按 facet 分开(`kv` 本期、`log` 迁移期),介质与生命周期共享。
|
||||
|
||||
现状复用审计(迁移前就能看清的账):
|
||||
|
||||
| session-persistence 现有逻辑 | 归属 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| JSONL:temp 写 + fsync + link/unlink 原子发布、0o700/0o600 权限、Windows 变体(win32.ts) | 纯介质 | 本期 `dsh-storage-json` 直接抄用(整文件原子覆写正是同一套);迁移期成为共享实现 |
|
||||
| JSONL:逐行 append、首行 header 快读、zstd 逐帧压缩 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| SQLite:openDatabase(mkdir/独占建文件/PRAGMA 序列/user_version 检查) | 纯介质 | 本期 `dsh-storage-sqlite` 抄用——两处 openDatabase 已几乎逐行同构,本组是第三个使用者;先抄后提,提取放迁移期 |
|
||||
| SQLite:events/sessions 表结构、同事务物化 | log 形状 | 留在原地;迁移期进 `log` facet |
|
||||
| coordinator(per-id 写链、懒物化、崩溃修复、flush 屏障) | session 语义 | 永不下沉——事件日志的领域逻辑,对应物在 domain 层(写串行链),各归各 |
|
||||
| encodeSegment(id 进路径转义) | 介质工具 | domain 侧 key 不进路径用不到;`log` facet(一 session 一文件)迁移时随之下沉 |
|
||||
|
||||
**本期不改 session-persistence 的介质代码**(只加 delete 原语);上表是迁移期的施工清单,也是后端接口"必须装得下 log 形状"的设计依据。
|
||||
|
||||
### 测试矩阵
|
||||
|
||||
| 套件 | 覆盖 | 后端 |
|
||||
| --- | --- | --- |
|
||||
| backend 契约(共享套件,一次编写两端跑) | 七条契约 + 版本拒绝 + close 幂等 | json、sqlite(`:memory:` + 临时目录) |
|
||||
| registry/mount | 重复注册、未挂载访问、disposer 摘除 | — |
|
||||
| domain 层 | open 六步语义、schema 拒绝、update 串行(并发交错压测)、`domain/changed` 逐条、global 初值懒物化、路由与 `facet-unsupported` | 任一(json) |
|
||||
| workspace | create/唯一性/realpath、attach 校验(含 sessionPersistence 缺席拒绝)、一致性口径四情形 | mock domain 或 json |
|
||||
| session delete 契约(future work,随实施并入 runPersistenceContract) | 未知 id、已删 id 复用、未物化 intent、与在途 append 串行、deleted 事件 | jsonl、sqlite |
|
||||
|
||||
快照:本期无模型可见面与组装面,不新增;下期 RPC 接线时随 `workspace.*` 域补。
|
||||
|
||||
### 不做清单
|
||||
|
||||
| 不做 | 触发条件 | 返工点 | 预埋 |
|
||||
| --- | --- | --- | --- |
|
||||
| 删除全套(`SessionPersistence.delete`、deleted 事件、`registry.delete` 级联、递归删、运行中检查) | future work 启动(GUI 需要删除交互前) | 按上文 future work 节实施:session 原语 + `registry.delete(id, { recursive? })` 一体落地 | 编排规则/拒绝清单已定案在本 Note;本期无任何删除入口,无半截语义要兼容 |
|
||||
| `log` facet 与 session 后端迁移 | 本期后任意期启动 | 介质操作下沉(复用审计表即施工清单) | facet 结构已留位;两后端介质代码本期即按可下沉形状组织 |
|
||||
| 多进程并发写保护 | 两 host 进程同写一介质 | JSON 后端文件锁;SQLite WAL 天然多进程 | 写全经 domain 单点串行,加锁只动后端 |
|
||||
| 跨进程变更观测 | GUI 断线重连感知 | revision 模式(抄 session-persistence) | 进程内已有 `domain/changed` |
|
||||
| 数据迁移 | 首个 tagged release 后模型再变 | 版本号驱动逐域迁移 | 版本号自第一天入介质 |
|
||||
| 大表性能 | 千级记录域挂 json | `routes` 改指 sqlite,数据手工导一次 | 路由即配置,消费者零改动 |
|
||||
| 多段 key | 两段 key 消费者出现(每 workspace 每 session 维度数据) | key 泛型换 tuple、SQLite 复合主键、JSON 嵌套层 | 一级表 = 段数 1 特例;不做任意深度嵌套;不拼字符串 key |
|
||||
| scope 维度 | "每 workspace 一份"的域出现且复合 key 表达不动 | DomainSpec 加 scope + 文件名 scope 段(encodeSegment) | 名字字符集已收紧,文件名不冲突 |
|
||||
| 跨表原子事务 | 同域两表一次原子操作需求 | `domain.transact(fn)`;JSON 天然原子,SQLite 包事务 | — |
|
||||
| 二级索引/条件查询 | 内存过滤不动(万级记录) | SQLite JSON1 查 value 列,加只读 query 面 | JSON 后端不陪跑 |
|
||||
| session 跨 workspace 移动 | 产品需求出现 | attach 校验放宽为"先 detach 后 attach"编排 | — |
|
||||
| RPC/GUI/boot | 下期 | `workspace.*` + `session.delete` 端点、wire schema、boot 挂载、sidebar 接真数据 | 本期模型与语义即 wire 投影的直接来源 |
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **复用 session-persistence 的 coordinator/后端**:事件日志语义(append-only、turn 崩溃修复、懒物化)与 KV 覆写语义不匹配;只借其分层思想(协调层持写序、后端只实现最小原语)。
|
||||
- **workspace 专用存储包,后续再抽 seam**:第二个消费者(session sidecar)已可预见,届时泛化要再动一次接口。
|
||||
- **domain 与 storage 合为一层**:后端会被迫接触 schema 校验、变更事件、写串行等领域关切;拆开后 storage 后端只做不透明原语(可替换面最小),domain 单实现收敛全部领域逻辑(zod/事件/串行化只写一遍,不随后端翻倍)。
|
||||
- **整库单后端二选一(学 session-persistence 单坑位模式)**:曾是初版方案;改为多后端并存 + 配置路由,因为存储枢纽要承载多种数据形式,不同形式/域对后端的偏好(肉眼可读 vs 高频点更新)注定分化,单坑位会逼出"整体换挂 + 手工导数据"的粗粒度动作。代价是按名查找多一步,fail-loud 兜底。
|
||||
- **JSON 后端 jsonl 追加 + 墓碑 + 压实**:temp+fsync+rename 的崩溃安全与 append 等价;覆写让文件永远是净值、肉眼可读,免掉折叠/压实/断行容错。域规模下整写与追加一行同量级。
|
||||
- **JSON 一表一文件**:覆写下文件粒度不影响写成本,按域合并文件更少,global 单例有落点。
|
||||
- **SQLite 整域存单行 blob**:任何一条记录变更都重写整域,失去按 key 精确更新——SQLite 相对 JSON 的唯一优势归零。
|
||||
- **SQLite 按 schema 生成 typed columns**:DDL 生成器过度建设;document-per-row 足够,查询需求出现再议。
|
||||
- **每域独立 sqlite db 文件**:与仓库一库多表惯例相反。
|
||||
- **path 作为 workspace key**:规范化/符号链接解析会改写 path;引用锚点必须稳定。
|
||||
- **归属用 cwd 派生(或与账合并)**:双真相源;cwd 表达不了排序;归属本就是 workspace 侧事实。
|
||||
- **变更事件带旧值**:仓库变更事件惯例是"新快照 + 操作判别"(唯一例外 fs 的 before/after 是方法返回值而非事件,因旧值事后不可重建且有 diff 消费者);需要 diff 的消费者自己持有上次快照。
|
||||
- **删除自动 cancel 运行中 session**:持久层/编排层反向牵动运行时,层次变脏;cancel 机制已存在,调用方组合即可。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- 测试矩阵本期四套件全绿:backend 契约共享套件在 json/sqlite 双端、registry/mount disposer 语义、domain 层(含 open 六步与路由 fail-loud)、workspace 全语义(create/attach 校验/一致性口径)。
|
||||
- `ctx.workspace` 可在测试组装下完成 create → attach → list 生命周期(删除为 future work)。
|
||||
- session-persistence 包零 diff(本期不动 session 侧的验收线)。
|
||||
- 本期无新快照(无模型可见面与组装面);下期 RPC 接线时补。
|
||||
|
||||
## Risks
|
||||
|
||||
- **仓库持久化面第一个推式变更事件**(session-persistence 靠 revision 轮询):形态虽有 `goal/changed` 范本,但"存储层发事件"是新先例,下期 RPC 消费时才能验证形态是否合适。
|
||||
- **JSON 后端整域覆写的规模前提**:若第二个消费者(session sidecar)在路由到 SQLite 前就以千级记录落在 JSON 后端,整写成本会先于预期显现;缓解即 `routes` 改指 sqlite。
|
||||
- **删除语义的编排层检查依赖 `ctx.sessions` 弱依赖**:headless 组装拿不到运行时注册表时按"无热 session"处理,存在窗口(外部进程正在跑该 session);多进程本就在不做清单内,接受。
|
||||
- **facet 泛化以未来的 `log` facet 为设计依据但本期不实现它**:存在"预留形状不合身"的风险;缓解是本期后端介质代码按复用审计表的下沉形状组织,`log` facet 真正落地时只动 facet 层。
|
||||
@@ -36,6 +36,13 @@ flowchart LR
|
||||
pkg_hooks_claude["hooks-claude"]
|
||||
pkg_hooks_codex["hooks-codex"]
|
||||
pkg_acp["acp"]
|
||||
pkg_storage["storage"]
|
||||
svc_storage["ctx.storage<br/>Non-session storage hub"]
|
||||
pkg_storage_json["storage-json"]
|
||||
pkg_storage_sqlite["storage-sqlite"]
|
||||
pkg_storage_domain["storage-domain"]
|
||||
pkg_workspace["workspace"]
|
||||
svc_workspace["ctx.workspace<br/>Workspace entity registry"]
|
||||
svc_sessionQuery["ctx.sessionQuery<br/>Session reads, traces, filters, and search"]
|
||||
pkg_session_reference["session-reference"]
|
||||
svc_sessionReferences["ctx.sessionReferences<br/>Cross-session snapshot preparation"]
|
||||
@@ -173,6 +180,9 @@ flowchart LR
|
||||
pkg_skill_local --> svc_skills
|
||||
pkg_spill --> svc_spillStore
|
||||
pkg_spill_local --> svc_spillStore
|
||||
pkg_storage --> svc_storage
|
||||
pkg_storage_json --> svc_storage
|
||||
pkg_storage_sqlite --> svc_storage
|
||||
pkg_subagent --> svc_subagents
|
||||
pkg_subagent_acp --> svc_subagents
|
||||
pkg_subagent_fork --> svc_subagents
|
||||
@@ -193,6 +203,7 @@ flowchart LR
|
||||
pkg_webserver --> svc_httpServer
|
||||
pkg_workflow --> svc_workflows
|
||||
pkg_workflow_workerthread --> svc_workflows
|
||||
pkg_workspace --> svc_workspace
|
||||
svc_agentLoop --> pkg_agent_spine_demo
|
||||
svc_agents --> pkg_acp
|
||||
svc_agents --> pkg_agent_loop
|
||||
@@ -247,6 +258,8 @@ flowchart LR
|
||||
svc_sessions --> pkg_subagent_inprocess
|
||||
svc_skills --> pkg_tool_skill
|
||||
svc_spillStore --> pkg_spill_policy
|
||||
svc_storage --> pkg_storage_domain
|
||||
svc_storage --> pkg_workspace
|
||||
svc_subagents --> pkg_tool_ralph
|
||||
svc_subagents --> pkg_tool_subagent
|
||||
svc_systemPrompt --> pkg_agent_loop
|
||||
@@ -288,6 +301,8 @@ flowchart LR
|
||||
| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`cli-demo`](../packages/examples/cli-demo), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. |
|
||||
| `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
|
||||
| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session-persistence/session-persistence) | [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session-persistence/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`acp`](../packages/ui/acp), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. |
|
||||
| `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. |
|
||||
| `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | - | - | Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase. |
|
||||
| `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations on the same service. |
|
||||
| `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui), [`acp`](../packages/ui/acp) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. |
|
||||
| `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. |
|
||||
|
||||
@@ -1201,6 +1201,84 @@ export interface Config {
|
||||
|
||||
Source: [`packages/spill/spill-policy/src/index.ts:51`](../packages/spill/spill-policy/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-storage-domain`
|
||||
|
||||
Requires: `storage`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* 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<string, string>
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/storage/storage-domain/src/index.ts:45`](../packages/storage/storage-domain/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-storage-json`
|
||||
|
||||
Requires: `storage`
|
||||
|
||||
```ts config-catalog
|
||||
/**
|
||||
* Plugin configuration.
|
||||
* `root` has NO default on purpose: a `process.cwd()` fallback would scatter
|
||||
* unit files wherever the process happens to start; assemblies state the
|
||||
* location explicitly.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Directory holding one `<unit>.json` file per unit. */
|
||||
root: string
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/storage/storage-json/src/index.ts:27`](../packages/storage/storage-json/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-storage-sqlite`
|
||||
|
||||
Requires: `storage`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Filesystem path to the SQLite database file. The special value `:memory:`
|
||||
* opens an in-process database (tests). On filesystems with POSIX modes,
|
||||
* missing directories and databases are created owner-only; existing path
|
||||
* modes are preserved. Filesystem setup errors other than an existing
|
||||
* database fail the open. The backend does not protect confidentiality or
|
||||
* integrity when another principal can replace the database entry in its
|
||||
* parent directory.
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick
|
||||
* a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems
|
||||
* where WAL's shared-memory files do not work (network mounts). See
|
||||
* {@link JournalMode}.
|
||||
*/
|
||||
journalMode?: JournalMode
|
||||
}
|
||||
|
||||
/**
|
||||
* Journal modes the backend will run under. `wal` is the default; the
|
||||
* rollback-journal modes (`delete`/`truncate`/`persist`) exist for
|
||||
* filesystems where WAL's shared-memory files do not work (network mounts).
|
||||
* `memory`/`off` are excluded: dropping journal durability silently
|
||||
* contradicts the durability clause of the KV backend contract.
|
||||
*/
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
```
|
||||
|
||||
Source: [`packages/storage/storage-sqlite/src/index.ts:24`](../packages/storage/storage-sqlite/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-subagent-acp`
|
||||
|
||||
Requires: `subagents`
|
||||
@@ -1958,12 +2036,14 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
- `@deepseek-ai/dsh-pty` ([`packages/pty/pty/src/index.ts`](../packages/pty/pty/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts))
|
||||
- `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts))
|
||||
- `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tasks` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts))
|
||||
- `@deepseek-ai/dsh-timeout-policy` — requires `tools` ([`packages/timeout/timeout-policy/src/index.ts`](../packages/timeout/timeout-policy/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userInteraction` ([`packages/ui/tool-ask-user/src/index.ts`](../packages/ui/tool-ask-user/src/index.ts))
|
||||
- `@deepseek-ai/dsh-tool-todo` — requires `tools` ([`packages/todo/tool-todo/src/index.ts`](../packages/todo/tool-todo/src/index.ts))
|
||||
- `@deepseek-ai/dsh-user-interaction` ([`packages/ui/user-interaction/src/index.ts`](../packages/ui/user-interaction/src/index.ts))
|
||||
- `@deepseek-ai/dsh-workspace` — requires `storage` ([`packages/workspace/workspace/src/index.ts`](../packages/workspace/workspace/src/index.ts))
|
||||
|
||||
## Seam packages (not directly loadable)
|
||||
|
||||
|
||||
@@ -489,6 +489,26 @@ A command was registered or unregistered. This is an unfiltered registry notific
|
||||
|
||||
Source: [`packages/ui/commands/src/index.ts:103`](../../packages/ui/commands/src/index.ts)
|
||||
|
||||
## `domain/*`
|
||||
|
||||
### `domain/changed` — emit
|
||||
|
||||
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.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* 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 on `put` the new snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
'domain/changed'(change: DomainChanged): void
|
||||
```
|
||||
|
||||
Source: [`packages/storage/storage-domain/src/events.ts:46`](../../packages/storage/storage-domain/src/events.ts)
|
||||
|
||||
## `fs/*`
|
||||
|
||||
### `fs/edit-intent` — waterfall
|
||||
|
||||
@@ -1421,6 +1421,30 @@ Types: [SaveTextSpill](../core-data-structures/spill.md) · [SpillRef](../core-d
|
||||
|
||||
Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/index.ts)
|
||||
|
||||
## `ctx.storage` — `Storage`
|
||||
|
||||
The storage hub service. Backends register under `backend`; data forms mount under their `StorageForms` key and are reached as `ctx.storage.<form>`.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Mount a data-form facility on the hub. Mounting is an effect: the
|
||||
* returned disposer unmounts the form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @param facility - The facility instance to expose.
|
||||
* @returns the disposer that unmounts the form.
|
||||
*/
|
||||
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void
|
||||
|
||||
/**
|
||||
* Resolve a mounted data form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @returns the mounted facility.
|
||||
*/
|
||||
form<K extends keyof StorageForms>(form: K): StorageForms[K]
|
||||
```
|
||||
|
||||
Source: [`packages/storage/storage/src/index.ts:35`](../../packages/storage/storage/src/index.ts)
|
||||
|
||||
## `ctx.subagents` — `SubagentService`
|
||||
|
||||
Named provider registry and capability-checked start surface.
|
||||
@@ -1881,6 +1905,52 @@ Types: [WorkflowRun](../core-data-structures/workflow.md) · [WorkflowStartReque
|
||||
|
||||
Source: [`packages/workflow/workflow/src/index.ts:159`](../../packages/workflow/workflow/src/index.ts)
|
||||
|
||||
## `ctx.workspace` — `WorkspaceRegistry`
|
||||
|
||||
The workspace registry service. Opens the `workspace` domain at startup, rebuilds one entity per stored record, and serves entities from an in-memory cache keyed by id. Session persistence is an OPTIONAL peer (resolved via `ctx.get`, never injected): while it is absent, session attachment rejects (what cannot be validated is not recorded) and `sessionIds` projections serve the account unfiltered.
|
||||
|
||||
There is deliberately no delete entry point in this phase: workspace deletion ships as one complete semantic together with the session-cascade primitives (future work in the owning Agent Note).
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Create a workspace over an existing directory. The path is canonicalized
|
||||
* through `fs.realpath` first — a nonexistent path rejects with the
|
||||
* original `ENOENT`, a path resolving to anything but a directory rejects,
|
||||
* and a canonical path already owned by another workspace (including a
|
||||
* symlink resolving to it) rejects.
|
||||
* @param path - Directory the workspace points at; canonicalized before storing.
|
||||
* @param title - Display title; defaults to `basename` of the canonical path.
|
||||
* @returns the created workspace after durability.
|
||||
*/
|
||||
async create(path: string, title?: string): Promise<Workspace>
|
||||
|
||||
/**
|
||||
* Look up a workspace by id.
|
||||
* @param id - The workspace id.
|
||||
* @returns the workspace, or `undefined` when unknown.
|
||||
*/
|
||||
get(id: WorkspaceId): Workspace | undefined
|
||||
|
||||
/**
|
||||
* Snapshot of all workspaces, in load-then-creation order.
|
||||
* @returns a fresh array of the cached entities.
|
||||
*/
|
||||
list(): Workspace[]
|
||||
|
||||
/**
|
||||
* Resolve a workspace by directory path, through the same `fs.realpath`
|
||||
* canon as {@link create} (hence async). A path that does not exist rejects
|
||||
* with the original error — a missing directory has no canonical form to
|
||||
* compare (a workspace whose recorded directory vanished is only reachable
|
||||
* by id; see `Workspace.status`).
|
||||
* @param path - Directory path in any spelling (symlinks, `..`, trailing slash).
|
||||
* @returns the owning workspace, or `undefined` when none matches.
|
||||
*/
|
||||
async resolveByPath(path: string): Promise<Workspace | undefined>
|
||||
```
|
||||
|
||||
Source: [`packages/workspace/workspace/src/index.ts:60`](../../packages/workspace/workspace/src/index.ts)
|
||||
|
||||
## Inherited `ctx` members (cordis core + loader/hmr/timer)
|
||||
|
||||
The framework `ctx` surface every plugin also sees, beyond the harness services above. This is pinned vendor source ([vendoring policy](../../vendor/README.md)); it is summarized here so the page is a complete picture of what `ctx` offers, without elevating framework internals to the harness tier's prominence.
|
||||
|
||||
@@ -28,6 +28,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tool-goal`](../packages/goal/tool-goal) |
|
||||
| `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) |
|
||||
| `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:103`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`tui`](../packages/ui/tui) |
|
||||
| `domain/changed` | `emit` | [`packages/storage/storage-domain/src/events.ts:46`](../packages/storage/storage-domain/src/events.ts) | [`storage-domain`](../packages/storage/storage-domain) (`emit`) | [`storage-domain`](../packages/storage/storage-domain), [`workspace`](../packages/workspace/workspace) |
|
||||
| `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:62`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:71`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) |
|
||||
|
||||
@@ -195,6 +195,12 @@ flowchart TD
|
||||
pkg_scripts["scripts"]
|
||||
pkg_telemetry["telemetry"]
|
||||
end
|
||||
subgraph group_storage["packages/storage"]
|
||||
pkg_storage["storage"]
|
||||
pkg_storage_domain["storage-domain"]
|
||||
pkg_storage_json["storage-json"]
|
||||
pkg_storage_sqlite["storage-sqlite"]
|
||||
end
|
||||
subgraph group_tasks["packages/tasks"]
|
||||
pkg_tasks["tasks"]
|
||||
pkg_tool_tasks["tool-tasks"]
|
||||
@@ -205,6 +211,9 @@ flowchart TD
|
||||
pkg_workflow["workflow"]
|
||||
pkg_workflow_workerthread["workflow-workerthread"]
|
||||
end
|
||||
subgraph group_workspace["packages/workspace"]
|
||||
pkg_workspace["workspace"]
|
||||
end
|
||||
pkg_brand --> pkg_invariants
|
||||
pkg_paths --> pkg_invariants
|
||||
pkg_retention --> pkg_invariants
|
||||
@@ -229,6 +238,7 @@ flowchart TD
|
||||
pkg_host_apiproxy --> pkg_invariants
|
||||
pkg_host_runtime --> pkg_invariants
|
||||
pkg_host_webserver --> pkg_invariants
|
||||
pkg_storage --> pkg_invariants
|
||||
pkg_llm --> pkg_brand
|
||||
pkg_llm --> pkg_invariants
|
||||
pkg_client_connection --> pkg_host_webserver
|
||||
@@ -252,6 +262,12 @@ flowchart TD
|
||||
pkg_telemetry --> pkg_brand
|
||||
pkg_telemetry --> pkg_invariants
|
||||
pkg_telemetry --> pkg_paths
|
||||
pkg_storage_domain --> pkg_invariants
|
||||
pkg_storage_domain --> pkg_storage
|
||||
pkg_storage_json --> pkg_invariants
|
||||
pkg_storage_json --> pkg_storage
|
||||
pkg_storage_sqlite --> pkg_invariants
|
||||
pkg_storage_sqlite --> pkg_storage
|
||||
pkg_llm_deepseek --> pkg_invariants
|
||||
pkg_llm_deepseek --> pkg_llm
|
||||
pkg_llm_deepseek --> pkg_timeout
|
||||
@@ -415,6 +431,12 @@ flowchart TD
|
||||
pkg_workflow --> pkg_invariants
|
||||
pkg_workflow --> pkg_llm
|
||||
pkg_workflow --> pkg_session
|
||||
pkg_workspace --> pkg_brand
|
||||
pkg_workspace --> pkg_invariants
|
||||
pkg_workspace --> pkg_session
|
||||
pkg_workspace --> pkg_session_persistence
|
||||
pkg_workspace --> pkg_storage
|
||||
pkg_workspace --> pkg_storage_domain
|
||||
pkg_tools --> pkg_agent
|
||||
pkg_tools --> pkg_code_runtime
|
||||
pkg_tools --> pkg_invariants
|
||||
@@ -806,6 +828,7 @@ flowchart TD
|
||||
| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`host-runtime`](../packages/host/runtime) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/support/invariants) |
|
||||
| [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/support/invariants) |
|
||||
| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
| [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) |
|
||||
@@ -814,6 +837,9 @@ flowchart TD
|
||||
| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
|
||||
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
|
||||
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |
|
||||
| [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) |
|
||||
| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout) |
|
||||
| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) |
|
||||
@@ -861,6 +887,7 @@ flowchart TD
|
||||
| [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`workspace`](../packages/workspace/workspace) | `workspace` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain) |
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
|
||||
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/ui/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) |
|
||||
| [`goal-session`](../packages/goal/goal-session) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
|
||||
179
missions/tasks/20260724-storage-workspace/dev-plan.md
Normal file
179
missions/tasks/20260724-storage-workspace/dev-plan.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Storage + Workspace 工程开发文档
|
||||
|
||||
> 施工范围:5 个新包,session 侧零 diff。规范正典:[Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md)——本文只写工程拆解(目录/文件、class 落位、teammate 分工、并行依赖),接口语义以 Note 为准,冲突时改这里不改 Note(除非经用户拍板)。
|
||||
> 门禁口径:GUI 免门禁期同款——不随手写测试门禁,跑 typecheck/build 保证编译;测试文件按仓库惯例落位(包级 `tests/`、`.spec.ts`),红绿在 PR 窗口收口。
|
||||
|
||||
## 0. 总览
|
||||
|
||||
```
|
||||
packages/storage/
|
||||
storage/ dsh-storage 枢纽:Storage service + BackendRegistry + StorageForms
|
||||
storage-json/ dsh-storage-json JsonStorageBackend(kv facet)
|
||||
storage-sqlite/ dsh-storage-sqlite SqliteStorageBackend(kv facet)
|
||||
storage-domain/ dsh-storage-domain DomainFacility + Domain + KvTable + domain/changed
|
||||
packages/workspace/
|
||||
workspace/ dsh-workspace WorkspaceRegistry + WorkspaceEntity + workspaceDomainSpec
|
||||
```
|
||||
|
||||
依赖与并行关系(→ = 依赖):
|
||||
|
||||
```
|
||||
W1 storage(枢纽) ──→ W2a storage-json ──┐
|
||||
└──→ W2b storage-sqlite ─┼──→ 集成冒烟(W4 兼)
|
||||
└──→ W3 domain ──────────┘
|
||||
└──→ W4 workspace
|
||||
```
|
||||
|
||||
- W1 先行(接口包是所有人的编译依赖),完成后 W2a/W2b/W3 **三线并行**;W4 依赖 W3 的接口定型(不必等 json/sqlite 完工,可对着 W3 的类型先写,用内存假 backend 跑测试)。
|
||||
- 每包的 package.json/tsconfig/README/invariant 伴生由该包 owner 自己配齐(模板照抄 `packages/session-persistence/session-persistence-sqlite/` 的形状)。
|
||||
|
||||
## 1. W1:`dsh-storage`(枢纽)——主线程自做
|
||||
|
||||
量小且是全组编译根,主线程直接写,不派 teammate。
|
||||
|
||||
```
|
||||
packages/storage/storage/
|
||||
package.json # 无运行时依赖;cordis peerDep + dev
|
||||
tsconfig.json
|
||||
src/index.ts # Storage service + apply + 全部导出
|
||||
src/registry.ts # BackendRegistry
|
||||
src/backend.ts # StorageBackend/KvFacet/KvUnitDescriptor/KvUnit 类型
|
||||
src/error.ts # StorageError + code 联合
|
||||
src/invariant.ts # 见下
|
||||
tests/registry.spec.ts # registry/mount 套件
|
||||
README.md
|
||||
```
|
||||
|
||||
class/接口逐条(签名以 Note 为准,此处列实现要点):
|
||||
|
||||
| 成员 | 实现要点 |
|
||||
| --- | --- |
|
||||
| `class Storage extends Service` | `super(ctx, 'storage')`;`readonly backend = new BackendRegistry()`;`mount(form, facility)` 存入私有 `Map<keyof StorageForms, unknown>`,重复 → `StorageError('duplicate-mount')`,返回删除闭包;`get domain()` 从 map 取,缺 → `StorageError('form-not-mounted')` |
|
||||
| `class BackendRegistry` | 私有 `Map<string, StorageBackend>`;`register` 重名 → `duplicate-backend`,返回 `() => map.delete(name)`;`get` 缺名 → `backend-not-found`;`names()` 返回数组拷贝 |
|
||||
| `interface StorageForms {}` | 空接口 + JSDoc(merge-extensible,键 = 数据形式名) |
|
||||
| `interface StorageBackend / KvFacet / KvUnitDescriptor / KvUnit` | 纯类型 + 契约 JSDoc(七条契约写在 KvUnit 各方法 JSDoc 上——这是 backend 实现者的规范文本) |
|
||||
| `class StorageError extends Error` | `constructor(code, message?, cause?)`;`name = 'StorageError'` |
|
||||
| `const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/` | 导出;descriptor 校验用(backend open 时验,fail loud) |
|
||||
| invariant | 枢纽自身无运行时不变量(纯注册表,无事件流/可变盘面),写"explained empty"(措辞照抄 sqlite 后端 invariant.ts 的 "No runtime invariant:" 模板) |
|
||||
|
||||
事件面:本包**无**事件(`domain/changed` 归 dsh-storage-domain)。
|
||||
|
||||
## 2. W2a:`dsh-storage-json` —— teammate **json-backend**
|
||||
|
||||
```
|
||||
packages/storage/storage-json/
|
||||
src/index.ts # Config + apply + JsonStorageBackend
|
||||
src/unit.ts # JsonKvUnit
|
||||
src/atomic.ts # temp+fsync+rename 原子写(含 win32 分支)
|
||||
src/format.ts # 文件格式 parse/serialize + malformed 检查
|
||||
src/invariant.ts
|
||||
tests/json-backend.spec.ts # 挂共享契约套件(见 §5)+ json 特有(文件肉眼格式、malformed)
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | schemastery,`root: z.string().required()`(JSDoc 说明为何无默认:防 cwd 散落,参照 session-persistence 措辞) |
|
||||
| `class JsonStorageBackend implements StorageBackend` | `name='json'`;`kv = { open }`;持 `Map<unitName, JsonKvUnit>`(同名重复 open → 复用还是报错:**报错**,unit 生命周期归调用方,double-open 是 bug);`close()` 逐 unit close,幂等 |
|
||||
| `class JsonKvUnit implements KvUnit` | 内存态 `{ version, global, tables: Map<string, Map<string, unknown>> }` 为权威;构造时读盘:文件缺失 = 空单元(不落盘),存在则 parse + 版本比对;每个写原语 = 改内存 → `writeAtomic(serialize())`;**写不排队**(契约第 4 条:串行是调用方的事),但单次 writeAtomic 内部完整(temp/fsync/rename);close 后操作 → `closed` |
|
||||
| `atomic.ts` | `writeAtomic(path, data)`:同目录 temp 文件 + fsync + rename;win32 分支照抄 `session-persistence-jsonl/src/win32.ts` 的替换语义(先照抄,`log` facet 迁移期再提共享——Note 已记)|
|
||||
| `format.ts` | `serialize(unit): string`(`JSON.stringify(…, null, 2)` + 尾换行);`parse(text): ParsedUnit`,缺 `unit` 头/结构不符 → `malformed-medium` |
|
||||
| apply | `ctx.effect(() => { const d = ctx.storage.backend.register('json', backend); return async () => { d(); await backend.close() } })`;inject: `['storage']` |
|
||||
| invariant | 断言候选:rename 发布后盘上文件必可 parse 回等价内存态(写后读回校验,仅测试态开启);若判断无运行时可断言关系则 explained empty |
|
||||
|
||||
## 3. W2b:`dsh-storage-sqlite` —— teammate **sqlite-backend**
|
||||
|
||||
```
|
||||
packages/storage/storage-sqlite/
|
||||
src/index.ts # Config + apply + SqliteStorageBackend
|
||||
src/unit.ts # SqliteKvUnit
|
||||
src/schema.ts # SCHEMA_VERSION + openDatabase + DDL
|
||||
src/invariant.ts
|
||||
tests/sqlite-backend.spec.ts
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | `path: z.string().required()`(`:memory:` 允许)+ `journalMode` 枚举 default 'wal' |
|
||||
| `schema.ts` | `STORAGE_SQLITE_SCHEMA_VERSION = 1`;`openDatabase(config)` 照抄 session-persistence-sqlite 的序列(mkdir 0o700 → wx 0o600 建文件 → PRAGMA foreign_keys → journal_mode → user_version 检查盖章/拒绝 → 建 `units`/`unit_globals`);**先照抄不提共享 helper**(Note 已记:提取放迁移期) |
|
||||
| `class SqliteStorageBackend` | `name='sqlite'`;单 `DatabaseSync` 连接;`kv.open(descriptor)`:校验名字字符集 → `units` 行版本比对(无行则 INSERT 盖章)→ 按 descriptor.tables 逐张 `CREATE TABLE IF NOT EXISTS "u_<unit>_<table>"` → 返回 unit;`close()` 关连接 |
|
||||
| `class SqliteKvUnit` | 预编译语句(每表 upsert/delete/select-all + global upsert);`loadAll` 全表 SELECT 组装;`putRecord` = `INSERT … ON CONFLICT(key) DO UPDATE`;单语句原子,无显式事务;value `JSON.stringify`/parse |
|
||||
| invariant | 断言候选:STRICT 表 + user_version 与常量一致(open 后检);或 explained empty |
|
||||
|
||||
## 4. W3:`dsh-storage-domain` —— teammate **domain-layer**
|
||||
|
||||
```
|
||||
packages/storage/storage-domain/
|
||||
src/index.ts # Config + apply + DomainFacility
|
||||
src/spec.ts # DomainSpec/defineDomain/domainTable + descriptorOf
|
||||
src/domain.ts # DomainImpl + KvTableImpl + 写链
|
||||
src/events.ts # domain/changed declaration merging
|
||||
src/error.ts # DomainError
|
||||
src/invariant.ts
|
||||
tests/domain.spec.ts # 用内存假 backend(tests/helpers/memory-backend.ts)
|
||||
```
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `Config` | `backend: z.string().required()` + `routes: z.dict(z.string()).default({})` |
|
||||
| `spec.ts` | `defineDomain` 恒等函数(编译期收窄)+ 名字/表名正则校验(违规 throw,misconfiguration fails loud);`descriptorOf(spec)` 投影 |
|
||||
| `class DomainFacility` | 持 `Map<domainName, DomainImpl>`(already-open 检查);`open(spec)` 按 Note 六步实现;zod 依赖在此包(dependencies,不是 peer) |
|
||||
| `class DomainImpl` | 写链 `chain: Promise<void>`(`enqueue<T>(job): Promise<T>` 私有方法,所有写走它);内存态 `Map<table, Map<key, value>>` + global;每写:链上 → 改内存 → unit 原语 await → `ctx.emit('domain/changed', …)`;dispose:`enqueue(noop)` 排空 → `unit.close()` |
|
||||
| `class KvTableImpl<K,V>` | 读同步走内存;`update` fn 同步纯(类型上 `(current: V) => V`),缺 key → `missing-key`;`delete` 返回是否存在 |
|
||||
| `events.ts` | 按 Note 全文(`@mode emit` + `@param`);`DomainChanged` 接口导出 |
|
||||
| invariant | 断言候选(真不变量,建议做):**每次 `domain/changed` 事件的 value 必等于内存态当前值**(事件流 vs 可变数据的 owned relationship,正合仓库 invariant 规范)|
|
||||
| tests/helpers/memory-backend.ts | `MemoryStorageBackend`:Map 实现 KvUnit,宣称版本可注入——共享给 W4 用 |
|
||||
|
||||
## 5. 共享 backend 契约套件 —— domain-layer 兼写(或主线程)
|
||||
|
||||
```
|
||||
packages/storage/storage/tests/contract.ts # export function runKvBackendContract(factory)
|
||||
```
|
||||
|
||||
- 仿 `runPersistenceContract` 形状:`factory: () => Promise<{ backend, reopen(): Promise<StorageBackend> }>`,两后端 spec 文件各自 import 调用。
|
||||
- 覆盖 Note 七条契约 + 版本拒绝 + close 幂等;"崩溃再 open"用 `reopen()`(新实例指向同一介质)模拟。
|
||||
- 落在接口包 tests/ 下(不进 src,不发布),json/sqlite 的 devDependencies 指向 workspace 接口包即可复用。
|
||||
|
||||
## 6. W4:`dsh-workspace` —— teammate **workspace-domain**
|
||||
|
||||
```
|
||||
packages/workspace/workspace/
|
||||
src/index.ts # apply + WorkspaceRegistry(service 挂 ctx.workspace)
|
||||
src/types.ts # WorkspaceId brand + Workspace 接口
|
||||
src/spec.ts # workspaceRecord zod + workspaceDomainSpec
|
||||
src/entity.ts # WorkspaceEntity(不出包:index.ts 不 re-export)
|
||||
src/paths.ts # realpathNormalize(path)
|
||||
src/invariant.ts
|
||||
tests/workspace.spec.ts # MemoryStorageBackend + 假 sessionPersistence stub
|
||||
```
|
||||
|
||||
(删除入口本期不存在:registry 无 delete、entity 无关联清理——整套删除语义在 Agent Note 的 future work 节。)
|
||||
|
||||
| class | 要点 |
|
||||
| --- | --- |
|
||||
| `types.ts` | `WorkspaceId` brand + 工厂;`Workspace` 接口(Note 签名照录,JSDoc 齐全——这是对外契约) |
|
||||
| `spec.ts` | `workspaceRecord`(path/title/sessionIds/createdAt/updatedAt)+ `workspaceDomainSpec = defineDomain({ name: 'workspace', version: 1, tables: { workspaces: … } })` |
|
||||
| `paths.ts` | `realpathNormalize(p): Promise<string>`——`fs.realpath`;ENOENT 原样抛(create 的 reject 路径) |
|
||||
| `class WorkspaceRegistry extends Service` | `super(ctx, 'workspace')`;inject `['storage', 'sessionPersistence']`(sessionPersistence optional:`ctx.get()` 取,缺席时 attach 拒绝);`start()`:`ctx.storage.domain.open(workspaceDomainSpec)` + 重建 `Map<WorkspaceId, WorkspaceEntity>`;`create`:realpath → resolveByPath 撞 → reject;否则 `WorkspaceId(randomUUID())` + `table.put` + 建实体入缓存;`list()` 快照数组(过滤无效 sessionId 的投影在实体 getter 做);**无 delete 方法**(future work,与 session 级联一体落地) |
|
||||
| `class WorkspaceEntity implements Workspace` | 构造持 registry/id/record;getter 投影;`mutate(fn)` 私有:`table.update(id, r => stampUpdatedAt(fn(r)))` 后原地换 record;`attachSession`:读 `sessionPersistence.list()` 找 header(或 inspect),cwd realpath ≠ path → reject;幂等(已在账 → no-op);`detachSession` 摘账(不动 session 文件);`status()`:`fs.access(path)` |
|
||||
| 一致性口径 | ①账指向的 session 查无:**投影过滤**(getter 层)+ 下次 mutate 摘除;③双重账 load 检出 → throw;④missing-dir 只反映在 status() |
|
||||
| invariant | 断言候选:缓存实体集合与 domain 表 key 集合一致(owned relationship:registry 缓存 vs 权威盘面)|
|
||||
|
||||
## 7. Teammate 编成与节奏
|
||||
|
||||
| teammate | 包 | 开工条件 | 预估节奏 |
|
||||
| --- | --- | --- | --- |
|
||||
| (主线程) | W1 storage 枢纽 + §5 契约套件骨架 | 立即 | 首批落盘,随后进入 review/dispatcher 角色 |
|
||||
| json-backend | W2a | W1 类型可编译即开工 | 分批落盘:atomic/format 先行,unit 次之,契约套件接入收尾 |
|
||||
| sqlite-backend | W2b | 同上 | schema.ts 先行(照抄源已指明),unit 次之 |
|
||||
| domain-layer | W3 + memory-backend helper | 同上 | spec/error 先行 → DomainImpl 写链 → 事件 → 契约套件(若主线程未完成则兼) |
|
||||
| workspace-domain | W4 | W3 的 src 类型定型(不等其测试) | types/spec/paths 先行 → registry/entity → 测试 |
|
||||
|
||||
协作规矩(照 conventions):分批落盘每批几分钟内、每批一句话回执;产出零落盘超 5 分钟报告;不混 commit 别人的在途文件;代码注释一律英文且只写非显然契约;干完不 kill 保持待命。commit 纪律:`--no-verify`,按包分刀(W1 一刀 → W2a/W2b/W3 各一刀 → W4 一刀 → 测试/文档尾刀),文档(本文件 + Agent Note 增量)住顶刀。
|
||||
|
||||
## 8. 主线程验收清单(每包合入前)
|
||||
|
||||
- [ ] `pnpm run typecheck` 过(本期唯一硬门禁)
|
||||
- [ ] 包结构齐:package.json(`@deepseek-ai/dsh-*`、ESM、cordis peerDep)、README、invariant 伴生(真断言或 explained empty)
|
||||
- [ ] 接口与 Agent Note 一致;发现实现逼着改接口 → 停下来报主线程裁决(不擅改 Note)
|
||||
- [ ] 测试文件落位正确(包级 tests/、`.spec.ts`),能跑多少跑多少,红的记台账不追修
|
||||
- [ ] session-persistence 包零 diff(`git status` 检查线)
|
||||
@@ -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 |
|
||||
|
||||
@@ -682,6 +682,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.',
|
||||
@@ -886,6 +900,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. */
|
||||
@@ -1037,6 +1073,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',
|
||||
@@ -2039,6 +2082,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};',
|
||||
@@ -2383,6 +2430,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.
|
||||
33
packages/storage/storage-domain/README.md
Normal file
33
packages/storage/storage-domain/README.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# @deepseek-ai/dsh-storage-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, reach durability on the routed backend first, then update memory and emit `domain/changed`. The opening consumer owns the handle's lifecycle and releases it with `Domain.close()` (idempotent; typically its own `ctx.effect` disposer); domains still open when the plugin unmounts are closed by the facility.
|
||||
|
||||
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
|
||||
|
||||
### 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 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.
|
||||
43
packages/storage/storage-domain/package.json
Normal file
43
packages/storage/storage-domain/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage-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"
|
||||
}
|
||||
}
|
||||
357
packages/storage/storage-domain/src/domain.ts
Normal file
357
packages/storage/storage-domain/src/domain.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* 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, awaits backend durability
|
||||
* FIRST, then mutates memory, then emits `domain/changed` — a rejected
|
||||
* backend write leaves memory untouched (no divergence between reads and the
|
||||
* medium), and events carry values that equal the in-memory state at
|
||||
* emission, in write order.
|
||||
* @module @deepseek-ai/dsh-storage-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<G> {
|
||||
/**
|
||||
* 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<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<K extends string, V> {
|
||||
/**
|
||||
* 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<K>
|
||||
|
||||
/** 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<void>
|
||||
|
||||
/**
|
||||
* 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<boolean>
|
||||
|
||||
/**
|
||||
* 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<V>
|
||||
}
|
||||
|
||||
/** Global handle of a spec: typed when declared, `never` (inaccessible) when not. */
|
||||
export type DomainGlobalHandleOf<S extends DomainSpec> =
|
||||
S extends { readonly global: DomainGlobalSpec<infer G> } ? DomainGlobal<G> : never
|
||||
|
||||
/** One open domain, typed by its spec. */
|
||||
export interface Domain<S extends DomainSpec> {
|
||||
/** Domain name from the spec. */
|
||||
readonly name: string
|
||||
/** Global singleton handle; a spec without `global` has no usable handle (`never`). */
|
||||
readonly global: DomainGlobalHandleOf<S>
|
||||
/**
|
||||
* 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<N extends keyof S['tables'] & string>(name: N): KvTable<TableKeyOf<S, N>, TableValueOf<S, N>>
|
||||
|
||||
/**
|
||||
* Close this domain: reject new writes immediately, drain already-queued
|
||||
* writes (their events still emit), release the backend unit, then free
|
||||
* the domain name for a later open. Idempotent — repeated calls share one
|
||||
* teardown. The consumer owns this call (typically as its own `ctx.effect`
|
||||
* disposer); the facility closes any domain left open when it unmounts.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** 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<T>(job: () => Promise<T>): Promise<T>
|
||||
/** 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<S>`; nothing outside this package constructs one.
|
||||
*/
|
||||
export class DomainImpl {
|
||||
/** Domain name from the spec. */
|
||||
readonly name: string
|
||||
|
||||
private readonly tables = new Map<string, KvTableImpl<string, unknown>>()
|
||||
private globalValue: unknown
|
||||
private readonly globalHandle?: DomainGlobal<unknown>
|
||||
|
||||
/** Tail of the write chain; every link settles (rejections are observed by the caller's slice). */
|
||||
private chain: Promise<void> = Promise.resolve()
|
||||
/** Set when close begins: new writes reject while already-queued writes drain. */
|
||||
private disposing = false
|
||||
/** Set when close finishes (chain drained, unit closed): reads reject from here on. */
|
||||
private closed = false
|
||||
private disposal?: Promise<void>
|
||||
|
||||
/**
|
||||
* @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 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.
|
||||
* @param onClosed - Facility hook run once after teardown completes; frees
|
||||
* the domain name for a later open.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
spec: DomainSpec,
|
||||
private readonly unit: KvUnit,
|
||||
records: Map<string, Map<string, unknown>>,
|
||||
globalValue: unknown,
|
||||
private readonly onClosed: () => void,
|
||||
) {
|
||||
this.name = spec.name
|
||||
const host: TableHost = {
|
||||
domainName: spec.name,
|
||||
unit,
|
||||
enqueue: job => this.enqueue(job),
|
||||
assertReadable: () => { this.assertReadable() },
|
||||
emitChanged: (change) => { this.emitChanged(change) },
|
||||
}
|
||||
for (const [table, tableRecords] of records) {
|
||||
this.tables.set(table, new KvTableImpl(host, table, tableRecords))
|
||||
}
|
||||
if (spec.global !== undefined) {
|
||||
this.globalValue = globalValue
|
||||
this.globalHandle = {
|
||||
get: () => {
|
||||
this.assertReadable()
|
||||
return this.globalValue
|
||||
},
|
||||
set: value => this.enqueue(async () => {
|
||||
await this.unit.setGlobal(value)
|
||||
this.globalValue = value
|
||||
this.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<unknown> {
|
||||
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<string, unknown> {
|
||||
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), close the unit, then free the name via
|
||||
* the facility hook. Idempotent — repeated calls share one teardown.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.disposal ??= this.runClose()
|
||||
return this.disposal
|
||||
}
|
||||
|
||||
private async runClose(): Promise<void> {
|
||||
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
|
||||
this.onClosed()
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch one post-durability change notification, containing observer
|
||||
* failures: the write is already committed (medium and memory both hold
|
||||
* the new state), so a throwing listener must not retroactively reject it.
|
||||
*/
|
||||
private emitChanged(change: DomainChanged): void {
|
||||
try {
|
||||
this.ctx.emit('domain/changed', change)
|
||||
} catch (error) {
|
||||
// Swallows synchronous observer exceptions only: emit dispatches
|
||||
// listeners inline and nothing else runs in the try. The event is a
|
||||
// notification, not a transaction participant — the commit point has
|
||||
// passed, so containment (with a log) is the only correct outcome.
|
||||
this.ctx.logger.warn(`domain '${this.name}': domain/changed listener failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue<T>(job: () => Promise<T>): Promise<T> {
|
||||
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<K extends string, V> implements KvTable<K, V> {
|
||||
constructor(
|
||||
private readonly host: TableHost,
|
||||
private readonly tableName: string,
|
||||
private readonly records: Map<string, unknown>,
|
||||
) {}
|
||||
|
||||
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<K> {
|
||||
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<void> {
|
||||
return this.host.enqueue(async () => {
|
||||
await this.host.unit.putRecord(this.tableName, key, value)
|
||||
this.records.set(key, value)
|
||||
this.emitPut(key, value)
|
||||
})
|
||||
}
|
||||
|
||||
delete(key: K): Promise<boolean> {
|
||||
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
|
||||
await this.host.unit.deleteRecord(this.tableName, key)
|
||||
this.records.delete(key)
|
||||
this.host.emitChanged({
|
||||
domain: this.host.domainName,
|
||||
table: this.tableName,
|
||||
key,
|
||||
operation: 'deleted',
|
||||
})
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
update(key: K, fn: (current: V) => V): Promise<V> {
|
||||
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)
|
||||
await this.host.unit.putRecord(this.tableName, key, next)
|
||||
this.records.set(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,
|
||||
})
|
||||
}
|
||||
}
|
||||
53
packages/storage/storage-domain/src/error.ts
Normal file
53
packages/storage/storage-domain/src/error.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Error vocabulary of the domain data form.
|
||||
* @module @deepseek-ai/dsh-storage-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
|
||||
}
|
||||
}
|
||||
48
packages/storage/storage-domain/src/events.ts
Normal file
48
packages/storage/storage-domain/src/events.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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-storage-domain/src/events
|
||||
*/
|
||||
|
||||
/** Shared location fields of one durable domain change. */
|
||||
export interface DomainChangedBase {
|
||||
/** 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
|
||||
}
|
||||
|
||||
/** A record (or the global singleton) was inserted or overwritten. */
|
||||
export interface DomainChangedPut extends DomainChangedBase {
|
||||
readonly operation: 'put'
|
||||
/** The new snapshot. */
|
||||
readonly value: unknown
|
||||
}
|
||||
|
||||
/** A record was deleted; tombstones carry no value. */
|
||||
export interface DomainChangedDeleted extends DomainChangedBase {
|
||||
readonly operation: 'deleted'
|
||||
readonly value?: never
|
||||
}
|
||||
|
||||
/** One durable domain change; a closed union — switch on `operation`. */
|
||||
export type DomainChanged = DomainChangedPut | DomainChangedDeleted
|
||||
|
||||
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 on `put` the new snapshot.
|
||||
* @mode emit
|
||||
*/
|
||||
'domain/changed'(change: DomainChanged): void
|
||||
}
|
||||
}
|
||||
203
packages/storage/storage-domain/src/index.ts
Normal file
203
packages/storage/storage-domain/src/index.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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-storage-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 = 'storage-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<string, string>
|
||||
}
|
||||
|
||||
export const Config: z<Config> = 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<string, DomainImpl>()
|
||||
/** Names reserved by an in-flight or completed open, so concurrent opens of one name fail loud. */
|
||||
private readonly reserved = new Set<string>()
|
||||
|
||||
/**
|
||||
* @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.
|
||||
*
|
||||
* Lifecycle: the CALLER owns the returned handle and closes it via
|
||||
* `Domain.close()` (typically as its own `ctx.effect` disposer) — the
|
||||
* facility does not tie the domain to any consumer fiber. Domains still
|
||||
* open when the facility unmounts are closed by the plugin disposer.
|
||||
* @param spec - The domain declaration, typically from `defineDomain`.
|
||||
* @returns the opened domain handle, typed by the spec.
|
||||
*/
|
||||
async open<S extends DomainSpec>(spec: S): Promise<Domain<S>> {
|
||||
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<string, Map<string, unknown>>()
|
||||
for (const [table, tableSpec] of Object.entries(spec.tables)) {
|
||||
const records = new Map<string, unknown>()
|
||||
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 globalSpec = spec.global
|
||||
const globalValue = globalSpec === undefined
|
||||
? undefined
|
||||
: snapshot.global === null
|
||||
? globalSpec.initial
|
||||
: parseRecord(spec.name, '', '', () => globalSpec.schema.parse(snapshot.global))
|
||||
// The onClosed hook runs strictly after teardown completes: writes
|
||||
// landing during the drain still emit domain/changed, and the domain
|
||||
// stays resolvable (the package invariant cross-checks each event)
|
||||
// until fully closed — only then does the name free up for reopening.
|
||||
const domain: DomainImpl = new DomainImpl(this.ctx, spec, unit, tables, globalValue, () => {
|
||||
this.domains.delete(spec.name)
|
||||
this.reserved.delete(spec.name)
|
||||
})
|
||||
this.domains.set(spec.name, domain)
|
||||
// The single type-erasure point: DomainImpl is the untyped runtime,
|
||||
// Domain<S> 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<S>
|
||||
} catch (error) {
|
||||
await unit.close()
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
// Any failure means the domain never registered (nothing can throw
|
||||
// after it), so releasing the name reservation is unconditional.
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every domain still open on this facility. The unmount path for
|
||||
* consumers that never called `Domain.close()` themselves; closing is
|
||||
* idempotent, so double-closing an already-closed domain is harmless.
|
||||
* @returns resolution after every unit is released.
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
await Promise.all([...this.domains.values()].map(domain => domain.close()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one zod parse, translating failure to `invalid-record` with its location. */
|
||||
function parseRecord<T>(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) {
|
||||
const facility = new DomainFacility(ctx, config)
|
||||
ctx.effect(() => {
|
||||
const unmount = ctx.storage.mount('domain', facility)
|
||||
return async () => {
|
||||
// Close leftovers before unmounting: draining writes still emit
|
||||
// domain/changed, whose invariant resolves the facility through the hub.
|
||||
await facility.closeAll()
|
||||
unmount()
|
||||
}
|
||||
})
|
||||
}
|
||||
67
packages/storage/storage-domain/src/invariant.ts
Normal file
67
packages/storage/storage-domain/src/invariant.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-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-storage-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-storage-domain'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-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)
|
||||
switch (change.operation) {
|
||||
case '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
|
||||
case 'put':
|
||||
if (current !== change.value) {
|
||||
return fail(
|
||||
`domain/changed value for '${change.domain}'.'${change.table}'['${change.key}'] `
|
||||
+ 'differs from the in-memory record',
|
||||
)
|
||||
}
|
||||
return
|
||||
default:
|
||||
change satisfies never
|
||||
}
|
||||
}, { 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))
|
||||
112
packages/storage/storage-domain/src/spec.ts
Normal file
112
packages/storage/storage-domain/src/spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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-storage-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<G> {
|
||||
/** Validates the stored global at the durable boundary. */
|
||||
readonly schema: ZodType<G>
|
||||
/** 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<K extends string = string, V = unknown> {
|
||||
/** Validates every stored record at the durable boundary. */
|
||||
readonly valueSchema: ZodType<V>
|
||||
/** 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<unknown>
|
||||
/** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
|
||||
readonly tables: Record<string, DomainTableSpec>
|
||||
}
|
||||
|
||||
/** Key type of one declared table, recovered from its phantom carrier. */
|
||||
export type TableKeyOf<S extends DomainSpec, N extends keyof S['tables']> =
|
||||
S['tables'][N] extends DomainTableSpec<infer K> ? K : never
|
||||
|
||||
/** Value type of one declared table. */
|
||||
export type TableValueOf<S extends DomainSpec, N extends keyof S['tables']> =
|
||||
S['tables'][N] extends DomainTableSpec<string, infer V> ? V : never
|
||||
|
||||
/** Global value type of a spec; `never` when the spec declares no global. */
|
||||
export type GlobalValueOf<S extends DomainSpec> =
|
||||
S['global'] extends DomainGlobalSpec<infer G> ? 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<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V> {
|
||||
return { valueSchema: schema }
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity helper that pins a spec's literal types and validates its shape.
|
||||
* Misconfiguration fails loud at the owning package's module load, before any
|
||||
* medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version
|
||||
* that is not a non-negative integer, or a global schema that accepts `null`
|
||||
* all throw. The `null` rejection guards round-tripping: backends store the
|
||||
* global as opaque JSON with `null` as the "never written" sentinel, so a
|
||||
* nullable global would be indistinguishable from an absent one on reopen
|
||||
* (a stored `null` silently reverts to `initial`).
|
||||
* @param spec - The domain declaration.
|
||||
* @returns the same spec, narrowed to its literal type.
|
||||
*/
|
||||
export function defineDomain<S extends DomainSpec>(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}`)
|
||||
}
|
||||
}
|
||||
if (spec.global !== undefined && spec.global.schema.safeParse(null).success) {
|
||||
throw new Error(
|
||||
`domain '${spec.name}' global schema must not accept null: `
|
||||
+ 'null is the medium\'s "never written" sentinel, so a stored null could not round-trip',
|
||||
)
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
326
packages/storage/storage-domain/tests/domain.spec.ts
Normal file
326
packages/storage/storage-domain/tests/domain.spec.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { z } from 'zod'
|
||||
import Storage 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<typeof itemSchema>
|
||||
|
||||
const settingsSchema = z.object({ theme: z.string() })
|
||||
|
||||
const spec = defineDomain({
|
||||
name: 'demo',
|
||||
version: 1,
|
||||
global: { schema: settingsSchema, initial: { theme: 'plain' } },
|
||||
tables: { items: domainTable<string, Item>(itemSchema) },
|
||||
})
|
||||
|
||||
const bareSpec = defineDomain({
|
||||
name: 'bare',
|
||||
version: 1,
|
||||
tables: { rows: domainTable<string, Item>(itemSchema) },
|
||||
})
|
||||
|
||||
/** Boot a context with the storage hub, one memory backend, and a facility over it. */
|
||||
async function harness(options?: { pool?: MemoryMediaPool; config?: Partial<Config> }) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
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<string, Item>(itemSchema) },
|
||||
})).toThrow(/table name/)
|
||||
})
|
||||
|
||||
it('rejects a global schema that accepts null (the never-written sentinel)', () => {
|
||||
expect(() => defineDomain({
|
||||
name: 'ok',
|
||||
version: 1,
|
||||
global: { schema: settingsSchema.nullable(), initial: null },
|
||||
tables: {},
|
||||
})).toThrow(/must not accept null/)
|
||||
})
|
||||
})
|
||||
|
||||
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('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()
|
||||
{
|
||||
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('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()
|
||||
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('durability failure', () => {
|
||||
it('leaves memory untouched and emits nothing when the backend rejects a write', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { facility, changes } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
const seen = changes.length
|
||||
|
||||
pool.failNextWrites = 3
|
||||
await expect(table.put('a', { label: 'x', count: 99 })).rejects.toThrow(/injected/)
|
||||
await expect(table.update('a', c => ({ ...c, count: c.count + 1 }))).rejects.toThrow(/injected/)
|
||||
await expect(table.delete('a')).rejects.toThrow(/injected/)
|
||||
|
||||
// Reads still serve the pre-failure record; no events leaked.
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(pool.media.get('demo')!.tables.get('items')!.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(changes).toHaveLength(seen)
|
||||
|
||||
// The chain survives rejections: the next write lands cleanly with no residue.
|
||||
await table.update('a', c => ({ ...c, count: c.count + 1 }))
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 2 })
|
||||
})
|
||||
|
||||
it('keeps serving initial when the first global set fails durability', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { facility } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
pool.failNextWrites = 1
|
||||
await expect(domain.global.set({ theme: 'dark' })).rejects.toThrow(/injected/)
|
||||
expect(domain.global.get()).toEqual({ theme: 'plain' })
|
||||
expect(pool.media.get('demo')!.global).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
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('close and lifecycle', () => {
|
||||
it('close drains queued writes, then rejects reads and writes, and frees the name', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { 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 Promise.all([domain.close(), domain.close()]) // idempotent
|
||||
await pending // queued before close → 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/)
|
||||
// The name is free again: reopening sees the drained state.
|
||||
const reopened = await facility.open(spec)
|
||||
expect([...reopened.table('items').keys()].sort()).toEqual(['a', 'b'])
|
||||
})
|
||||
|
||||
it('facility unmount closes domains the consumer never closed', 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' })
|
||||
const domain = await ctx.storage.domain.open(bareSpec)
|
||||
const table = domain.table('rows')
|
||||
await table.put('a', { label: 'x', count: 1 })
|
||||
await fiber.dispose()
|
||||
await expect(table.put('b', { label: 'y', count: 2 })).rejects.toMatchObject({ code: 'closed' })
|
||||
expect(() => ctx.storage.form('domain')).toThrow(/not mounted/)
|
||||
})
|
||||
|
||||
it('contains a throwing domain/changed listener without rejecting the committed write', async () => {
|
||||
const pool = new MemoryMediaPool()
|
||||
const { ctx, facility, changes } = await harness({ pool })
|
||||
const domain = await facility.open(spec)
|
||||
const table = domain.table('items')
|
||||
ctx.on('domain/changed', () => {
|
||||
throw new Error('hostile observer')
|
||||
})
|
||||
await expect(table.put('a', { label: 'x', count: 1 })).resolves.toBeUndefined()
|
||||
// Commit survived intact on both planes, and well-behaved listeners
|
||||
// (registered before the thrower) still observed the event.
|
||||
expect(table.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(pool.media.get('demo')!.tables.get('items')!.get('a')).toEqual({ label: 'x', count: 1 })
|
||||
expect(changes).toHaveLength(1)
|
||||
// The chain is unpoisoned: subsequent writes proceed normally.
|
||||
await expect(table.delete('a')).resolves.toBe(true)
|
||||
})
|
||||
})
|
||||
160
packages/storage/storage-domain/tests/helpers/memory-backend.ts
Normal file
160
packages/storage/storage-domain/tests/helpers/memory-backend.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* 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 (`null` = never written). */
|
||||
export interface MemoryMedium {
|
||||
tables: Map<string, Map<string, unknown>>
|
||||
global: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, and
|
||||
* `failNextWrites` injects write-primitive failures.
|
||||
*/
|
||||
export class MemoryMediaPool {
|
||||
/** Unit name → its records; a missing entry is a never-materialized unit. */
|
||||
readonly media = new Map<string, MemoryMedium>()
|
||||
/** Unit name → stamped version; tests may pre-stamp to force `version-mismatch`. */
|
||||
readonly versions = new Map<string, number>()
|
||||
/**
|
||||
* When positive, that many subsequent write primitives (putRecord /
|
||||
* deleteRecord / setGlobal) reject without touching the medium, decrementing
|
||||
* per rejection. Negative-path seam: callers assert their state is
|
||||
* untouched after a durability failure.
|
||||
*/
|
||||
failNextWrites = 0
|
||||
|
||||
/** Consume one injected failure, throwing in a rejected write's place. */
|
||||
consumeInjectedFailure(): void {
|
||||
if (this.failNextWrites > 0) {
|
||||
this.failNextWrites -= 1
|
||||
throw new Error('injected write failure')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** In-memory KV unit over one pooled medium. */
|
||||
class MemoryKvUnit implements KvUnit {
|
||||
private closed = false
|
||||
|
||||
constructor(
|
||||
private readonly pool: MemoryMediaPool,
|
||||
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<string, Record<string, unknown>>; global: unknown }> {
|
||||
this.assertOpen()
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
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<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
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<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
this.medium.tables.get(table)?.delete(key)
|
||||
}
|
||||
|
||||
async setGlobal(value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
this.pool.consumeInjectedFailure()
|
||||
this.medium.global = value
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
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<string>()
|
||||
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<KvUnit> => {
|
||||
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(this.pool, medium, descriptor, () => this.openUnits.delete(descriptor.name))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closed = true
|
||||
this.openUnits.clear()
|
||||
}
|
||||
}
|
||||
91
packages/storage/storage-domain/tests/invariant.spec.ts
Normal file
91
packages/storage/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-storage-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: unknown = expect.objectContaining<Partial<InvariantError>>({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-storage-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()
|
||||
})
|
||||
})
|
||||
27
packages/storage/storage-domain/tsconfig.json
Normal file
27
packages/storage/storage-domain/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
36
packages/storage/storage-json/README.md
Normal file
36
packages/storage/storage-json/README.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# @deepseek-ai/dsh-storage-json
|
||||
|
||||
JSON backend for the [storage hub](../storage/README.md): one human-readable `<unit>.json` file per unit under a configured root, registered as backend `json`. Design: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Model
|
||||
|
||||
- The in-memory unit state is authoritative; every write primitive republishes the whole file via temp-write + fsync + atomic `rename()` replace. A unit file is always the complete current net state — legibility is this backend's reason to exist; scale is the SQLite backend's job.
|
||||
- A missing file opens as an empty unit and materializes on the first write. A foreign or unparsable file rejects with `malformed-medium`; a stored version differing from the descriptor rejects with `version-mismatch` (no migration, pre-release stance).
|
||||
- Write ordering across calls belongs to the caller (the domain layer's write chain); each single call is atomic and durable once resolved.
|
||||
|
||||
## Config
|
||||
|
||||
| Key | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `root` | string | required — no default (a cwd fallback would scatter files) | Directory holding unit files; created `0o700` on demand |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### 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
|
||||
|
||||
- Windows durability relies on libuv's `rename()` (`MoveFileExW` with replacement) without an explicit write-through flag; the session-log backend's stricter Win32 write-through publish helper is planned to move down here when the append-log facet lands (see the Agent Note's migration section).
|
||||
- No cross-process write locking: two processes writing the same root can interleave whole-file replacements (last write wins). Single-host-process deployments are the current consumer; the multi-process story is deferred per the Agent Note's out-of-scope table.
|
||||
42
packages/storage/storage-json/package.json
Normal file
42
packages/storage/storage-json/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage-json",
|
||||
"description": "JSON file KV storage backend for the DeepSeek Harness storage hub",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
53
packages/storage/storage-json/src/atomic.ts
Normal file
53
packages/storage/storage-json/src/atomic.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Atomic whole-file replacement for the JSON backend.
|
||||
*
|
||||
* Publish protocol: write a same-directory temp file, fsync it, then
|
||||
* `rename()` over the target. Rename is an atomic replace on POSIX and on
|
||||
* Windows (libuv maps it to `MoveFileExW(..., MOVEFILE_REPLACE_EXISTING)`),
|
||||
* and replacement is the intended semantic here — unlike the session-log
|
||||
* backend's link()+unlink() no-clobber protocol, a unit file has exactly one
|
||||
* writer per process and last-write-wins is correct. After the rename the
|
||||
* parent directory is fsynced on POSIX so the new entry is crash-durable.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/atomic
|
||||
*/
|
||||
|
||||
import { open, rename, rm } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/**
|
||||
* Durably replace `path` with `data`.
|
||||
* @param path - Absolute target file path.
|
||||
* @param data - Full new file content.
|
||||
* @returns resolution after the replacement is crash-durable.
|
||||
*/
|
||||
export async function writeAtomic(path: string, data: string): Promise<void> {
|
||||
const tmp = join(dirname(path), `.${randomUUID()}.tmp`)
|
||||
try {
|
||||
const handle = await open(tmp, 'wx', 0o600)
|
||||
try {
|
||||
await handle.writeFile(data, 'utf8')
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
await rename(tmp, path)
|
||||
await fsyncDirectory(dirname(path))
|
||||
} catch (error) {
|
||||
await rm(tmp, { force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** fsync a POSIX directory so a just-renamed entry is crash-durable. */
|
||||
/* v8 ignore start -- Windows rejects O_RDONLY directory opens; POSIX coverage exercises this. */
|
||||
async function fsyncDirectory(path: string): Promise<void> {
|
||||
if (process.platform === 'win32') return
|
||||
const handle = await open(path, 'r')
|
||||
try {
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
84
packages/storage/storage-json/src/format.ts
Normal file
84
packages/storage/storage-json/src/format.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* On-disk JSON unit format: the file is always the current net state, kept
|
||||
* human-readable (pretty-printed, stable key order from insertion) — that
|
||||
* legibility is this backend's reason to exist.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/format
|
||||
*/
|
||||
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** In-memory authoritative state of one unit; the file is its projection. `global` is `null` until first written. */
|
||||
export interface UnitState {
|
||||
version: number
|
||||
global: unknown
|
||||
tables: Map<string, Map<string, unknown>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a unit state to file content.
|
||||
* @param name - Unit name, stamped into the header.
|
||||
* @param state - Authoritative in-memory state.
|
||||
* @returns pretty-printed JSON document with a trailing newline.
|
||||
*/
|
||||
export function serialize(name: string, state: UnitState): string {
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [table, records] of state.tables) {
|
||||
tables[table] = Object.fromEntries(records)
|
||||
}
|
||||
const document = {
|
||||
unit: { name, version: state.version },
|
||||
global: state.global,
|
||||
tables,
|
||||
}
|
||||
return `${JSON.stringify(document, null, 2)}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse file content into unit state, validating shape and version.
|
||||
* @param text - Raw file content.
|
||||
* @param descriptor - Expected identity; version mismatch rejects.
|
||||
* @returns the parsed state.
|
||||
*/
|
||||
export function parse(text: string, descriptor: KvUnitDescriptor): UnitState {
|
||||
let document: unknown
|
||||
try {
|
||||
document = JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not valid JSON`, { cause: error })
|
||||
}
|
||||
if (typeof document !== 'object' || document === null) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not a JSON object`)
|
||||
}
|
||||
const { unit, global: globalValue, tables } = document as Record<string, unknown>
|
||||
if (
|
||||
typeof unit !== 'object' || unit === null ||
|
||||
(unit as Record<string, unknown>)['name'] !== descriptor.name ||
|
||||
typeof (unit as Record<string, unknown>)['version'] !== 'number'
|
||||
) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': missing or foreign unit header`)
|
||||
}
|
||||
const version = (unit as Record<string, unknown>)['version'] as number
|
||||
if (version !== descriptor.version) {
|
||||
throw new StorageError(
|
||||
'version-mismatch',
|
||||
`unit '${descriptor.name}': stored version ${version} != expected ${descriptor.version}`,
|
||||
)
|
||||
}
|
||||
if (typeof tables !== 'object' || tables === null) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': tables is not an object`)
|
||||
}
|
||||
const state: UnitState = { version, global: globalValue ?? null, tables: new Map() }
|
||||
for (const table of descriptor.tables) {
|
||||
const records = (tables as Record<string, unknown>)[table]
|
||||
if (records === undefined) {
|
||||
state.tables.set(table, new Map())
|
||||
continue
|
||||
}
|
||||
if (typeof records !== 'object' || records === null || Array.isArray(records)) {
|
||||
throw new StorageError('malformed-medium', `unit '${descriptor.name}': table '${table}' is not an object`)
|
||||
}
|
||||
state.tables.set(table, new Map(Object.entries(records as Record<string, unknown>)))
|
||||
}
|
||||
return state
|
||||
}
|
||||
113
packages/storage/storage-json/src/index.ts
Normal file
113
packages/storage/storage-json/src/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* JSON storage backend: one human-readable file per unit under a configured
|
||||
* root, published by atomic whole-file rewrite. Registers as backend `json`
|
||||
* on the storage hub.
|
||||
* @module @deepseek-ai/dsh-storage-json
|
||||
*/
|
||||
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
import { openJsonUnit } from './unit.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'storage-json'
|
||||
/** The hub must exist before the backend can register. */
|
||||
export const inject = ['storage']
|
||||
|
||||
/**
|
||||
* Plugin configuration.
|
||||
* `root` has NO default on purpose: a `process.cwd()` fallback would scatter
|
||||
* unit files wherever the process happens to start; assemblies state the
|
||||
* location explicitly.
|
||||
*/
|
||||
export interface Config {
|
||||
/** Directory holding one `<unit>.json` file per unit. */
|
||||
root: string
|
||||
}
|
||||
|
||||
/** Config schema. */
|
||||
export const Config: z<Config> = z.object({
|
||||
root: z.string().required(),
|
||||
})
|
||||
|
||||
/** JSON backend: owns the file-tree root and serves the `kv` facet. */
|
||||
export class JsonStorageBackend implements StorageBackend {
|
||||
private readonly open = new Map<string, KvUnit>()
|
||||
// Reserved synchronously at open() entry so a concurrent open of the same
|
||||
// unit fails, and close() can await opens still in flight.
|
||||
private readonly opening = new Map<string, Promise<KvUnit>>()
|
||||
private closed = false
|
||||
|
||||
constructor(private readonly root: string) {}
|
||||
|
||||
readonly kv: KvFacet = {
|
||||
// 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.
|
||||
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)
|
||||
return opening.finally(() => this.opening.delete(descriptor.name))
|
||||
},
|
||||
}
|
||||
|
||||
private async openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
const path = join(this.root, `${descriptor.name}.json`)
|
||||
const unit = await openJsonUnit(descriptor, path, () => this.open.delete(descriptor.name))
|
||||
if (this.closed) {
|
||||
// The backend closed while this open was in flight: do not hand out a
|
||||
// live unit past close().
|
||||
await unit.close()
|
||||
throw new StorageError('closed', 'json backend is closed')
|
||||
}
|
||||
this.open.set(descriptor.name, unit)
|
||||
return unit
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (!this.closed) {
|
||||
this.closed = true
|
||||
}
|
||||
await Promise.allSettled([...this.opening.values()])
|
||||
for (const unit of [...this.open.values()]) {
|
||||
await unit.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateDescriptor(descriptor: KvUnitDescriptor): void {
|
||||
if (!UNIT_NAME_RE.test(descriptor.name)) {
|
||||
throw new StorageError('malformed-medium', `invalid unit name '${descriptor.name}'`)
|
||||
}
|
||||
for (const table of descriptor.tables) {
|
||||
if (!UNIT_NAME_RE.test(table)) {
|
||||
throw new StorageError('malformed-medium', `invalid table name '${table}' in unit '${descriptor.name}'`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the `json` backend on the storage hub.
|
||||
* @param ctx - Plugin context.
|
||||
* @param config - Validated configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const backend = new JsonStorageBackend(config.root)
|
||||
ctx.effect(() => {
|
||||
const unregister = ctx.storage.backend.register('json', backend)
|
||||
return async () => {
|
||||
unregister()
|
||||
await backend.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
32
packages/storage/storage-json/src/invariant.ts
Normal file
32
packages/storage/storage-json/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-json`.
|
||||
* @module @deepseek-ai/dsh-storage-json/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage-json'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-json-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: correctness here is write-durability and
|
||||
* publish-then-reparse equivalence, which require medium round-trip tests
|
||||
* (the shared backend conformance suite); the backend exposes no continuously
|
||||
* observable in-process relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
141
packages/storage/storage-json/src/unit.ts
Normal file
141
packages/storage/storage-json/src/unit.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* One opened JSON unit. The in-memory state is authoritative; every write
|
||||
* primitive mutates it and republishes the whole file atomically. Writes are
|
||||
* NOT queued here — per the backend contract, write ordering belongs to the
|
||||
* caller (the domain layer's write chain); this unit only guarantees that
|
||||
* each single call publishes a complete, durable file.
|
||||
* @module @deepseek-ai/dsh-storage-json/src/unit
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
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`.
|
||||
* @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,
|
||||
onClose: () => void,
|
||||
): Promise<KvUnit> {
|
||||
let text: string | undefined
|
||||
try {
|
||||
text = await readFile(path, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
// Missing file = empty unit; materialization defers to the first write.
|
||||
}
|
||||
const state: UnitState =
|
||||
text === undefined
|
||||
? {
|
||||
version: descriptor.version,
|
||||
global: null,
|
||||
tables: new Map(descriptor.tables.map(table => [table, new Map<string, unknown>()])),
|
||||
}
|
||||
: parse(text, descriptor)
|
||||
return new JsonKvUnit(descriptor, path, state, onClose)
|
||||
}
|
||||
|
||||
class JsonKvUnit implements KvUnit {
|
||||
private closed = false
|
||||
/** In-flight publishes; close() drains them before releasing the unit. */
|
||||
private readonly inFlight = new Set<Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly descriptor: KvUnitDescriptor,
|
||||
private readonly path: string,
|
||||
private readonly state: UnitState,
|
||||
private readonly onClose: () => void,
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- async keeps the closed guard a rejection, not a synchronous throw
|
||||
async loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
||||
this.assertOpen()
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [table, records] of this.state.tables) {
|
||||
tables[table] = Object.fromEntries(records)
|
||||
}
|
||||
return { tables, global: this.state.global }
|
||||
}
|
||||
|
||||
async putRecord(table: string, key: string, value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
const records = this.records(table)
|
||||
const hadKey = records.has(key)
|
||||
const previous = records.get(key)
|
||||
records.set(key, value)
|
||||
// Roll back on a failed publish: memory is authoritative, so a rejected
|
||||
// write must not survive in memory (or ride along with the next publish).
|
||||
await this.publish().catch((error: unknown) => {
|
||||
if (hadKey) records.set(key, previous)
|
||||
else records.delete(key)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async deleteRecord(table: string, key: string): Promise<void> {
|
||||
this.assertOpen()
|
||||
const records = this.records(table)
|
||||
if (!records.has(key)) return
|
||||
const previous = records.get(key)
|
||||
records.delete(key)
|
||||
await this.publish().catch((error: unknown) => {
|
||||
records.set(key, previous)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async setGlobal(value: unknown): Promise<void> {
|
||||
this.assertOpen()
|
||||
if (!this.descriptor.hasGlobal) {
|
||||
throw new Error(`unit '${this.descriptor.name}' does not declare a global slot`)
|
||||
}
|
||||
const previous = this.state.global
|
||||
this.state.global = value
|
||||
await this.publish().catch((error: unknown) => {
|
||||
this.state.global = previous
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
if (this.closed) {
|
||||
await Promise.allSettled(this.inFlight)
|
||||
return
|
||||
}
|
||||
this.closed = true
|
||||
await Promise.allSettled(this.inFlight)
|
||||
this.onClose()
|
||||
}
|
||||
|
||||
private assertOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new StorageError('closed', `unit '${this.descriptor.name}' is closed`)
|
||||
}
|
||||
}
|
||||
|
||||
private records(table: string): Map<string, unknown> {
|
||||
const records = this.state.tables.get(table)
|
||||
if (!records) {
|
||||
throw new Error(`unit '${this.descriptor.name}' does not declare table '${table}'`)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private publish(): Promise<void> {
|
||||
const write = writeAtomic(this.path, serialize(this.descriptor.name, this.state))
|
||||
this.inFlight.add(write)
|
||||
// Swallow only on the tracking branch: the caller still awaits `write`
|
||||
// itself, so rejections stay observed exactly once.
|
||||
write.catch(() => {}).finally(() => this.inFlight.delete(write))
|
||||
return write
|
||||
}
|
||||
}
|
||||
222
packages/storage/storage-json/tests/json-backend.spec.ts
Normal file
222
packages/storage/storage-json/tests/json-backend.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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 { Config, JsonStorageBackend, apply } from '../src/index.ts'
|
||||
import * as InvariantCompanion from '../src/invariant.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
|
||||
async function freshRoot(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-storage-json-'))
|
||||
roots.push(root)
|
||||
return root
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
for (const root of roots) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
runKvBackendContract('json', async () => {
|
||||
const root = await freshRoot()
|
||||
return {
|
||||
backend: new JsonStorageBackend(root),
|
||||
reopen: async () => new JsonStorageBackend(root),
|
||||
}
|
||||
})
|
||||
|
||||
describe('json backend specifics', () => {
|
||||
const descriptor = { name: 'shape', version: 1, tables: ['t'], hasGlobal: true }
|
||||
|
||||
it('publishes a human-readable pretty-printed file', async () => {
|
||||
const root = await freshRoot()
|
||||
const backend = new JsonStorageBackend(root)
|
||||
const unit = await backend.kv.open(descriptor)
|
||||
await unit.putRecord('t', 'k', { hello: 'world' })
|
||||
const text = await readFile(join(root, 'shape.json'), 'utf8')
|
||||
expect(text).toBe(`${JSON.stringify(
|
||||
{ unit: { name: 'shape', version: 1 }, global: null, tables: { t: { k: { hello: 'world' } } } },
|
||||
null,
|
||||
2,
|
||||
)}\n`)
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('defers materialization until the first write', async () => {
|
||||
const root = await freshRoot()
|
||||
const backend = new JsonStorageBackend(root)
|
||||
await backend.kv.open(descriptor)
|
||||
await expect(readFile(join(root, 'shape.json'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a malformed medium', async () => {
|
||||
const root = await freshRoot()
|
||||
await writeFile(join(root, 'shape.json'), 'not json at all', 'utf8')
|
||||
const backend = new JsonStorageBackend(root)
|
||||
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a foreign unit header', async () => {
|
||||
const root = await freshRoot()
|
||||
await writeFile(
|
||||
join(root, 'shape.json'),
|
||||
JSON.stringify({ unit: { name: 'other', version: 1 }, global: null, tables: {} }),
|
||||
'utf8',
|
||||
)
|
||||
const backend = new JsonStorageBackend(root)
|
||||
await expect(backend.kv.open(descriptor)).rejects.toMatchObject({ code: 'malformed-medium' })
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects double-open of one unit as a plain caller error', async () => {
|
||||
const root = await freshRoot()
|
||||
const backend = new JsonStorageBackend(root)
|
||||
await backend.kv.open(descriptor)
|
||||
await expect(backend.kv.open(descriptor)).rejects.toThrow(/already open/)
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rolls back memory when a publish fails', async () => {
|
||||
const root = await freshRoot()
|
||||
const backend = new JsonStorageBackend(root)
|
||||
const unit = await backend.kv.open(descriptor)
|
||||
await unit.putRecord('t', 'k', { v: 'committed' })
|
||||
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')
|
||||
expect(text).not.toContain('rejected')
|
||||
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)
|
||||
const unit = await backend.kv.open(descriptor)
|
||||
const bigWrite = unit.putRecord('t', 'big', { blob: 'x'.repeat(4 * 1024 * 1024) })
|
||||
await unit.close()
|
||||
await expect(bigWrite).resolves.toBeUndefined()
|
||||
const onDisk = JSON.parse(await readFile(join(root, 'shape.json'), 'utf8')) as {
|
||||
tables: Record<string, Record<string, unknown>>
|
||||
}
|
||||
expect(onDisk.tables['t']?.['big']).toBeDefined()
|
||||
|
||||
const backend2 = new JsonStorageBackend(root)
|
||||
const opening = backend2.kv.open(descriptor)
|
||||
const closing = backend2.close()
|
||||
await expect(opening.then(u => u.putRecord('t', 'x', {}))).rejects.toMatchObject({ code: 'closed' })
|
||||
await closing
|
||||
})
|
||||
})
|
||||
27
packages/storage/storage-json/tsconfig.json
Normal file
27
packages/storage/storage-json/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
41
packages/storage/storage-sqlite/README.md
Normal file
41
packages/storage/storage-sqlite/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @deepseek-ai/dsh-storage-sqlite
|
||||
|
||||
SQLite backend for the [storage hub](../storage/README.md): registers as backend `sqlite`, serving the `kv` facet over one `node:sqlite` database file (or `:memory:`). Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Storage model
|
||||
|
||||
Document-per-row: each unit table becomes a physical `"u_<unit>_<table>" (key TEXT PRIMARY KEY, value TEXT)` STRICT table whose `value` is the record's JSON text, so one key updates one row (the reason to route a high-churn domain here instead of the JSON backend). Unit identity lives in two metadata tables — `units` stamps each unit's format version at first open and rejects a differing descriptor with `version-mismatch`; `unit_globals` holds each unit's global singleton row. The physical layout version lives in `PRAGMA user_version`; any other stamped value rejects (unreleased format, no migrations). Unit and table names are validated against the hub's `UNIT_NAME_RE` before they reach DDL, so no external input is ever interpolated into SQL identifiers.
|
||||
|
||||
Every write primitive is a single prepared statement — SQLite's per-statement atomicity satisfies the KV contract without explicit transactions, and write ordering stays the caller's responsibility (the domain layer's write chain). Missing directories and database files are created owner-only (`0o700`/`0o600`), matching the session-persistence SQLite backend, whose open sequence this package copies verbatim until the planned media-layer extraction.
|
||||
|
||||
## Configuration (schemastery)
|
||||
|
||||
```ts
|
||||
interface Config {
|
||||
path: string // SQLite database file path, or ':memory:' for an in-process DB
|
||||
journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal'
|
||||
}
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Stored domain records
|
||||
|
||||
#### What the model sees
|
||||
|
||||
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
|
||||
|
||||
None — the backend never touches live request prefixes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`DatabaseSync` is synchronous** — each write blocks the event loop for its (single-statement) duration; acceptable at domain-data scale.
|
||||
- **No busy-wait or retry policy** — another connection holding a write transaction rejects the operation immediately; multi-process write protection is on the design's future-work list.
|
||||
- **Only the current `STORAGE_SQLITE_SCHEMA_VERSION` opens** — any other stamped version is rejected rather than migrated (pre-release stance).
|
||||
- **`openDatabase` duplicates the session-persistence SQLite open sequence** — extraction into a shared media layer is deferred to the planned session-backend migration (see the Agent Note's reuse audit).
|
||||
42
packages/storage/storage-sqlite/package.json
Normal file
42
packages/storage/storage-sqlite/package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage-sqlite",
|
||||
"description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
167
packages/storage/storage-sqlite/src/index.ts
Normal file
167
packages/storage/storage-sqlite/src/index.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* SQLite storage backend for the storage hub: one database file hosts every
|
||||
* routed unit, document-per-row (`key TEXT` / `value TEXT` JSON). Registers
|
||||
* as backend `sqlite`; the disposer unregisters first, then closes the medium.
|
||||
* @module @deepseek-ai/dsh-storage-sqlite
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { StorageError, UNIT_NAME_RE } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage'
|
||||
import { openDatabase, recordTableName, type JournalMode } from './schema.ts'
|
||||
import { SqliteKvUnit } from './unit.ts'
|
||||
|
||||
export { STORAGE_SQLITE_SCHEMA_VERSION, type JournalMode } from './schema.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'storage-sqlite'
|
||||
/** The backend registers on the storage hub. */
|
||||
export const inject = ['storage']
|
||||
|
||||
/** Plugin configuration. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Filesystem path to the SQLite database file. The special value `:memory:`
|
||||
* opens an in-process database (tests). On filesystems with POSIX modes,
|
||||
* missing directories and databases are created owner-only; existing path
|
||||
* modes are preserved. Filesystem setup errors other than an existing
|
||||
* database fail the open. The backend does not protect confidentiality or
|
||||
* integrity when another principal can replace the database entry in its
|
||||
* parent directory.
|
||||
*/
|
||||
path: string
|
||||
/**
|
||||
* SQLite `journal_mode` pragma. `wal` (the default) suits local disks; pick
|
||||
* a rollback-journal mode (`delete`/`truncate`/`persist`) on filesystems
|
||||
* where WAL's shared-memory files do not work (network mounts). See
|
||||
* {@link JournalMode}.
|
||||
*/
|
||||
journalMode?: JournalMode
|
||||
}
|
||||
|
||||
/** Schemastery validator for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
path: z.string().required(),
|
||||
journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'),
|
||||
})
|
||||
|
||||
/**
|
||||
* The SQLite {@link StorageBackend}. Owns one `DatabaseSync` connection and
|
||||
* the open-unit table; `kv.open` validates names, enforces the per-unit
|
||||
* version stamp in `units`, and ensures the unit's record tables.
|
||||
*/
|
||||
export class SqliteStorageBackend implements StorageBackend {
|
||||
/** The key-value facet; the only shape this backend serves. */
|
||||
readonly kv: KvFacet = { open: descriptor => this.openUnit(descriptor) }
|
||||
|
||||
private readonly ready: Promise<DatabaseSync>
|
||||
/** Open (or still-opening) units by name; presence is the double-open guard. */
|
||||
private readonly units = new Map<string, Promise<SqliteKvUnit>>()
|
||||
private closing: Promise<void> | undefined
|
||||
|
||||
/**
|
||||
* @param config - Validated plugin configuration.
|
||||
*/
|
||||
constructor(config: Config) {
|
||||
this.ready = openDatabase(config.path, (config as Required<Config>).journalMode)
|
||||
// Mark the rejection handled: every primitive re-awaits `ready`, so an
|
||||
// open failure still surfaces to each caller; this guard only prevents an
|
||||
// unhandled-rejection crash when the failure precedes the first use.
|
||||
this.ready.catch(() => {})
|
||||
}
|
||||
|
||||
private openUnit(descriptor: KvUnitDescriptor): Promise<KvUnit> {
|
||||
if (this.closing !== undefined) {
|
||||
return Promise.reject(new StorageError('closed', 'sqlite storage backend is closed'))
|
||||
}
|
||||
if (!UNIT_NAME_RE.test(descriptor.name)) {
|
||||
return Promise.reject(new Error(`kv unit name '${descriptor.name}' violates ${UNIT_NAME_RE}`))
|
||||
}
|
||||
for (const table of descriptor.tables) {
|
||||
if (!UNIT_NAME_RE.test(table)) {
|
||||
return Promise.reject(new Error(`kv table name '${table}' in unit '${descriptor.name}' violates ${UNIT_NAME_RE}`))
|
||||
}
|
||||
}
|
||||
if (this.units.has(descriptor.name)) {
|
||||
return Promise.reject(new Error(`kv unit '${descriptor.name}' is already open (double-open is a caller bug)`))
|
||||
}
|
||||
// Reserve the name synchronously so a concurrent second open of the same
|
||||
// name rejects instead of racing past the guard during the awaits below.
|
||||
const pending = this.materializeUnit(descriptor)
|
||||
this.units.set(descriptor.name, pending)
|
||||
pending.catch(() => this.units.delete(descriptor.name))
|
||||
return pending
|
||||
}
|
||||
|
||||
private async materializeUnit(descriptor: KvUnitDescriptor): Promise<SqliteKvUnit> {
|
||||
const db = await this.ready
|
||||
const row = db.prepare('SELECT version FROM units WHERE name = ?').get(descriptor.name) as
|
||||
| { version: number }
|
||||
| undefined
|
||||
if (row === undefined) {
|
||||
db.prepare('INSERT INTO units (name, version) VALUES (?, ?)').run(descriptor.name, descriptor.version)
|
||||
} else if (row.version !== descriptor.version) {
|
||||
throw new StorageError(
|
||||
'version-mismatch',
|
||||
`kv unit '${descriptor.name}' is stamped version ${row.version} on the medium, incompatible with descriptor version ${descriptor.version}`,
|
||||
)
|
||||
}
|
||||
for (const table of descriptor.tables) {
|
||||
// Both segments passed UNIT_NAME_RE, so the identifier is safe in DDL.
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS "${recordTableName(descriptor.name, table)}" (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
}
|
||||
return new SqliteKvUnit(db, descriptor, () => {
|
||||
this.units.delete(descriptor.name)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Close every open unit and release the database. Idempotent; concurrent
|
||||
* and repeated calls resolve once teardown finishes.
|
||||
* @returns resolution after the medium is released.
|
||||
*/
|
||||
close(): Promise<void> {
|
||||
this.closing ??= this.doClose()
|
||||
return this.closing
|
||||
}
|
||||
|
||||
private async doClose(): Promise<void> {
|
||||
let db: DatabaseSync
|
||||
try {
|
||||
db = await this.ready
|
||||
} catch {
|
||||
// The medium never opened; that failure already rejected the opener and
|
||||
// every unit call, so there is nothing left to release here.
|
||||
return
|
||||
}
|
||||
for (const pending of [...this.units.values()]) {
|
||||
const unit = await pending.catch(() => undefined)
|
||||
await unit?.close()
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the SQLite backend as `sqlite` on the storage hub. The disposer
|
||||
* unregisters the name first, then closes the backend.
|
||||
* @param ctx - Plugin context (must inject `storage`).
|
||||
* @param config - Validated plugin configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config) {
|
||||
const backend = new SqliteStorageBackend(config)
|
||||
ctx.effect(() => {
|
||||
const dispose = ctx.storage.backend.register('sqlite', backend)
|
||||
return async () => {
|
||||
dispose()
|
||||
await backend.close()
|
||||
}
|
||||
}, 'storage-sqlite.registerBackend')
|
||||
}
|
||||
32
packages/storage/storage-sqlite/src/invariant.ts
Normal file
32
packages/storage/storage-sqlite/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage-sqlite`.
|
||||
* @module @deepseek-ai/dsh-storage-sqlite/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage-sqlite'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-sqlite-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: schema-version and unit-version consistency are
|
||||
* open-time checks that reject before a unit exists, and durability needs the
|
||||
* backend round-trip tests in the shared KV conformance suite; this package
|
||||
* exposes no continuously observable in-process relation.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
120
packages/storage/storage-sqlite/src/schema.ts
Normal file
120
packages/storage/storage-sqlite/src/schema.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Schema + open-time helpers for the SQLite storage backend: the physical
|
||||
* layout version, the database open/configure sequence (permissions, pragmas,
|
||||
* version stamp/reject), and the unit metadata tables. Unit record tables are
|
||||
* created per descriptor in `unit.ts`.
|
||||
* @module @deepseek-ai/dsh-storage-sqlite/schema
|
||||
*/
|
||||
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdir, open } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
|
||||
/**
|
||||
* The on-disk physical layout version, stored in `PRAGMA user_version`.
|
||||
* Orthogonal to each unit's own `version` (stamped per unit in the `units`
|
||||
* row). Bumped only on a breaking change to the table layout; any other
|
||||
* stamped version rejects — this unreleased format has no migrations.
|
||||
*/
|
||||
export const STORAGE_SQLITE_SCHEMA_VERSION = 1
|
||||
|
||||
/**
|
||||
* Journal modes the backend will run under. `wal` is the default; the
|
||||
* rollback-journal modes (`delete`/`truncate`/`persist`) exist for
|
||||
* filesystems where WAL's shared-memory files do not work (network mounts).
|
||||
* `memory`/`off` are excluded: dropping journal durability silently
|
||||
* contradicts the durability clause of the KV backend contract.
|
||||
*/
|
||||
export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist'
|
||||
|
||||
/* jscpd:ignore-start -- deliberately mirrors the session-persistence-sqlite /
|
||||
session-query-sqlite open sequence; this group is the third user, and the
|
||||
shared medium helper is deferred to the log-facet migration so the session
|
||||
packages stay untouched this phase (see the domain KV storage Agent Note's
|
||||
reuse audit). */
|
||||
/**
|
||||
* Exclusively create a missing database file with owner-only permissions.
|
||||
* Existing files retain their modes, and errors other than `EEXIST` propagate.
|
||||
* `DatabaseSync` reopens by path, so this does not protect confidentiality or
|
||||
* integrity when another principal can replace the database entry in its
|
||||
* parent directory.
|
||||
*/
|
||||
async function createDatabaseFile(path: string): Promise<void> {
|
||||
try {
|
||||
const handle = await open(path, 'wx', 0o600)
|
||||
await handle.close()
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the database and apply its schema and pragmas. Missing directories and
|
||||
* database files are created owner-only (`:memory:` skips filesystem setup).
|
||||
* A zero `user_version` is stamped with {@link STORAGE_SQLITE_SCHEMA_VERSION};
|
||||
* every other non-current version rejects rather than being migrated in place.
|
||||
* @param path - the SQLite database file to open, or `:memory:`.
|
||||
* @param journalMode - validated journal pragma.
|
||||
* @returns the open handle with pragmas applied and the unit metadata tables ensured.
|
||||
*/
|
||||
export async function openDatabase(path: string, journalMode: JournalMode): Promise<DatabaseSync> {
|
||||
const actual = path === ':memory:' ? path : resolve(path)
|
||||
if (actual !== ':memory:') {
|
||||
await mkdir(dirname(actual), { recursive: true, mode: 0o700 })
|
||||
await createDatabaseFile(actual)
|
||||
}
|
||||
const db = new DatabaseSync(actual)
|
||||
try {
|
||||
configureDatabase(db, actual, journalMode)
|
||||
return db
|
||||
} catch (error: unknown) {
|
||||
db.close()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalMode): void {
|
||||
db.exec('PRAGMA foreign_keys = ON')
|
||||
// The validated union is safe to interpolate into a non-bindable PRAGMA.
|
||||
db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`)
|
||||
// `PRAGMA user_version` always returns exactly one row { user_version }.
|
||||
const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number }
|
||||
if (onDisk !== 0 && onDisk !== STORAGE_SQLITE_SCHEMA_VERSION) {
|
||||
throw new StorageError(
|
||||
'version-mismatch',
|
||||
`storage database at "${path}" has schema version ${onDisk}, incompatible with this build (${STORAGE_SQLITE_SCHEMA_VERSION})`,
|
||||
)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS units (
|
||||
name TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS unit_globals (
|
||||
unit TEXT PRIMARY KEY REFERENCES units(name),
|
||||
value TEXT NOT NULL
|
||||
) STRICT
|
||||
`)
|
||||
if (onDisk === 0) {
|
||||
// Stamp fresh databases LAST: the stamp asserts the layout is complete,
|
||||
// so a failure above must leave the medium unstamped (a re-open after
|
||||
// the obstruction is cleared retries materialization from scratch).
|
||||
db.exec(`PRAGMA user_version = ${STORAGE_SQLITE_SCHEMA_VERSION}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical table name for one unit table. Both segments are validated against
|
||||
* `UNIT_NAME_RE` before reaching this, so the result is safe to interpolate
|
||||
* into DDL and prepared-statement text.
|
||||
* @param unit - Validated unit name.
|
||||
* @param table - Validated table name.
|
||||
* @returns the `u_<unit>_<table>` identifier.
|
||||
*/
|
||||
export function recordTableName(unit: string, table: string): string {
|
||||
return `u_${unit}_${table}`
|
||||
}
|
||||
156
packages/storage/storage-sqlite/src/unit.ts
Normal file
156
packages/storage/storage-sqlite/src/unit.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* One opened SQLite KV unit: prepared per-table statements over the
|
||||
* `u_<unit>_<table>` record tables plus this unit's row in the shared
|
||||
* `unit_globals` table. Each primitive is a single statement, so atomicity
|
||||
* comes from SQLite itself — no explicit transactions, and no write queue
|
||||
* (write ordering is the caller's responsibility per the KV contract).
|
||||
* @module @deepseek-ai/dsh-storage-sqlite/unit
|
||||
*/
|
||||
|
||||
import type { DatabaseSync, StatementSync } from 'node:sqlite'
|
||||
import { StorageError } from '@deepseek-ai/dsh-storage'
|
||||
import type { KvUnit, KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
|
||||
import { recordTableName } from './schema.ts'
|
||||
|
||||
/** Prepared statements for one declared table. */
|
||||
interface TableStatements {
|
||||
upsert: StatementSync
|
||||
remove: StatementSync
|
||||
selectAll: StatementSync
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQLite {@link KvUnit}. Constructed by the backend AFTER the unit's
|
||||
* record tables exist; statements are prepared once here and reused for every
|
||||
* primitive. Values are stored as JSON text in the `value` column.
|
||||
*/
|
||||
export class SqliteKvUnit implements KvUnit {
|
||||
private readonly tables = new Map<string, TableStatements>()
|
||||
private readonly globalUpsert: StatementSync | undefined
|
||||
private readonly globalSelect: StatementSync | undefined
|
||||
private closed = false
|
||||
|
||||
/**
|
||||
* @param db - Open database handle owned by the backend (never closed here).
|
||||
* @param descriptor - Validated descriptor whose record tables already exist.
|
||||
* @param onClose - Backend callback releasing this unit's open-name slot.
|
||||
*/
|
||||
constructor(
|
||||
db: DatabaseSync,
|
||||
private readonly descriptor: KvUnitDescriptor,
|
||||
private readonly onClose: () => void,
|
||||
) {
|
||||
for (const table of descriptor.tables) {
|
||||
// Both name segments are validated against UNIT_NAME_RE by the backend,
|
||||
// so the physical identifier is safe to interpolate into statement text.
|
||||
const physical = recordTableName(descriptor.name, table)
|
||||
this.tables.set(table, {
|
||||
upsert: db.prepare(
|
||||
`INSERT INTO "${physical}" (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,
|
||||
),
|
||||
remove: db.prepare(`DELETE FROM "${physical}" WHERE key = ?`),
|
||||
selectAll: db.prepare(`SELECT key, value FROM "${physical}"`),
|
||||
})
|
||||
}
|
||||
this.globalUpsert = descriptor.hasGlobal
|
||||
? db.prepare(
|
||||
'INSERT INTO unit_globals (unit, value) VALUES (?, ?) ON CONFLICT(unit) DO UPDATE SET value = excluded.value',
|
||||
)
|
||||
: undefined
|
||||
this.globalSelect = descriptor.hasGlobal
|
||||
? db.prepare('SELECT value FROM unit_globals WHERE unit = ?')
|
||||
: undefined
|
||||
}
|
||||
|
||||
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }> {
|
||||
return this.settle(() => {
|
||||
const tables: Record<string, Record<string, unknown>> = {}
|
||||
for (const [name, statements] of this.tables) {
|
||||
// Null prototype: record keys are arbitrary strings, so '__proto__'
|
||||
// must land as an own property instead of mutating the prototype.
|
||||
const records: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
for (const row of statements.selectAll.all() as unknown as Array<{ key: string; value: string }>) {
|
||||
records[row.key] = this.parseValue(row.value, `table '${name}' key '${row.key}'`)
|
||||
}
|
||||
tables[name] = records
|
||||
}
|
||||
let global: unknown = null
|
||||
if (this.globalSelect !== undefined) {
|
||||
const row = this.globalSelect.get(this.descriptor.name) as { value: string } | undefined
|
||||
if (row !== undefined) global = this.parseValue(row.value, 'global slot')
|
||||
}
|
||||
return { tables, global }
|
||||
})
|
||||
}
|
||||
|
||||
/** Parse one stored value column, mapping bad JSON to `malformed-medium`. */
|
||||
private parseValue(text: string, slot: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text)
|
||||
} catch (error) {
|
||||
throw new StorageError(
|
||||
'malformed-medium',
|
||||
`kv unit '${this.descriptor.name}' holds unparsable JSON at ${slot}`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
putRecord(table: string, key: string, value: unknown): Promise<void> {
|
||||
return this.settle(() => {
|
||||
this.statementsFor(table).upsert.run(key, JSON.stringify(value))
|
||||
})
|
||||
}
|
||||
|
||||
deleteRecord(table: string, key: string): Promise<void> {
|
||||
return this.settle(() => {
|
||||
this.statementsFor(table).remove.run(key)
|
||||
})
|
||||
}
|
||||
|
||||
setGlobal(value: unknown): Promise<void> {
|
||||
return this.settle(() => {
|
||||
if (this.globalUpsert === undefined) {
|
||||
throw new Error(`kv unit '${this.descriptor.name}' declared no global slot`)
|
||||
}
|
||||
this.globalUpsert.run(this.descriptor.name, JSON.stringify(value))
|
||||
})
|
||||
}
|
||||
|
||||
close(): Promise<void> {
|
||||
if (!this.closed) {
|
||||
this.closed = true
|
||||
this.onClose()
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one synchronous primitive behind the closed guard, mapping a throw to
|
||||
* a rejection so the Promise-returning contract never throws synchronously.
|
||||
*/
|
||||
private settle<T>(operation: () => T): Promise<T> {
|
||||
try {
|
||||
this.ensureOpen()
|
||||
return Promise.resolve(operation())
|
||||
} catch (error) {
|
||||
// Non-Error throws can only enter through JSON.stringify propagating a
|
||||
// value's own toJSON throw; wrap those, preserve every real Error.
|
||||
return Promise.reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
private ensureOpen(): void {
|
||||
if (this.closed) {
|
||||
throw new StorageError('closed', `kv unit '${this.descriptor.name}' is closed`)
|
||||
}
|
||||
}
|
||||
|
||||
private statementsFor(table: string): TableStatements {
|
||||
const statements = this.tables.get(table)
|
||||
if (statements === undefined) {
|
||||
throw new Error(`kv unit '${this.descriptor.name}' declared no table '${table}'`)
|
||||
}
|
||||
return statements
|
||||
}
|
||||
}
|
||||
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()
|
||||
})
|
||||
})
|
||||
263
packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts
Normal file
263
packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
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. */
|
||||
function backendAt(path: string): SqliteStorageBackend {
|
||||
return new SqliteStorageBackend(new Config({ path }))
|
||||
}
|
||||
|
||||
const dirs: string[] = []
|
||||
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
|
||||
|
||||
async function freshDbPath(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-storage-sqlite-'))
|
||||
dirs.push(dir)
|
||||
return join(dir, 'storage.db')
|
||||
}
|
||||
|
||||
// The contract suite's reopen() needs a surviving medium, so the harness binds
|
||||
// a real file; :memory: gets its own cases below.
|
||||
runKvBackendContract('sqlite', async () => {
|
||||
const path = await freshDbPath()
|
||||
return {
|
||||
backend: backendAt(path),
|
||||
reopen: async () => backendAt(path),
|
||||
}
|
||||
})
|
||||
|
||||
const DESCRIPTOR: KvUnitDescriptor = {
|
||||
name: 'specimen',
|
||||
version: 1,
|
||||
tables: ['records'],
|
||||
hasGlobal: true,
|
||||
}
|
||||
|
||||
describe('sqlite backend specifics', () => {
|
||||
it('opens an in-memory database', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'k', { n: 1 })
|
||||
expect((await unit.loadAll()).tables['records']).toEqual({ k: { n: 1 } })
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('materializes STRICT record tables and stamps the schema version', async () => {
|
||||
const path = await freshDbPath()
|
||||
const backend = backendAt(path)
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'k', { n: 1 })
|
||||
await backend.close()
|
||||
|
||||
const db = new DatabaseSync(path)
|
||||
try {
|
||||
const { user_version: version } = db.prepare('PRAGMA user_version').get() as { user_version: number }
|
||||
expect(version).toBe(STORAGE_SQLITE_SCHEMA_VERSION)
|
||||
const table = db.prepare(
|
||||
"SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'u_specimen_records'",
|
||||
).get() as { sql: string } | undefined
|
||||
expect(table?.sql).toContain('STRICT')
|
||||
const unitRow = db.prepare('SELECT version FROM units WHERE name = ?').get('specimen') as { version: number }
|
||||
expect(unitRow.version).toBe(DESCRIPTOR.version)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a mismatched database schema version', async () => {
|
||||
const path = await freshDbPath()
|
||||
const db = new DatabaseSync(path)
|
||||
db.exec('PRAGMA user_version = 999')
|
||||
db.close()
|
||||
|
||||
const backend = backendAt(path)
|
||||
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'version-mismatch',
|
||||
})
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects invalid unit and table names before touching the medium', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
await expect(backend.kv.open({ ...DESCRIPTOR, name: 'Bad-Name' })).rejects.toThrow(/violates/)
|
||||
await expect(backend.kv.open({ ...DESCRIPTOR, tables: ['ok', '1bad'] })).rejects.toThrow(/violates/)
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a second open of the same unit name', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
await backend.kv.open(DESCRIPTOR)
|
||||
await expect(backend.kv.open(DESCRIPTOR)).rejects.toThrow(/already open/)
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('allows re-open after unit close, and rejects open on a closed backend', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.close()
|
||||
const again = await backend.kv.open(DESCRIPTOR)
|
||||
await again.putRecord('records', 'k', 1)
|
||||
await backend.close()
|
||||
await expect(backend.kv.open(DESCRIPTOR)).rejects.toMatchObject({ code: 'closed' })
|
||||
})
|
||||
|
||||
it('round-trips prototype-polluting keys as own properties', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', '__proto__', { evil: true })
|
||||
await unit.putRecord('records', 'constructor', { n: 1 })
|
||||
const { tables } = await unit.loadAll()
|
||||
const records = tables['records']!
|
||||
expect(Object.hasOwn(records, '__proto__')).toBe(true)
|
||||
expect(records['__proto__']).toEqual({ evil: true })
|
||||
expect(records['constructor']).toEqual({ n: 1 })
|
||||
expect(Object.getPrototypeOf({})).not.toHaveProperty('evil')
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('leaves a failed materialization unstamped so a repaired medium reopens', async () => {
|
||||
const path = await freshDbPath()
|
||||
// Obstruct table creation: an index squatting on the unit_globals name
|
||||
// makes CREATE TABLE IF NOT EXISTS throw AFTER the units table exists.
|
||||
const setup = new DatabaseSync(path)
|
||||
setup.exec('CREATE TABLE squatter (x TEXT)')
|
||||
setup.exec('CREATE INDEX unit_globals ON squatter(x)')
|
||||
setup.close()
|
||||
|
||||
const broken = backendAt(path)
|
||||
await expect(broken.kv.open(DESCRIPTOR)).rejects.toThrow(/already an index/)
|
||||
await broken.close()
|
||||
|
||||
// Clear the obstruction; the medium must still be version 0, not a
|
||||
// half-materialized database stamped as current.
|
||||
const repair = new DatabaseSync(path)
|
||||
expect((repair.prepare('PRAGMA user_version').get() as { user_version: number }).user_version).toBe(0)
|
||||
repair.exec('DROP INDEX unit_globals')
|
||||
repair.close()
|
||||
|
||||
const backend = backendAt(path)
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'k', { n: 1 })
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects unparsable stored JSON with malformed-medium', async () => {
|
||||
const path = await freshDbPath()
|
||||
const backend = backendAt(path)
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.putRecord('records', 'good', { n: 1 })
|
||||
await unit.setGlobal({ g: 1 })
|
||||
await backend.close()
|
||||
|
||||
const db = new DatabaseSync(path)
|
||||
db.prepare('UPDATE u_specimen_records SET value = ? WHERE key = ?').run('{not json', 'good')
|
||||
db.close()
|
||||
|
||||
const reopened = backendAt(path)
|
||||
const damaged = await reopened.kv.open(DESCRIPTOR)
|
||||
await expect(damaged.loadAll()).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'malformed-medium',
|
||||
})
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('wraps a non-Error toJSON throw into an Error rejection', async () => {
|
||||
const backend = backendAt(':memory:')
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
// JSON.stringify propagates a value's own toJSON throw verbatim; the unit
|
||||
// must still reject with an Error instance.
|
||||
const hostile = { toJSON: () => { throw 'not an error' } }
|
||||
await expect(unit.putRecord('records', 'k', hostile)).rejects.toThrow('not an error')
|
||||
await expect(unit.putRecord('records', 'k', hostile)).rejects.toBeInstanceOf(Error)
|
||||
await backend.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)
|
||||
const unit = await backend.kv.open(DESCRIPTOR)
|
||||
await unit.setGlobal({ g: 1 })
|
||||
await backend.close()
|
||||
|
||||
const db = new DatabaseSync(path)
|
||||
db.prepare('UPDATE unit_globals SET value = ? WHERE unit = ?').run('][', 'specimen')
|
||||
db.close()
|
||||
|
||||
const reopened = backendAt(path)
|
||||
const damaged = await reopened.kv.open(DESCRIPTOR)
|
||||
await expect(damaged.loadAll()).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'malformed-medium',
|
||||
})
|
||||
await reopened.close()
|
||||
})
|
||||
})
|
||||
27
packages/storage/storage-sqlite/tsconfig.json
Normal file
27
packages/storage/storage-sqlite/tsconfig.json
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
39
packages/storage/storage/README.md
Normal file
39
packages/storage/storage/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @deepseek-ai/dsh-storage
|
||||
|
||||
Storage hub (`ctx.storage`) for non-session data: a named backend registry plus mounted data-form facilities. The hub performs no IO itself — backends own media, data forms own semantics. Design and trade-offs: [domain KV storage Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Shape
|
||||
|
||||
- `ctx.storage.backend` — name → backend table. Multiple backends stay mounted side by side (`json`, `sqlite`); which backend serves a consumer is that consumer's configuration (the domain layer's route table), never a hub-global choice. `register()` returns the disposer; duplicate names and unknown lookups fail loud.
|
||||
- `ctx.storage.mount(form, facility)` / `ctx.storage.form(form)` — data-form mounting. `StorageForms` is merge-extensible; the domain layer merges `domain` and is reached as `ctx.storage.domain`.
|
||||
- A backend owns one medium (file-tree root, database file) and exposes optional data-shape **facets** — `kv` today; an append-log facet is reserved for the future session-backend migration. `src/backend.ts` is the normative contract text; `tests/contract.ts` exports the shared conformance suite every backend runs.
|
||||
|
||||
## Packages in this group
|
||||
|
||||
| Package | Role |
|
||||
| --- | --- |
|
||||
| `dsh-storage` | The hub service + backend vocabulary + shared conformance suite |
|
||||
| `dsh-storage-json` | JSON backend: one unit per human-readable file, atomic whole-file rewrite |
|
||||
| `dsh-storage-sqlite` | SQLite backend: one database hosting all routed units, document-per-row |
|
||||
| `dsh-storage-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).
|
||||
37
packages/storage/storage/package.json
Normal file
37
packages/storage/storage/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-storage",
|
||||
"description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
104
packages/storage/storage/src/backend.ts
Normal file
104
packages/storage/storage/src/backend.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Backend-facing vocabulary of the storage hub: a backend owns one medium
|
||||
* (a file-tree root, a database file) and exposes data-shape facets over it.
|
||||
* This module is the normative contract text for backend implementers; the
|
||||
* shared conformance suite in `tests/contract.ts` asserts every clause.
|
||||
* @module @deepseek-ai/dsh-storage/src/backend
|
||||
*/
|
||||
|
||||
/** Allowed shape for unit and table names: safe as a file name and as a SQL identifier segment without escaping. */
|
||||
export const UNIT_NAME_RE = /^[a-z][a-z0-9_]*$/
|
||||
|
||||
/**
|
||||
* One registered backend. A backend owns exactly one medium and shares its
|
||||
* lifecycle across all facets; facets are optional members — a backend that
|
||||
* cannot serve a shape simply omits it, and resolution fails loud instead.
|
||||
*/
|
||||
export interface StorageBackend {
|
||||
/** Key-value data shape; absent when this backend cannot serve it. */
|
||||
readonly kv?: KvFacet
|
||||
|
||||
/**
|
||||
* Drain in-flight writes across all open units and release the medium.
|
||||
* Idempotent; concurrent and repeated calls resolve once teardown finishes.
|
||||
* @returns resolution after the medium is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
/** The key-value data shape: whole-unit snapshots plus per-record durable writes. */
|
||||
export interface KvFacet {
|
||||
/**
|
||||
* Open one unit, creating it when the medium holds no trace of it yet
|
||||
* (materialization may defer to the first write, but {@link KvUnit.loadAll}
|
||||
* must immediately serve the empty shape). A version already stamped on the
|
||||
* medium that differs from `descriptor.version` rejects with
|
||||
* `version-mismatch`; a medium that cannot be parsed as this unit rejects
|
||||
* with `malformed-medium`. Opening the same unit name twice without closing
|
||||
* is a caller bug and rejects.
|
||||
* @param descriptor - Static identity and shape of the unit to open.
|
||||
* @returns the opened unit.
|
||||
*/
|
||||
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
|
||||
}
|
||||
|
||||
/** Static identity and shape of one KV unit, projected from its owner's spec. */
|
||||
export interface KvUnitDescriptor {
|
||||
/** Unit name; must match {@link UNIT_NAME_RE}. Also the file-name / SQL-identifier segment. */
|
||||
readonly name: string
|
||||
/** Unit format version; a non-negative integer stamped on the medium at first materialization. */
|
||||
readonly version: number
|
||||
/** Table names; each must match {@link UNIT_NAME_RE}. */
|
||||
readonly tables: readonly string[]
|
||||
/** Whether this unit carries the global singleton slot. */
|
||||
readonly hasGlobal: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* One opened unit. Values are opaque JSON to this layer: no schema, no
|
||||
* events, no domain meaning. The unit does NOT serialize concurrent writes —
|
||||
* write ordering is the caller's responsibility (the domain layer runs one
|
||||
* write chain per unit); the unit only guarantees that each single call is
|
||||
* atomic on the medium and durable once resolved (a crash after resolution
|
||||
* followed by a re-open observes the write). Any call after {@link close}
|
||||
* rejects with `closed`.
|
||||
*/
|
||||
export interface KvUnit {
|
||||
/**
|
||||
* Read the full current snapshot.
|
||||
* @returns every table's records keyed by table name, plus the global
|
||||
* singleton (`null` when never written or not declared).
|
||||
*/
|
||||
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown }>
|
||||
|
||||
/**
|
||||
* Upsert one record durably. Overwrite semantics: an existing key is replaced.
|
||||
* @param table - Declared table name.
|
||||
* @param key - Record key; any string is safe (keys never reach file paths).
|
||||
* @param value - Opaque JSON-serializable record.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
putRecord(table: string, key: string, value: unknown): Promise<void>
|
||||
|
||||
/**
|
||||
* Delete one record durably. Idempotent: a missing key is a no-op.
|
||||
* @param table - Declared table name.
|
||||
* @param key - Record key.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
deleteRecord(table: string, key: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Write the global singleton durably. Only valid when the descriptor
|
||||
* declared `hasGlobal`.
|
||||
* @param value - Opaque JSON-serializable value.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
setGlobal(value: unknown): Promise<void>
|
||||
|
||||
/**
|
||||
* Drain this unit's in-flight writes and release it. Idempotent.
|
||||
* @returns resolution after the unit is released.
|
||||
*/
|
||||
close(): Promise<void>
|
||||
}
|
||||
35
packages/storage/storage/src/error.ts
Normal file
35
packages/storage/storage/src/error.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Error vocabulary for the storage hub and its backends.
|
||||
* @module @deepseek-ai/dsh-storage/src/error
|
||||
*/
|
||||
|
||||
/** Discriminant codes carried by every {@link StorageError}. */
|
||||
export type StorageErrorCode =
|
||||
| 'backend-not-found'
|
||||
| 'form-not-mounted'
|
||||
| 'duplicate-backend'
|
||||
| 'duplicate-mount'
|
||||
| 'version-mismatch'
|
||||
| 'malformed-medium'
|
||||
| 'closed'
|
||||
|
||||
/**
|
||||
* Error thrown by the hub and by backend implementations. The `code` is the
|
||||
* stable contract consumers may switch on; `message` is diagnostic prose.
|
||||
*/
|
||||
export class StorageError extends Error {
|
||||
override readonly name = 'StorageError'
|
||||
|
||||
/**
|
||||
* @param code - Stable discriminant for the failure class.
|
||||
* @param message - Human-readable diagnostic detail.
|
||||
* @param options - Standard error options (`cause`).
|
||||
*/
|
||||
constructor(
|
||||
readonly code: StorageErrorCode,
|
||||
message: string,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(message, options)
|
||||
}
|
||||
}
|
||||
86
packages/storage/storage/src/index.ts
Normal file
86
packages/storage/storage/src/index.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Storage hub (`ctx.storage`): a named backend registry plus mounted
|
||||
* data-form facilities. The hub itself performs no IO — backends own media,
|
||||
* data forms (the domain layer first) own semantics.
|
||||
* @module @deepseek-ai/dsh-storage
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { StorageError } from './error.ts'
|
||||
import { BackendRegistry } from './registry.ts'
|
||||
|
||||
export { BackendRegistry } from './registry.ts'
|
||||
export { StorageError } from './error.ts'
|
||||
export type { StorageErrorCode } from './error.ts'
|
||||
export { UNIT_NAME_RE } from './backend.ts'
|
||||
export type { StorageBackend, KvFacet, KvUnit, KvUnitDescriptor } from './backend.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
storage: Storage
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Data forms mountable on the hub, keyed by form name. Form owners extend
|
||||
* this map via declaration merging (the domain layer merges
|
||||
* `domain: DomainFacility`) and mount the facility in their `apply`.
|
||||
*/
|
||||
export interface StorageForms {}
|
||||
|
||||
/**
|
||||
* The storage hub service. Backends register under `backend`; data forms
|
||||
* mount under their `StorageForms` key and are reached as `ctx.storage.<form>`.
|
||||
*/
|
||||
export class Storage extends Service {
|
||||
/** Named backend table; multiple backends stay mounted side by side. */
|
||||
readonly backend = new BackendRegistry()
|
||||
|
||||
private readonly forms = new Map<keyof StorageForms, unknown>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'storage')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a data-form facility on the hub. Mounting is an effect: the
|
||||
* returned disposer unmounts the form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @param facility - The facility instance to expose.
|
||||
* @returns the disposer that unmounts the form.
|
||||
*/
|
||||
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void {
|
||||
if (this.forms.has(form)) {
|
||||
throw new StorageError('duplicate-mount', `storage form '${String(form)}' is already mounted`)
|
||||
}
|
||||
this.forms.set(form, facility)
|
||||
return () => {
|
||||
// Same stale-disposer guard as BackendRegistry.register.
|
||||
if (this.forms.get(form) === facility) {
|
||||
this.forms.delete(form)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a mounted data form.
|
||||
* @param form - Form key declared in {@link StorageForms}.
|
||||
* @returns the mounted facility.
|
||||
*/
|
||||
form<K extends keyof StorageForms>(form: K): StorageForms[K] {
|
||||
if (!this.forms.has(form)) {
|
||||
throw new StorageError('form-not-mounted', `storage form '${String(form)}' is not mounted`)
|
||||
}
|
||||
return this.forms.get(form) as StorageForms[K]
|
||||
}
|
||||
|
||||
/** Domain data form; present once the domain layer plugin is loaded. */
|
||||
get domain(): StorageForms extends { domain: infer D } ? D : never {
|
||||
return this.form('domain' as keyof StorageForms)
|
||||
}
|
||||
}
|
||||
|
||||
// Service packages default-export their service class and nothing else
|
||||
// plugin-shaped (packages/AGENTS.md): mixing a default export with a
|
||||
// function-plugin `apply` makes the Loader drop the plugin namespace.
|
||||
export default Storage
|
||||
32
packages/storage/storage/src/invariant.ts
Normal file
32
packages/storage/storage/src/invariant.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-storage`.
|
||||
* @module @deepseek-ai/dsh-storage/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-storage'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'storage-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: the hub is a pure registration table (names →
|
||||
* backends, forms → facilities) whose consistency is fully enforced at the
|
||||
* call sites (duplicate/missing entries fail loud synchronously); it owns no
|
||||
* event stream or mutable medium to cross-check.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
62
packages/storage/storage/src/registry.ts
Normal file
62
packages/storage/storage/src/registry.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Named backend registry of the storage hub.
|
||||
* @module @deepseek-ai/dsh-storage/src/registry
|
||||
*/
|
||||
|
||||
import type { StorageBackend } from './backend.ts'
|
||||
import { StorageError } from './error.ts'
|
||||
|
||||
/**
|
||||
* Mutable name → backend table. Multiple backends stay mounted side by side;
|
||||
* which backend serves which consumer is the consumer's configuration
|
||||
* (e.g. the domain layer's route table), never a hub-global choice.
|
||||
*/
|
||||
export class BackendRegistry {
|
||||
private readonly backends = new Map<string, StorageBackend>()
|
||||
|
||||
/**
|
||||
* Register a named backend. Registration is an effect: the returned
|
||||
* disposer removes the name. Disposal does NOT close the backend — the
|
||||
* owning plugin closes it after unregistering.
|
||||
* @param name - Backend name, e.g. `json` or `sqlite`.
|
||||
* @param backend - The backend instance.
|
||||
* @returns the disposer that unregisters the name.
|
||||
*/
|
||||
register(name: string, backend: StorageBackend): () => void {
|
||||
if (this.backends.has(name)) {
|
||||
throw new StorageError('duplicate-backend', `storage backend '${name}' is already registered`)
|
||||
}
|
||||
this.backends.set(name, backend)
|
||||
return () => {
|
||||
// Remove only this registration's contribution: after dispose + re-register,
|
||||
// a stale disposer firing again must not remove the successor.
|
||||
if (this.backends.get(name) === backend) {
|
||||
this.backends.delete(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a backend by name.
|
||||
* @param name - Registered backend name.
|
||||
* @returns the backend.
|
||||
*/
|
||||
get(name: string): StorageBackend {
|
||||
const backend = this.backends.get(name)
|
||||
if (!backend) {
|
||||
throw new StorageError(
|
||||
'backend-not-found',
|
||||
`storage backend '${name}' is not registered (registered: ${[...this.backends.keys()].join(', ') || 'none'})`,
|
||||
)
|
||||
}
|
||||
return backend
|
||||
}
|
||||
|
||||
/**
|
||||
* Registered backend names, for diagnostics.
|
||||
* @returns a snapshot array of names.
|
||||
*/
|
||||
names(): string[] {
|
||||
return [...this.backends.keys()]
|
||||
}
|
||||
}
|
||||
102
packages/storage/storage/tests/contract.ts
Normal file
102
packages/storage/storage/tests/contract.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Shared KV-backend conformance suite. Each backend's spec file calls
|
||||
* {@link runKvBackendContract} with a factory bound to its own medium; the
|
||||
* suite asserts every clause of the `src/backend.ts` contract so both
|
||||
* backends are held to identical semantics.
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { KvUnitDescriptor, StorageBackend } from '../src/backend.ts'
|
||||
|
||||
/** One conformance run: a fresh backend plus a way to reopen the same medium (crash simulation). */
|
||||
export interface KvBackendContractHarness {
|
||||
/** The backend under test, freshly created over an empty medium. */
|
||||
backend: StorageBackend
|
||||
/** Open a NEW backend instance over the SAME medium, as after a process restart. */
|
||||
reopen(): Promise<StorageBackend>
|
||||
}
|
||||
|
||||
const DESCRIPTOR: KvUnitDescriptor = {
|
||||
name: 'contract_unit',
|
||||
version: 3,
|
||||
tables: ['alpha', 'beta'],
|
||||
hasGlobal: true,
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the shared conformance suite against one backend implementation.
|
||||
* @param label - Suite label, e.g. `json` / `sqlite`.
|
||||
* @param create - Factory producing a fresh harness per test.
|
||||
*/
|
||||
export function runKvBackendContract(label: string, create: () => Promise<KvBackendContractHarness>) {
|
||||
describe(`kv backend contract: ${label}`, () => {
|
||||
it('opens a missing unit as empty and serves loadAll immediately', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables).toEqual({ alpha: {}, beta: {} })
|
||||
expect(snapshot.global).toBeNull()
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('round-trips records and global durably across reopen', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k1', { n: 1 })
|
||||
await unit.putRecord('alpha', 'k2', { n: 2 })
|
||||
await unit.putRecord('beta', 'weird key / with:stuff', { ok: true })
|
||||
await unit.setGlobal({ counter: 7 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
const snapshot = await unit2.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({ k1: { n: 1 }, k2: { n: 2 } })
|
||||
expect(snapshot.tables['beta']).toEqual({ 'weird key / with:stuff': { ok: true } })
|
||||
expect(snapshot.global).toEqual({ counter: 7 })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('putRecord overwrites and deleteRecord is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 'old' })
|
||||
await unit.putRecord('alpha', 'k', { v: 'new' })
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'k')
|
||||
await unit.deleteRecord('alpha', 'never-existed')
|
||||
const snapshot = await unit.loadAll()
|
||||
expect(snapshot.tables['alpha']).toEqual({})
|
||||
await backend.close()
|
||||
})
|
||||
|
||||
it('rejects a version mismatch on reopen without touching the data', async () => {
|
||||
const harness = await create()
|
||||
const unit = await harness.backend.kv!.open(DESCRIPTOR)
|
||||
await unit.putRecord('alpha', 'k', { v: 1 })
|
||||
await harness.backend.close()
|
||||
|
||||
const reopened = await harness.reopen()
|
||||
await expect(reopened.kv!.open({ ...DESCRIPTOR, version: 4 })).rejects.toMatchObject({
|
||||
name: 'StorageError',
|
||||
code: 'version-mismatch',
|
||||
})
|
||||
// Original version still opens and still holds the data.
|
||||
const unit2 = await reopened.kv!.open(DESCRIPTOR)
|
||||
expect((await unit2.loadAll()).tables['alpha']).toEqual({ k: { v: 1 } })
|
||||
await reopened.close()
|
||||
})
|
||||
|
||||
it('rejects operations after unit close, and close is idempotent', async () => {
|
||||
const { backend } = await create()
|
||||
const unit = await backend.kv!.open(DESCRIPTOR)
|
||||
await unit.close()
|
||||
await unit.close()
|
||||
await expect(unit.putRecord('alpha', 'k', {})).rejects.toMatchObject({ code: 'closed' })
|
||||
await expect(unit.loadAll()).rejects.toMatchObject({ code: 'closed' })
|
||||
await backend.close()
|
||||
await backend.close()
|
||||
})
|
||||
})
|
||||
}
|
||||
84
packages/storage/storage/tests/registry.spec.ts
Normal file
84
packages/storage/storage/tests/registry.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Storage, { BackendRegistry } from '../src/index.ts'
|
||||
import type { StorageBackend } from '../src/index.ts'
|
||||
|
||||
const fakeBackend = (): StorageBackend => ({ close: async () => {} })
|
||||
|
||||
describe('BackendRegistry', () => {
|
||||
it('registers, resolves, and disposes names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
const backend = fakeBackend()
|
||||
const dispose = registry.register('json', backend)
|
||||
expect(registry.get('json')).toBe(backend)
|
||||
expect(registry.names()).toEqual(['json'])
|
||||
dispose()
|
||||
expect(registry.names()).toEqual([])
|
||||
expect(() => registry.get('json')).toThrowMatchingObject({ code: 'backend-not-found' })
|
||||
})
|
||||
|
||||
it('rejects duplicate names', () => {
|
||||
const registry = new BackendRegistry()
|
||||
registry.register('json', fakeBackend())
|
||||
expect(() => registry.register('json', fakeBackend())).toThrowMatchingObject({ code: 'duplicate-backend' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('Storage service', () => {
|
||||
it('mounts on the context and exposes registry plus form mounting', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
expect(ctx.storage).toBeInstanceOf(Storage)
|
||||
|
||||
const facility = { marker: true }
|
||||
const dispose = ctx.storage.mount('domain' as never, facility as never)
|
||||
expect(ctx.storage.form('domain' as never)).toBe(facility)
|
||||
expect(ctx.storage.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)
|
||||
})
|
||||
})
|
||||
|
||||
expect.extend({
|
||||
toThrowMatchingObject(received: () => unknown, expected: object) {
|
||||
try {
|
||||
received()
|
||||
} catch (error) {
|
||||
const pass = Object.entries(expected).every(
|
||||
entry => (error as Record<string, unknown>)[entry[0]] === entry[1],
|
||||
)
|
||||
return { pass, message: () => `expected thrown error to match ${JSON.stringify(expected)}, got ${String(error)}` }
|
||||
}
|
||||
return { pass: false, message: () => 'expected function to throw' }
|
||||
},
|
||||
})
|
||||
|
||||
declare module 'vitest' {
|
||||
interface Assertion<T> {
|
||||
toThrowMatchingObject(expected: object): T
|
||||
}
|
||||
}
|
||||
21
packages/storage/storage/tsconfig.json
Normal file
21
packages/storage/storage/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
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.
|
||||
37
packages/workspace/workspace/README.md
Normal file
37
packages/workspace/workspace/README.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# @deepseek-ai/dsh-workspace
|
||||
|
||||
Workspace entity registry (`ctx.workspace`) for the DeepSeek Harness: durable workspace records — a stable `WorkspaceId`, a canonical directory path, a display title, and the ordered account of owned sessions — stored through the domain data form (`workspaceDomainSpec`, table `workspaces`). Consumers see the `Workspace` interface only; the entity implementation stays package-private.
|
||||
|
||||
Design rationale, the path/uniqueness canon, and the consistency rules live in the [Agent Note](../../../.agents/notes/proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.zh.md).
|
||||
|
||||
## Shape
|
||||
|
||||
- `ctx.workspace.create(path, title?)` — canonicalizes `path` via `fs.realpath` (trailing slashes, `..`, symlinks), rejects a nonexistent path (the original `ENOENT`), a path resolving to anything but a directory, and a canonical path another workspace already owns. Title defaults to `basename(path)`.
|
||||
- `ctx.workspace.get(id)` / `list()` / `resolveByPath(path)` — cache-served lookups; `resolveByPath` is async because it runs the same `realpath` canon first.
|
||||
- `Workspace.attachSession(id)` — idempotent; validates that the session's stored header `cwd`, canonicalized the same way, equals the workspace path. A missing persistence service, unknown session, absent or unresolvable `cwd`, or mismatch rejects without writing (what cannot be validated is not recorded). `detachSession` removes from the account only, never touching the session's own log.
|
||||
- `Workspace.sessionIds` — the ordered ownership account (array order is display order). Accounted ids whose session no longer exists are filtered from the projection and pruned durably on the next mutation. A medium accounting one session under two workspaces, or claiming one canonical path from two records, rejects at startup (external edit — the write side makes both unreachable). Attach/detach idempotence is decided on the domain write chain, so unawaited concurrent calls settle in call order.
|
||||
- `Workspace.status()` — uncached directory check, `'ok' | 'missing-dir'`; a missing directory never mutates the record.
|
||||
|
||||
Session persistence is an optional peer resolved with `ctx.get`: absent, attach rejects and projections serve the account unfiltered.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### 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
|
||||
|
||||
- No delete entry point in this phase — workspace deletion ships as one complete semantic together with the session-delete primitive and cascade orchestration (future-work section of the Agent Note); a half "drop the record, keep the sessions" operation is deliberately not exposed.
|
||||
- No RPC surface or GUI wiring yet; the record schema is the direct source of the next phase's wire projection.
|
||||
- The known-session view refreshes at startup and on attach validation; a session deleted by an external process during this one is filtered only after the next refresh.
|
||||
50
packages/workspace/workspace/package.json
Normal file
50
packages/workspace/workspace/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-workspace",
|
||||
"description": "Workspace entity registry (ctx.workspace): durable workspace records with validated session attachment over the domain data form 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-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-storage-domain": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-storage": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage-domain": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-storage": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
169
packages/workspace/workspace/src/entity.ts
Normal file
169
packages/workspace/workspace/src/entity.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Package-private workspace entity: the single {@link Workspace}
|
||||
* implementation. Holds a record snapshot that is swapped in place after each
|
||||
* durable mutation; every write funnels through the private `mutate` so
|
||||
* `updatedAt` stamping and dead-account pruning happen exactly once.
|
||||
* Not re-exported from the package entrypoint — consumers see only the
|
||||
* `Workspace` interface.
|
||||
* @module @deepseek-ai/dsh-workspace/src/entity
|
||||
*/
|
||||
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { WorkspaceRecord } from './spec.ts'
|
||||
import type { Workspace, WorkspaceId } from './types.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
|
||||
/**
|
||||
* The registry-owned machinery an entity mutates through. Entities never see
|
||||
* the registry itself — only the open table, the known-session view backing
|
||||
* the `sessionIds` projection, and header reads for attach validation.
|
||||
*/
|
||||
export interface WorkspaceEntityHost {
|
||||
/**
|
||||
* Resolve the open `workspaces` table.
|
||||
* @returns the table; throws while the registry has not started yet.
|
||||
*/
|
||||
table(): KvTable<WorkspaceId, WorkspaceRecord>
|
||||
|
||||
/**
|
||||
* Synchronous view of the session ids known to exist in session
|
||||
* persistence.
|
||||
* @returns the id set, or `undefined` when persistence has been absent so
|
||||
* far (membership cannot be verified, so projections serve the account
|
||||
* unfiltered).
|
||||
*/
|
||||
knownSessionIds(): ReadonlySet<string> | undefined
|
||||
|
||||
/**
|
||||
* Read one stored session header for attach validation.
|
||||
* @param id - The session whose header to read.
|
||||
* @returns the header; rejects when session persistence is absent or holds
|
||||
* no session with this id.
|
||||
*/
|
||||
readSessionHeader(id: SessionId): Promise<SessionHeader>
|
||||
}
|
||||
|
||||
/** Chain-slot abort sentinel thrown by the update fn when the record needs no change; only `mutate` observes it. */
|
||||
const unchangedSentinel = new Error('workspace record unchanged (internal sentinel)')
|
||||
|
||||
/** The single {@link Workspace} implementation; constructed only by the registry. */
|
||||
export class WorkspaceEntity implements Workspace {
|
||||
private record: WorkspaceRecord
|
||||
|
||||
/**
|
||||
* @param host - Registry-owned table, known-session view, and header reads.
|
||||
* @param id - The record's stable id.
|
||||
* @param record - The validated record snapshot loaded or just written.
|
||||
*/
|
||||
constructor(
|
||||
private readonly host: WorkspaceEntityHost,
|
||||
readonly id: WorkspaceId,
|
||||
record: WorkspaceRecord,
|
||||
) {
|
||||
this.record = record
|
||||
}
|
||||
|
||||
get path(): string {
|
||||
return this.record.path
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this.record.title
|
||||
}
|
||||
|
||||
get sessionIds(): readonly SessionId[] {
|
||||
const known = this.host.knownSessionIds()
|
||||
if (known === undefined) return this.record.sessionIds
|
||||
return this.record.sessionIds.filter(id => known.has(id))
|
||||
}
|
||||
|
||||
async setTitle(title: string): Promise<void> {
|
||||
await this.mutate(record => ({ ...record, title }))
|
||||
}
|
||||
|
||||
async attachSession(sessionId: SessionId): Promise<void> {
|
||||
// Validation is skipped when the settled snapshot already accounts the
|
||||
// id: the cwd fact was checked when it first attached and both inputs
|
||||
// (stored header cwd, workspace path) are immutable. Membership itself is
|
||||
// decided on the write chain inside `mutate`, never on this snapshot.
|
||||
if (!this.record.sessionIds.includes(sessionId)) {
|
||||
const header = await this.host.readSessionHeader(sessionId)
|
||||
if (header.cwd === undefined) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ 'its stored header carries no cwd to validate against',
|
||||
)
|
||||
}
|
||||
let cwd: string
|
||||
try {
|
||||
cwd = await realpathNormalize(header.cwd)
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd '${header.cwd}' does not resolve, so it cannot be validated`,
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
if (cwd !== this.record.path) {
|
||||
throw new Error(
|
||||
`cannot attach session '${sessionId}' to workspace '${this.record.path}': `
|
||||
+ `its cwd resolves to '${cwd}'`,
|
||||
)
|
||||
}
|
||||
}
|
||||
await this.mutate(record => record.sessionIds.includes(sessionId)
|
||||
? record
|
||||
: { ...record, sessionIds: [...record.sessionIds, sessionId] })
|
||||
}
|
||||
|
||||
async detachSession(sessionId: SessionId): Promise<void> {
|
||||
await this.mutate(record => record.sessionIds.includes(sessionId)
|
||||
? { ...record, sessionIds: record.sessionIds.filter(id => id !== sessionId) }
|
||||
: record)
|
||||
}
|
||||
|
||||
async status(): Promise<'ok' | 'missing-dir'> {
|
||||
try {
|
||||
return (await stat(this.record.path)).isDirectory() ? 'ok' : 'missing-dir'
|
||||
} catch {
|
||||
// Any stat failure (ENOENT, dangling parent, permission loss) means the
|
||||
// directory is not usable right now; the record itself never mutates.
|
||||
return 'missing-dir'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The single write path: run `fn` on the domain write chain via
|
||||
* `table.update`, stamping `updatedAt` and pruning accounted ids whose
|
||||
* session no longer exists (consistency rule: dead ids are dropped on the
|
||||
* next mutation, whatever that mutation is), then swap the snapshot.
|
||||
*
|
||||
* `fn` sees the value current at its chain slot, so membership decisions
|
||||
* (attach/detach idempotence) are race-free against queued writes; a fn
|
||||
* signalling no change by returning `current` verbatim aborts the slot
|
||||
* through the sentinel when pruning also finds nothing, so a no-op neither
|
||||
* rewrites the medium nor emits a change event.
|
||||
*/
|
||||
private async mutate(fn: (record: WorkspaceRecord) => WorkspaceRecord): Promise<void> {
|
||||
const known = this.host.knownSessionIds()
|
||||
let next: WorkspaceRecord
|
||||
try {
|
||||
next = await this.host.table().update(this.id, (current) => {
|
||||
const changed = fn(current)
|
||||
const sessionIds = known === undefined
|
||||
? changed.sessionIds
|
||||
: changed.sessionIds.filter(id => known.has(id))
|
||||
if (changed === current && sessionIds.length === current.sessionIds.length) {
|
||||
throw unchangedSentinel
|
||||
}
|
||||
return { ...changed, sessionIds, updatedAt: new Date().toISOString() }
|
||||
})
|
||||
} catch (error) {
|
||||
if (error === unchangedSentinel) return
|
||||
throw error
|
||||
}
|
||||
this.record = next
|
||||
}
|
||||
}
|
||||
231
packages/workspace/workspace/src/index.ts
Normal file
231
packages/workspace/workspace/src/index.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
/**
|
||||
* Workspace entity registry (`ctx.workspace`): durable workspace records over
|
||||
* the domain data form, with session attachment validated against stored
|
||||
* session headers. This package owns the `WorkspaceId` brand and the
|
||||
* `workspace` domain; consumers see the {@link Workspace} interface only.
|
||||
* @module @deepseek-ai/dsh-workspace
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { basename } from 'node:path'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
// Type-only: merges `sessionPersistence` into the Context service map for the
|
||||
// optional `ctx.get` lookups below.
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { workspaceDomainSpec } from './spec.ts'
|
||||
import type { WorkspaceRecord } from './spec.ts'
|
||||
import { WorkspaceEntity } from './entity.ts'
|
||||
import type { WorkspaceEntityHost } from './entity.ts'
|
||||
import { realpathNormalize } from './paths.ts'
|
||||
import type { Workspace, WorkspaceId as WorkspaceIdBrand } from './types.ts'
|
||||
|
||||
export type { Workspace } from './types.ts'
|
||||
export { workspaceRecord, workspaceDomainSpec } from './spec.ts'
|
||||
export type { WorkspaceRecord } from './spec.ts'
|
||||
export { realpathNormalize } from './paths.ts'
|
||||
|
||||
/** Identifies one workspace record (see `src/types.ts` for the brand rationale). */
|
||||
export type WorkspaceId = WorkspaceIdBrand
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link WorkspaceId}.
|
||||
* @param id - the raw workspace id string.
|
||||
* @returns the same string, branded (a compile-time cast — no runtime cost).
|
||||
*/
|
||||
export function WorkspaceId(id: string): WorkspaceId {
|
||||
return id as WorkspaceId
|
||||
}
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
workspace: WorkspaceRegistry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace registry service. Opens the `workspace` domain at startup,
|
||||
* rebuilds one entity per stored record, and serves entities from an
|
||||
* in-memory cache keyed by id. Session persistence is an OPTIONAL peer
|
||||
* (resolved via `ctx.get`, never injected): while it is absent, session
|
||||
* attachment rejects (what cannot be validated is not recorded) and
|
||||
* `sessionIds` projections serve the account unfiltered.
|
||||
*
|
||||
* There is deliberately no delete entry point in this phase: workspace
|
||||
* deletion ships as one complete semantic together with the session-cascade
|
||||
* primitives (future work in the owning Agent Note).
|
||||
*/
|
||||
export class WorkspaceRegistry extends Service {
|
||||
static inject = ['storage']
|
||||
|
||||
private table?: KvTable<WorkspaceId, WorkspaceRecord>
|
||||
private readonly entities = new Map<WorkspaceId, WorkspaceEntity>()
|
||||
/**
|
||||
* Session ids known to exist in session persistence; `undefined` until the
|
||||
* first successful listing. Refreshed at startup and on every attach
|
||||
* validation — within one process sessions are only ever added (this phase
|
||||
* has no delete primitive), so the set can only lag by missing very recent
|
||||
* sessions, never by holding dead ones from this process's lifetime.
|
||||
*/
|
||||
private known?: Set<string>
|
||||
|
||||
private readonly host: WorkspaceEntityHost = {
|
||||
table: () => this.requireTable(),
|
||||
knownSessionIds: () => this.known,
|
||||
readSessionHeader: id => this.readSessionHeader(id),
|
||||
}
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'workspace')
|
||||
}
|
||||
|
||||
/** Open the domain and rebuild the entity cache before the service is published as active. */
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const domain = await this.ctx.storage.domain.open(workspaceDomainSpec)
|
||||
// This registry owns the domain handle it opened: closing on fiber
|
||||
// disposal frees the domain name, so a re-plugged registry can reopen it.
|
||||
this.ctx.effect(() => () => domain.close(), 'workspace.domainClose')
|
||||
this.table = domain.table('workspaces')
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
this.known = new Set<string>((await persistence.list()).map(header => header.id))
|
||||
}
|
||||
// Rebuild entities, rejecting states the write side makes structurally
|
||||
// impossible (an external medium edit is the only way in, and hiding it
|
||||
// would silently pick a winner): one session accounted under two
|
||||
// workspaces, or two records claiming one canonical path (plain string
|
||||
// equality — stored paths are already canonical, so no realpath here).
|
||||
const accounted = new Map<string, WorkspaceId>()
|
||||
const paths = new Map<string, WorkspaceId>()
|
||||
for (const [id, record] of this.table.entries()) {
|
||||
const pathHolder = paths.get(record.path)
|
||||
if (pathHolder !== undefined) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: path '${record.path}' is claimed `
|
||||
+ `by both workspace '${pathHolder}' and workspace '${id}'`,
|
||||
)
|
||||
}
|
||||
paths.set(record.path, id)
|
||||
for (const sessionId of record.sessionIds) {
|
||||
const holder = accounted.get(sessionId)
|
||||
if (holder !== undefined) {
|
||||
throw new Error(
|
||||
`workspace domain is inconsistent: session '${sessionId}' is accounted `
|
||||
+ `by both workspace '${holder}' and workspace '${id}'`,
|
||||
)
|
||||
}
|
||||
accounted.set(sessionId, id)
|
||||
}
|
||||
this.entities.set(id, new WorkspaceEntity(this.host, id, record))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a workspace over an existing directory. The path is canonicalized
|
||||
* through `fs.realpath` first — a nonexistent path rejects with the
|
||||
* original `ENOENT`, a path resolving to anything but a directory rejects,
|
||||
* and a canonical path already owned by another workspace (including a
|
||||
* symlink resolving to it) rejects.
|
||||
* @param path - Directory the workspace points at; canonicalized before storing.
|
||||
* @param title - Display title; defaults to `basename` of the canonical path.
|
||||
* @returns the created workspace after durability.
|
||||
*/
|
||||
async create(path: string, title?: string): Promise<Workspace> {
|
||||
const table = this.requireTable()
|
||||
const canonical = await realpathNormalize(path)
|
||||
if (!(await stat(canonical)).isDirectory()) {
|
||||
throw new Error(`cannot create a workspace at '${canonical}': path is not a directory`)
|
||||
}
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) {
|
||||
throw new Error(`a workspace for '${canonical}' already exists ('${entity.id}')`)
|
||||
}
|
||||
}
|
||||
const id = WorkspaceId(randomUUID())
|
||||
const now = new Date().toISOString()
|
||||
const record: WorkspaceRecord = {
|
||||
path: canonical,
|
||||
title: title ?? basename(canonical),
|
||||
sessionIds: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
const entity = new WorkspaceEntity(this.host, id, record)
|
||||
// Cache before the durable put: a concurrent same-path create fails the
|
||||
// scan above, and the entity already exists when `domain/changed` fires.
|
||||
this.entities.set(id, entity)
|
||||
try {
|
||||
await table.put(id, record)
|
||||
} catch (error) {
|
||||
this.entities.delete(id)
|
||||
throw error
|
||||
}
|
||||
return entity
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up a workspace by id.
|
||||
* @param id - The workspace id.
|
||||
* @returns the workspace, or `undefined` when unknown.
|
||||
*/
|
||||
get(id: WorkspaceId): Workspace | undefined {
|
||||
return this.entities.get(id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot of all workspaces, in load-then-creation order.
|
||||
* @returns a fresh array of the cached entities.
|
||||
*/
|
||||
list(): Workspace[] {
|
||||
return [...this.entities.values()]
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a workspace by directory path, through the same `fs.realpath`
|
||||
* canon as {@link create} (hence async). A path that does not exist rejects
|
||||
* with the original error — a missing directory has no canonical form to
|
||||
* compare (a workspace whose recorded directory vanished is only reachable
|
||||
* by id; see `Workspace.status`).
|
||||
* @param path - Directory path in any spelling (symlinks, `..`, trailing slash).
|
||||
* @returns the owning workspace, or `undefined` when none matches.
|
||||
*/
|
||||
async resolveByPath(path: string): Promise<Workspace | undefined> {
|
||||
const canonical = await realpathNormalize(path)
|
||||
for (const entity of this.entities.values()) {
|
||||
if (entity.path === canonical) return entity
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private requireTable(): KvTable<WorkspaceId, WorkspaceRecord> {
|
||||
if (this.table === undefined) {
|
||||
throw new Error('workspace registry is not started yet')
|
||||
}
|
||||
return this.table
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one stored session header for attach validation, refreshing the
|
||||
* known-session view from the same listing. Rejects when session
|
||||
* persistence is absent or holds no session with this id.
|
||||
*/
|
||||
private async readSessionHeader(id: SessionId): Promise<SessionHeader> {
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error(
|
||||
`cannot validate session '${id}': no session persistence service is available`,
|
||||
)
|
||||
}
|
||||
const headers = await persistence.list()
|
||||
this.known = new Set<string>(headers.map(header => header.id))
|
||||
const header = headers.find(candidate => candidate.id === id)
|
||||
if (header === undefined) {
|
||||
throw new Error(`cannot validate session '${id}': session persistence holds no such session`)
|
||||
}
|
||||
return header
|
||||
}
|
||||
}
|
||||
|
||||
export default WorkspaceRegistry
|
||||
53
packages/workspace/workspace/src/invariant.ts
Normal file
53
packages/workspace/workspace/src/invariant.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-workspace`.
|
||||
* @module @deepseek-ai/dsh-workspace/invariant
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { WorkspaceId } from '@deepseek-ai/dsh-workspace'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-workspace'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'workspace-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* Owned relationship: the registry's entity cache mirrors the workspace
|
||||
* domain's durable table. Every `domain/changed` for the `workspaces` table
|
||||
* must name a record the cache already holds an entity for (the registry
|
||||
* caches before the durable put and mutates only through cached entities),
|
||||
* and no `deleted` operation may appear at all — this phase ships no delete
|
||||
* entry point, so a deletion proves a write path outside the registry.
|
||||
*/
|
||||
const install: InvariantInstaller = Object.assign(
|
||||
(ctx: Context, fail: (message: string) => never) => {
|
||||
ctx.on('domain/changed', (change: DomainChanged) => {
|
||||
if (change.domain !== 'workspace' || change.table !== 'workspaces') return
|
||||
if (change.operation === 'deleted') {
|
||||
fail(
|
||||
`workspace record '${change.key}' emitted a deleted change, but the registry `
|
||||
+ 'exposes no delete entry point — some write path bypassed ctx.workspace',
|
||||
)
|
||||
}
|
||||
if (ctx.workspace.get(WorkspaceId(change.key)) === undefined) {
|
||||
fail(
|
||||
`workspace record '${change.key}' landed durably but the registry cache holds `
|
||||
+ 'no entity for it — the cache and the domain table have diverged',
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
{ inject: ['workspace'] },
|
||||
)
|
||||
|
||||
/**
|
||||
* 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))
|
||||
22
packages/workspace/workspace/src/paths.ts
Normal file
22
packages/workspace/workspace/src/paths.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Path canonicalization for workspace identity.
|
||||
* @module @deepseek-ai/dsh-workspace/src/paths
|
||||
*/
|
||||
|
||||
import { realpath } from 'node:fs/promises'
|
||||
|
||||
/**
|
||||
* Canonicalize a directory path via `fs.realpath`: trailing slashes, `..`
|
||||
* segments, and symlinks are all resolved. This is the ONE uniqueness canon of
|
||||
* the package — workspace paths are stored canonicalized, uniqueness is
|
||||
* string equality of canonicalized paths (a symlink to an existing
|
||||
* workspace's directory collides), and attach-time session `cwd` checks go
|
||||
* through the same canon. A path that does not exist rejects with the
|
||||
* original `ENOENT` — this is `create`'s reject path (a workspace must point
|
||||
* at an existing directory).
|
||||
* @param path - The path to canonicalize.
|
||||
* @returns the canonical absolute path.
|
||||
*/
|
||||
export async function realpathNormalize(path: string): Promise<string> {
|
||||
return await realpath(path)
|
||||
}
|
||||
39
packages/workspace/workspace/src/spec.ts
Normal file
39
packages/workspace/workspace/src/spec.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The workspace domain declaration: record schema and the `defineDomain` spec
|
||||
* the registry opens. The zod schema is the durable-boundary validator today
|
||||
* and the direct source of the RPC wire projection in a later phase.
|
||||
* @module @deepseek-ai/dsh-workspace/src/spec
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
|
||||
import type { WorkspaceId } from './types.ts'
|
||||
|
||||
/**
|
||||
* Durable shape of one workspace record. `path` is the `fs.realpath` canon
|
||||
* stamped at create; `sessionIds` is the ordered ownership account (array
|
||||
* order is display order); timestamps are ISO-8601 strings.
|
||||
*/
|
||||
export const workspaceRecord = z.object({
|
||||
path: z.string(),
|
||||
title: z.string(),
|
||||
sessionIds: z.array(z.string().transform(SessionId)),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
/** One stored workspace record, inferred from {@link workspaceRecord}. */
|
||||
export type WorkspaceRecord = z.infer<typeof workspaceRecord>
|
||||
|
||||
/**
|
||||
* The workspace domain spec: one `workspaces` table keyed by
|
||||
* {@link WorkspaceId}, no global singleton. The registry opens this through
|
||||
* `ctx.storage.domain`; the spec object is the single source of the domain's
|
||||
* identity, version, and record schema.
|
||||
*/
|
||||
export const workspaceDomainSpec = defineDomain({
|
||||
name: 'workspace',
|
||||
version: 1,
|
||||
tables: { workspaces: domainTable<WorkspaceId, WorkspaceRecord>(workspaceRecord) },
|
||||
})
|
||||
86
packages/workspace/workspace/src/types.ts
Normal file
86
packages/workspace/workspace/src/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Public type vocabulary of the workspace entity: the `WorkspaceId` brand and
|
||||
* the `Workspace` consumer interface. Types only — the `WorkspaceId` factory
|
||||
* lives in `index.ts` (this file carries no runtime code).
|
||||
* @module @deepseek-ai/dsh-workspace/src/types
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Identifies one workspace record. A generated uuid, never the path: path
|
||||
* normalization rewrites paths, and a reference anchor must stay stable.
|
||||
*/
|
||||
export type WorkspaceId = Branded<'WorkspaceId'>
|
||||
|
||||
/**
|
||||
* One workspace: a stable id over an existing directory, a display title, and
|
||||
* the ordered account of sessions that belong to it. The account is the sole
|
||||
* source of ownership — sessions are never inferred from cwd. Consumers only
|
||||
* see this interface; the entity implementation stays package-private.
|
||||
*/
|
||||
export interface Workspace {
|
||||
/** Stable record id (generated uuid). */
|
||||
readonly id: WorkspaceId
|
||||
|
||||
/**
|
||||
* Canonical directory path: the `fs.realpath` of the path given at create
|
||||
* time (trailing slashes, `..`, and symlinks all resolved). Never rewritten
|
||||
* afterwards, even when the directory disappears (see {@link status}).
|
||||
*/
|
||||
readonly path: string
|
||||
|
||||
/** Display title. Defaults to `basename(path)` at create; duplicates are allowed. */
|
||||
readonly title: string
|
||||
|
||||
/**
|
||||
* Sessions recorded under this workspace, in attach order (the array order
|
||||
* is the display order). A projection: accounted ids whose session no
|
||||
* longer exists in session persistence are filtered out here (and dropped
|
||||
* from the durable account on the next mutation); when session persistence
|
||||
* is absent the account is served unfiltered because membership cannot be
|
||||
* verified.
|
||||
*/
|
||||
readonly sessionIds: readonly SessionId[]
|
||||
|
||||
/**
|
||||
* Replace the display title durably.
|
||||
* @param title - New title; any string, duplicates across workspaces allowed.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
setTitle(title: string): Promise<void>
|
||||
|
||||
/**
|
||||
* Record a session under this workspace. Idempotent: a session already on
|
||||
* the account resolves without writing (membership is decided on the
|
||||
* domain write chain, so unawaited concurrent attach/detach calls settle
|
||||
* in call order). For a session not yet on the account, its stored header
|
||||
* is read from session persistence and its `cwd`, normalized through the
|
||||
* same `fs.realpath` canon as workspace paths, must equal this workspace's
|
||||
* {@link path} — a missing persistence service, an unknown session id, a
|
||||
* header without `cwd`, a `cwd` that no longer resolves, or a mismatched
|
||||
* `cwd` all reject without touching the account (what cannot be validated
|
||||
* is not recorded).
|
||||
* @param sessionId - The session to record.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
attachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Remove a session from this workspace's account. Idempotent: an id not on
|
||||
* the account resolves without writing (decided on the domain write chain,
|
||||
* like attach). Never touches the session's own stored log.
|
||||
* @param sessionId - The session to remove.
|
||||
* @returns resolution after durability.
|
||||
*/
|
||||
detachSession(sessionId: SessionId): Promise<void>
|
||||
|
||||
/**
|
||||
* Live directory check, uncached: whether {@link path} currently exists and
|
||||
* is a directory. A missing directory never mutates the record — the
|
||||
* directory may only be temporarily moved.
|
||||
* @returns `'ok'` when the directory exists, `'missing-dir'` otherwise.
|
||||
*/
|
||||
status(): Promise<'ok' | 'missing-dir'>
|
||||
}
|
||||
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-storage-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/)
|
||||
})
|
||||
})
|
||||
381
packages/workspace/workspace/tests/workspace.spec.ts
Normal file
381
packages/workspace/workspace/tests/workspace.spec.ts
Normal file
@@ -0,0 +1,381 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
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-storage-domain'
|
||||
import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { MemoryMediaPool, MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts'
|
||||
import WorkspaceRegistry, { WorkspaceId } from '../src/index.ts'
|
||||
import type { WorkspaceRecord } from '../src/index.ts'
|
||||
|
||||
const header = (id: string, cwd?: string): SessionHeader =>
|
||||
({ version: 0, id: SessionId(id), createdAt: 0, ...(cwd === undefined ? {} : { cwd }) })
|
||||
|
||||
/**
|
||||
* Boot storage hub + memory backend + domain form + the workspace registry.
|
||||
* `sessions: 'absent'` boots without a sessionPersistence service; otherwise
|
||||
* a stub serving exactly the given headers from `list()` is provided, and
|
||||
* `setSessions` swaps what it serves next.
|
||||
*/
|
||||
async function harness(options?: {
|
||||
pool?: MemoryMediaPool
|
||||
sessions?: SessionHeader[] | 'absent'
|
||||
backend?: StorageBackend
|
||||
}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
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) {
|
||||
ctx.provide('sessionPersistence', { list: async () => listed ?? [] })
|
||||
}
|
||||
const changes: DomainChanged[] = []
|
||||
ctx.on('domain/changed', (change) => { changes.push(change) })
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
return {
|
||||
ctx,
|
||||
registry: ctx.workspace,
|
||||
changes,
|
||||
setSessions: (headers: SessionHeader[]) => { listed = headers },
|
||||
}
|
||||
}
|
||||
|
||||
/** 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()
|
||||
pool.versions.set('workspace', 1)
|
||||
pool.media.set('workspace', {
|
||||
tables: new Map([['workspaces', new Map<string, unknown>([[id, record]])]]),
|
||||
global: null,
|
||||
})
|
||||
return pool
|
||||
}
|
||||
|
||||
const record = (path: string, sessionIds: string[]): WorkspaceRecord => ({
|
||||
path,
|
||||
title: basename(path),
|
||||
sessionIds: sessionIds.map(SessionId),
|
||||
createdAt: '2026-07-24T00:00:00.000Z',
|
||||
updatedAt: '2026-07-24T00:00:00.000Z',
|
||||
})
|
||||
|
||||
/** Stored record as the memory medium currently holds it. */
|
||||
function storedRecord(pool: MemoryMediaPool, id: string): WorkspaceRecord {
|
||||
return pool.media.get('workspace')!.tables.get('workspaces')!.get(id) as WorkspaceRecord
|
||||
}
|
||||
|
||||
let base: string
|
||||
const tempDirs: string[] = []
|
||||
|
||||
/** A fresh real directory under a canonicalized temp base. */
|
||||
async function makeDir(name: string): Promise<string> {
|
||||
base ??= await realpath(await mkdtemp(join(tmpdir(), 'dsh-workspace-')))
|
||||
if (tempDirs.length === 0) tempDirs.push(base)
|
||||
const dir = join(base, name)
|
||||
await mkdir(dir, { recursive: true })
|
||||
return dir
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true })
|
||||
base = undefined as never
|
||||
})
|
||||
|
||||
describe('WorkspaceRegistry.create', () => {
|
||||
it('stores the canonical path, defaults the title to basename, and lists the entity', async () => {
|
||||
const dir = await makeDir('proj')
|
||||
const { registry } = await harness()
|
||||
const workspace = await registry.create(dir + '/')
|
||||
expect(workspace.path).toBe(dir)
|
||||
expect(workspace.title).toBe('proj')
|
||||
expect(workspace.sessionIds).toEqual([])
|
||||
expect(registry.list()).toEqual([workspace])
|
||||
expect(registry.get(workspace.id)).toBe(workspace)
|
||||
const titled = await registry.create(await makeDir('other'), 'Custom')
|
||||
expect(titled.title).toBe('Custom')
|
||||
})
|
||||
|
||||
it('rejects a nonexistent directory with the original ENOENT', async () => {
|
||||
const dir = await makeDir('exists')
|
||||
const { registry } = await harness()
|
||||
await expect(registry.create(join(dir, 'nope'))).rejects.toMatchObject({ code: 'ENOENT' })
|
||||
expect(registry.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a path resolving to a plain file', async () => {
|
||||
const dir = await makeDir('has-file')
|
||||
const file = join(dir, 'plain.txt')
|
||||
await writeFile(file, 'not a directory')
|
||||
const { registry } = await harness()
|
||||
await expect(registry.create(file)).rejects.toThrow(/not a directory/)
|
||||
expect(registry.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a duplicate path, including a symlink resolving to an existing workspace', async () => {
|
||||
const dir = await makeDir('real')
|
||||
const link = join(base, 'link')
|
||||
await symlink(dir, link)
|
||||
const { registry } = await harness()
|
||||
await registry.create(dir)
|
||||
await expect(registry.create(link)).rejects.toThrow(/already exists/)
|
||||
expect(registry.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('resolves by path through the same canon', async () => {
|
||||
const dir = await makeDir('canon')
|
||||
const link = join(base, 'canon-link')
|
||||
await symlink(dir, link)
|
||||
const { registry } = await harness()
|
||||
const workspace = await registry.create(dir)
|
||||
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/)
|
||||
})
|
||||
|
||||
it('closes its domain on fiber disposal so a re-plugged registry reopens it', async () => {
|
||||
const dir = await makeDir('replug')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend())
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
const fiber = ctx.plugin(WorkspaceRegistry)
|
||||
await fiber
|
||||
const first = await ctx.workspace.create(dir)
|
||||
await fiber.dispose()
|
||||
// The registry's effect closed the domain, freeing the name: a second
|
||||
// plugin of the same registry must reopen it (not already-open) and see
|
||||
// the durable record.
|
||||
await ctx.plugin(WorkspaceRegistry)
|
||||
const reloaded = await ctx.workspace.resolveByPath(dir)
|
||||
expect(reloaded?.id).toBe(first.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Workspace.attachSession', () => {
|
||||
it('attaches when the session cwd resolves to the workspace path, keeping attach order', async () => {
|
||||
const dir = await makeDir('attach')
|
||||
const link = join(base, 'attach-link')
|
||||
await symlink(dir, link)
|
||||
// s2's cwd is spelled through the symlink: same canon, must attach.
|
||||
const { registry } = await harness({
|
||||
sessions: [header('s1', dir), header('s2', link), header('s3', dir)],
|
||||
})
|
||||
const workspace = await registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.attachSession(SessionId('s2'))
|
||||
await workspace.attachSession(SessionId('s3'))
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's2', 's3'])
|
||||
await workspace.detachSession(SessionId('s2'))
|
||||
expect(workspace.sessionIds).toEqual(['s1', 's3'])
|
||||
})
|
||||
|
||||
it('rejects a cwd resolving elsewhere, a missing cwd, and an unknown session', async () => {
|
||||
const dir = await makeDir('strict')
|
||||
const elsewhere = await makeDir('elsewhere')
|
||||
const { registry } = await harness({
|
||||
sessions: [header('other-dir', elsewhere), header('no-cwd', undefined)],
|
||||
})
|
||||
const workspace = await registry.create(dir)
|
||||
await expect(workspace.attachSession(SessionId('other-dir'))).rejects.toThrow(/resolves to/)
|
||||
await expect(workspace.attachSession(SessionId('no-cwd'))).rejects.toThrow(/no cwd/)
|
||||
await expect(workspace.attachSession(SessionId('unknown'))).rejects.toThrow(/no such session/)
|
||||
expect(workspace.sessionIds).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a cwd that no longer resolves', async () => {
|
||||
const dir = await makeDir('target')
|
||||
const gone = await makeDir('gone')
|
||||
const { registry } = await harness({ sessions: [header('s1', gone)] })
|
||||
const workspace = await registry.create(dir)
|
||||
await rm(gone, { recursive: true })
|
||||
await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/does not resolve/)
|
||||
})
|
||||
|
||||
it('rejects every attach while session persistence is absent', async () => {
|
||||
const dir = await makeDir('no-persistence')
|
||||
const { registry } = await harness({ sessions: 'absent' })
|
||||
const workspace = await registry.create(dir)
|
||||
await expect(workspace.attachSession(SessionId('s1'))).rejects.toThrow(/no session persistence/)
|
||||
})
|
||||
|
||||
it('is idempotent on both attach and detach — a no-op never writes', async () => {
|
||||
const dir = await makeDir('idem')
|
||||
const { registry, changes, setSessions } = await harness({ sessions: [header('s1', dir)] })
|
||||
const workspace = await registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
const written = changes.length
|
||||
// Re-attaching skips validation entirely: even with the session gone from
|
||||
// the listing, the id already being on the account resolves without IO.
|
||||
setSessions([])
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
await workspace.detachSession(SessionId('absent'))
|
||||
expect(changes.length).toBe(written)
|
||||
})
|
||||
|
||||
it('decides membership at the write-chain slot: unawaited detach then attach re-attaches', async () => {
|
||||
const dir = await makeDir('race')
|
||||
const { registry } = await harness({ sessions: [header('s1', dir)] })
|
||||
const workspace = await registry.create(dir)
|
||||
await workspace.attachSession(SessionId('s1'))
|
||||
// Both fire before either lands. Snapshot-based idempotence would see
|
||||
// 's1' still on the account and turn the attach into a no-op, losing it;
|
||||
// chain-slot decisions replay detach → attach in order. (The attach skips
|
||||
// re-validation off the same stale snapshot — the cwd fact is immutable —
|
||||
// and enqueues immediately, keeping the chain order deterministic here.)
|
||||
const detached = workspace.detachSession(SessionId('s1'))
|
||||
const attached = workspace.attachSession(SessionId('s1'))
|
||||
await Promise.all([detached, attached])
|
||||
expect(workspace.sessionIds).toEqual(['s1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('consistency projections', () => {
|
||||
it('filters accounted ids with no stored session and prunes them on the next mutation', async () => {
|
||||
const dir = await makeDir('stale')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000001')
|
||||
const pool = pooledRecord(id, record(dir, ['live', 'ghost']))
|
||||
const { registry } = await harness({ pool, sessions: [header('live', dir)] })
|
||||
const workspace = registry.get(id)!
|
||||
// Rule 1: the projection hides the dead id; the durable account still holds it.
|
||||
expect(workspace.sessionIds).toEqual(['live'])
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['live', 'ghost'])
|
||||
// Any mutation prunes it durably.
|
||||
await workspace.setTitle('renamed')
|
||||
expect(storedRecord(pool, id).sessionIds).toEqual(['live'])
|
||||
expect(workspace.title).toBe('renamed')
|
||||
})
|
||||
|
||||
it('serves the account unfiltered while session persistence is absent', async () => {
|
||||
const dir = await makeDir('unverifiable')
|
||||
const id = WorkspaceId('00000000-0000-4000-8000-000000000002')
|
||||
const pool = pooledRecord(id, record(dir, ['maybe']))
|
||||
const { registry } = await harness({ pool, sessions: 'absent' })
|
||||
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 () => {
|
||||
const dirA = await makeDir('double-a')
|
||||
const dirB = await makeDir('double-b')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000003', record(dirA, ['dup']))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000004', record(dirB, ['dup']))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/accounted/)
|
||||
})
|
||||
|
||||
it('rejects startup over a medium where two records claim one path', async () => {
|
||||
const dirA = await makeDir('claimed')
|
||||
const pool = pooledRecord('00000000-0000-4000-8000-000000000005', record(dirA, []))
|
||||
pool.media.get('workspace')!.tables.get('workspaces')!
|
||||
.set('00000000-0000-4000-8000-000000000006', record(dirA, []))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Storage)
|
||||
ctx.storage.backend.register('memory', new MemoryStorageBackend(pool))
|
||||
ctx.storage.mount('domain', new DomainFacility(ctx, { backend: 'memory', routes: {} }))
|
||||
await expect(Promise.resolve(ctx.plugin(WorkspaceRegistry))).rejects.toThrow(/claimed/)
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
const { registry } = await harness()
|
||||
const workspace = await registry.create(dir)
|
||||
expect(await workspace.status()).toBe('ok')
|
||||
await rm(dir, { recursive: true })
|
||||
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')
|
||||
})
|
||||
})
|
||||
39
packages/workspace/workspace/tsconfig.json
Normal file
39
packages/workspace/workspace/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../storage/storage"
|
||||
},
|
||||
{
|
||||
"path": "../../storage/storage-domain"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
pnpm-lock.yaml
generated
88
pnpm-lock.yaml
generated
@@ -3314,6 +3314,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/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-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-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':
|
||||
@@ -4420,6 +4480,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-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
|
||||
'@deepseek-ai/dsh-storage-domain':
|
||||
specifier: workspace:^
|
||||
version: link:../../storage/storage-domain
|
||||
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':
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"docs/testing.md": 1020,
|
||||
"examples/AGENTS.md": 310,
|
||||
"packages/AGENTS.md": 660,
|
||||
"packages/README.md": 760
|
||||
"packages/README.md": 790
|
||||
}
|
||||
|
||||
@@ -207,6 +207,11 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts',
|
||||
DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md',
|
||||
DomainSpec: 'domain declaration contract is owned by packages/storage/storage-domain/README.md',
|
||||
StorageBackend: 'backend contract is owned by packages/storage/storage/src/backend.ts',
|
||||
StorageForms: 'merge-extensible form map is owned by packages/storage/storage/src/index.ts',
|
||||
InvariantInstaller: 'service-local contribution contract is owned by packages/support/invariants/README.md',
|
||||
LocaleDict: 'service-local dictionary shape is owned by packages/client/i18n/src/index.ts',
|
||||
WebBootGraph: 'web boot graph wire shape is owned by packages/client/modules/src/client/index.ts',
|
||||
@@ -226,6 +231,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowResultInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
Workspace: 'workspace entity contract is owned by packages/workspace/workspace/README.md',
|
||||
WorkspaceId: 'branded id is owned by packages/workspace/workspace/README.md',
|
||||
}
|
||||
|
||||
/** Collect named references from parameter, generic-constraint/default, and return types. */
|
||||
|
||||
@@ -77,6 +77,8 @@ const GROUP_ORDER = [
|
||||
'session-persistence',
|
||||
'session-query',
|
||||
'session-title',
|
||||
'storage',
|
||||
'workspace',
|
||||
'support',
|
||||
'ui',
|
||||
]
|
||||
@@ -132,6 +134,23 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'acp', 'session-query', 'session-query-sqlite'],
|
||||
note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.',
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
pkg: 'storage',
|
||||
title: 'Non-session storage hub',
|
||||
mode: 'seam',
|
||||
implementations: ['storage-json', 'storage-sqlite'],
|
||||
consumers: ['storage-domain', 'workspace'],
|
||||
note: 'Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives.',
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
pkg: 'workspace',
|
||||
title: 'Workspace entity registry',
|
||||
mode: 'core',
|
||||
consumers: [],
|
||||
note: 'Owns WorkspaceId-branded records over the domain form; sessionIds is the single source of ownership truth. RPC and GUI consumers arrive next phase.',
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
pkg: 'session-query',
|
||||
|
||||
@@ -74,6 +74,8 @@
|
||||
"./packages/hooks/*/src/invariant.ts",
|
||||
"./packages/session-persistence/*/src/invariant.ts",
|
||||
"./packages/session-query/*/src/invariant.ts",
|
||||
"./packages/storage/*/src/invariant.ts",
|
||||
"./packages/workspace/*/src/invariant.ts",
|
||||
"./packages/sdk/*/src/invariant.ts",
|
||||
"./packages/ui/*/src/invariant.ts",
|
||||
"./packages/examples/*/src/invariant.ts",
|
||||
@@ -142,6 +144,8 @@
|
||||
"./packages/session-persistence/*/src",
|
||||
"./packages/session-query/*/src",
|
||||
"./packages/session-title/*/src",
|
||||
"./packages/storage/*/src",
|
||||
"./packages/workspace/*/src",
|
||||
"./packages/sdk/*/src",
|
||||
"./packages/ui/*/src",
|
||||
"./packages/examples/*/src",
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
{ "path": "./packages/session-persistence/session-persistence-sqlite" },
|
||||
{ "path": "./packages/session-query/session-query" },
|
||||
{ "path": "./packages/session-query/session-query-sqlite" },
|
||||
{ "path": "./packages/storage/storage" },
|
||||
{ "path": "./packages/storage/storage-json" },
|
||||
{ "path": "./packages/storage/storage-sqlite" },
|
||||
{ "path": "./packages/storage/storage-domain" },
|
||||
{ "path": "./packages/workspace/workspace" },
|
||||
{ "path": "./packages/session-title/session-title" },
|
||||
{ "path": "./packages/session-title/session-title-llm" },
|
||||
{ "path": "./packages/session-title/session-title-first-message-llm" },
|
||||
|
||||
Reference in New Issue
Block a user