docs(storage): replace design-sketch fences with prose in the bilingual note

CI's static lane counts opted-out ts fences repo-wide and the note's
ignore-check sketches tipped the ratio past 50%. The hub/backend/event
shapes those fences sketched are all shipped code now, so the sections
point at the owning source files (src/index.ts, src/backend.ts,
src/error.ts, src/events.ts) instead of restating signatures; both
language sides move together and the pairing record is re-confirmed.
This commit is contained in:
imccyu
2026-07-24 23:26:41 +08:00
parent 0559e0207e
commit 25a4a2063b
3 changed files with 10 additions and 178 deletions

View File

@@ -2,5 +2,5 @@
# 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: 96299afae94245c1441643407a21a8fab0efcad5
2026-07-24-domain-kv-storage-and-workspace.zh.md: 67921a4eec4d322aefc705042b6356b156755a7f
2026-07-24-domain-kv-storage-and-workspace.md: f6b74b656270f4791fd9369438235c7fc1b1edb8
2026-07-24-domain-kv-storage-and-workspace.zh.md: f6649fc0c387d9c1240d45efda4b4a0ab9372486

View File

@@ -33,65 +33,11 @@ Dependency direction: `dsh-workspace` → `dsh-domain` → `dsh-storage` ← the
### `dsh-storage`: the storage hub
A pure registration hub, no IO of its own, no Config.
```ts ignore-check
declare module 'cordis' { interface Context { storage: Storage } }
export class Storage extends Service {
constructor(ctx: Context) // super(ctx, 'storage')
readonly backend: BackendRegistry
/** Domain data form; present once dsh-domain is loaded. Unmounted access → throw. */
readonly domain: DomainFacility
/** Mount a data-form facility. Returns the disposer. Duplicate mount → throw. */
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void
}
/** Merge-extensible map of data forms; dsh-domain merges `domain: DomainFacility`. */
export interface StorageForms {}
export class BackendRegistry {
/** Register a named backend. Returns the disposer (unregisters). Duplicate name → throw. */
register(name: string, backend: StorageBackend): () => void
/** Resolve by name. Unknown → throw StorageError('backend-not-found'). */
get(name: string): StorageBackend
names(): string[]
}
```
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:
```ts ignore-check
export interface StorageBackend {
readonly name: string
readonly kv?: KvFacet // 迁移期扩展readonly log?: LogFacet
/** Drain in-flight writes and release the medium. Idempotent. */
close(): Promise<void>
}
export interface KvFacet {
/** Open (create or load) one unit. Version mismatch / malformed medium → throw. */
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
}
export interface KvUnitDescriptor {
readonly name: string // ^[a-z][a-z0-9_]*$,兼作文件名/SQL 表名段
readonly version: number
readonly tables: readonly string[] // 同字符集约束
readonly hasGlobal: boolean
}
/** One opened unit. Values are opaque JSON to this layer. */
export interface KvUnit {
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }>
putRecord(table: string, key: string, value: unknown): Promise<void>
deleteRecord(table: string, key: string): Promise<void> // missing key = no-op
setGlobal(value: unknown): Promise<void>
close(): Promise<void> // idempotent
}
```
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):
@@ -103,12 +49,7 @@ The backend contract (asserted clause by clause by the shared conformance suite,
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')`.
```ts ignore-check
export type StorageErrorCode =
| 'backend-not-found' | 'form-not-mounted' | 'duplicate-backend' | 'duplicate-mount'
| 'version-mismatch' | 'malformed-medium' | 'closed'
export class StorageError extends Error { readonly code: StorageErrorCode }
```
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`
@@ -216,32 +157,7 @@ Rules:
- **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**: emitted after each write's durability resolves, one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); this is next phase's RPC push-frame event source:
```ts ignore-check
declare module 'cordis' {
interface Events {
/**
* A domain record or global changed (post-durability).
* @mode emit
* @param change - domain, table ('' for global), key ('' for global),
* operation, and the new snapshot (absent for deletions).
*/
'domain/changed'(change: DomainChanged): void
}
}
export interface DomainChanged {
readonly domain: string
readonly table: string
readonly key: string
readonly operation: 'put' | 'deleted'
readonly value?: unknown
}
export type DomainErrorCode =
| 'already-open' | 'facet-unsupported' | 'invalid-record' | 'missing-key' | 'closed'
export class DomainError extends Error { readonly code: DomainErrorCode }
```
- **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/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)

View File

@@ -33,65 +33,11 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session-persis
### `dsh-storage`:存储枢纽
纯注册枢纽,自身不做 IO无 Config。
```ts ignore-check
declare module 'cordis' { interface Context { storage: Storage } }
export class Storage extends Service {
constructor(ctx: Context) // super(ctx, 'storage')
readonly backend: BackendRegistry
/** Domain data form; present once dsh-domain is loaded. Unmounted access → throw. */
readonly domain: DomainFacility
/** Mount a data-form facility. Returns the disposer. Duplicate mount → throw. */
mount<K extends keyof StorageForms>(form: K, facility: StorageForms[K]): () => void
}
/** Merge-extensible map of data forms; dsh-domain merges `domain: DomainFacility`. */
export interface StorageForms {}
export class BackendRegistry {
/** Register a named backend. Returns the disposer (unregisters). Duplicate name → throw. */
register(name: string, backend: StorageBackend): () => void
/** Resolve by name. Unknown → throw StorageError('backend-not-found'). */
get(name: string): StorageBackend
names(): string[]
}
```
纯注册枢纽,自身不做 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
```ts ignore-check
export interface StorageBackend {
readonly name: string
readonly kv?: KvFacet // 迁移期扩展readonly log?: LogFacet
/** Drain in-flight writes and release the medium. Idempotent. */
close(): Promise<void>
}
export interface KvFacet {
/** Open (create or load) one unit. Version mismatch / malformed medium → throw. */
open(descriptor: KvUnitDescriptor): Promise<KvUnit>
}
export interface KvUnitDescriptor {
readonly name: string // ^[a-z][a-z0-9_]*$,兼作文件名/SQL 表名段
readonly version: number
readonly tables: readonly string[] // 同字符集约束
readonly hasGlobal: boolean
}
/** One opened unit. Values are opaque JSON to this layer. */
export interface KvUnit {
loadAll(): Promise<{ tables: Record<string, Record<string, unknown>>; global: unknown | null }>
putRecord(table: string, key: string, value: unknown): Promise<void>
deleteRecord(table: string, key: string): Promise<void> // missing key = no-op
setGlobal(value: unknown): Promise<void>
close(): Promise<void> // idempotent
}
```
一个后端是一个**介质 owner**(一棵文件树 root / 一个 db 文件),通过**数据形状 facet** 暴露原语——本期只有 `kv`session 迁移期加 `log`见迁移节。facet 是可选成员,缺席即该后端不支持该形状,解析时 fail loud`kv` facet 的原语面:`open(descriptor)`descriptor = 名字/版本/表名清单/有无 global名字与表名限 `^[a-z][a-z0-9_]*$` 兼作文件名与 SQL 表名段)返回 unitunit 提供 `loadAll` / `putRecord` / `deleteRecord`(缺 key 为 no-op/ `setGlobal` / `close`(幂等);值对后端是不透明 JSON。规范正文含逐方法 JSDoc`packages/storage/storage/src/backend.ts`
backend 契约(共享契约测试逐条断言,两后端同套件):
@@ -103,12 +49,7 @@ backend 契约(共享契约测试逐条断言,两后端同套件):
6. 任意字符串 key / 任意 JSON 值安全key 不进文件路径,结构性质)。
7. `close` 幂等close 后任何操作 → `StorageError('closed')`
```ts ignore-check
export type StorageErrorCode =
| 'backend-not-found' | 'form-not-mounted' | 'duplicate-backend' | 'duplicate-mount'
| 'version-mismatch' | 'malformed-medium' | 'closed'
export class StorageError extends Error { readonly code: StorageErrorCode }
```
错误词汇是带 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`
@@ -216,32 +157,7 @@ export interface KvTable<K extends string, V> {
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO`get`/`entries` 返回值不得原地改TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费者包。
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record取出可变对象自动落盘——落盘时机不可控与整域原子覆写冲突
- **版本 fail loud**:盘上版本与 spec 不符直接报错不迁移不重建数据不可再生pre-release 拒绝旧格式)。
- **变更事件**:每次写落盘 resolve 后 emit逐条发、不带旧值对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`此为下期 RPC 推帧的事件源:
```ts ignore-check
declare module 'cordis' {
interface Events {
/**
* A domain record or global changed (post-durability).
* @mode emit
* @param change - domain, table ('' for global), key ('' for global),
* operation, and the new snapshot (absent for deletions).
*/
'domain/changed'(change: DomainChanged): void
}
}
export interface DomainChanged {
readonly domain: string
readonly table: string
readonly key: string
readonly operation: 'put' | 'deleted'
readonly value?: unknown
}
export type DomainErrorCode =
| 'already-open' | 'facet-unsupported' | 'invalid-record' | 'missing-key' | 'closed'
export class DomainError extends Error { readonly code: DomainErrorCode }
```
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed``@mode emit`,逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + keyglobal 变更两者为 `''`+ operationput 支带新快照 value、deleted 支无 value`packages/storage/domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`/ `missing-key` / `closed`。
### Future worksession 侧删除(设计定案,本期不实施)