Files
deepseek-harness/packages/storage/domain/src/domain.ts
imccyu 7c27107be4 feat(storage): domain data form — typed schemas over opaque KV units
ctx.storage.domain opens declared domains: zod value schemas parsed at
the durable boundary, one write chain per domain (update(fn) is the only
read-modify-write), domain/changed emitted per record after durability
(new snapshot + operation, no old value, per repo event convention).
Domain-to-backend routing is configuration (default backend + per-domain
overrides); unknown names and missing facets fail loud. Ships the
MemoryStorageBackend test helper and a runtime invariant asserting every
change event matches the in-memory state.
2026-07-25 11:08:04 +08:00

323 lines
11 KiB
TypeScript

/**
* Runtime of one open domain: authoritative in-memory state, the single
* per-domain write chain, and change-event emission. Reads are synchronous
* from memory; every write queues on the chain, mutates memory, awaits
* backend durability, then emits `domain/changed` — so events carry values
* that equal the in-memory state at emission and arrive in write order.
* @module @deepseek-ai/dsh-domain/src/domain
*/
import type { Context } from 'cordis'
import type { KvUnit } from '@deepseek-ai/dsh-storage'
import { DomainError } from './error.ts'
import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts'
import type { DomainChanged } from './events.ts'
/** Handle on a domain's global singleton. */
export interface DomainGlobal<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>>
}
/** 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 dispose begins: new writes reject while already-queued writes drain. */
private disposing = false
/** Set when dispose finishes (chain drained, unit closed): reads reject from here on. */
private closed = false
private disposal?: Promise<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 per-table records from the unit's `loadAll`.
* @param globalValue - Validated stored global, or the spec's `initial`
* when the medium held none; `undefined` when the spec declares no global.
*/
constructor(
private readonly ctx: Context,
spec: DomainSpec,
private readonly unit: KvUnit,
records: Map<string, Map<string, unknown>>,
globalValue: unknown,
) {
this.name = spec.name
const host: TableHost = {
domainName: spec.name,
unit,
enqueue: (job) => this.enqueue(job),
assertReadable: () => this.assertReadable(),
emitChanged: (change) => this.ctx.emit('domain/changed', change),
}
for (const table of Object.keys(spec.tables)) {
this.tables.set(table, new KvTableImpl(host, table, records.get(table) ?? new Map()))
}
if (spec.global !== undefined) {
this.globalValue = globalValue
this.globalHandle = {
get: () => {
this.assertReadable()
return this.globalValue
},
set: (value) => this.enqueue(async () => {
this.globalValue = value
await this.unit.setGlobal(value)
host.emitChanged({ domain: this.name, table: '', key: '', operation: 'put', value })
}),
}
}
}
/** Global singleton handle; accessing it on a spec that declares no global is a caller bug and throws. */
get global(): DomainGlobal<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), then close the unit. Idempotent —
* repeated calls share one teardown.
* @returns resolution after the unit is released.
*/
dispose(): Promise<void> {
this.disposal ??= this.runDispose()
return this.disposal
}
private async runDispose(): 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
}
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 () => {
this.records.set(key, value)
await this.host.unit.putRecord(this.tableName, 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
this.records.delete(key)
await this.host.unit.deleteRecord(this.tableName, key)
this.host.emitChanged({
domain: this.host.domainName,
table: this.tableName,
key,
operation: 'deleted',
})
return true
})
}
update(key: K, fn: (current: V) => V): Promise<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)
this.records.set(key, next)
await this.host.unit.putRecord(this.tableName, key, next)
this.emitPut(key, next)
return next
})
}
private emitPut(key: K, value: V): void {
this.host.emitChanged({
domain: this.host.domainName,
table: this.tableName,
key,
operation: 'put',
value,
})
}
}