|
|
@@ -0,0 +1,322 @@
|
|
|
+/**
|
|
|
+ * Runtime of one open domain: authoritative in-memory state, the single
|
|
|
+ * per-domain write chain, and change-event emission. Reads are synchronous
|
|
|
+ * from memory; every write queues on the chain, mutates memory, awaits
|
|
|
+ * backend durability, then emits `domain/changed` — so events carry values
|
|
|
+ * that equal the in-memory state at emission and arrive in write order.
|
|
|
+ * @module @deepseek-ai/dsh-domain/src/domain
|
|
|
+ */
|
|
|
+
|
|
|
+import type { Context } from 'cordis'
|
|
|
+import type { KvUnit } from '@deepseek-ai/dsh-storage'
|
|
|
+import { DomainError } from './error.ts'
|
|
|
+import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts'
|
|
|
+import type { DomainChanged } from './events.ts'
|
|
|
+
|
|
|
+/** Handle on a domain's global singleton. */
|
|
|
+export interface DomainGlobal<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,
|
|
|
+ })
|
|
|
+ }
|
|
|
+}
|