1
/**2
* Domain declaration vocabulary. A spec object is the single source of a3
* domain's identity, layout, and record schemas: the owning package defines4
* it once with {@link defineDomain} and both the type surface and the runtime5
* (validation, descriptor projection) derive from it. Record schemas are zod6
* (`z.infer` keeps types un-duplicated and the same schemas later project to7
* RPC wire schemas); plugin `Config` stays schemastery.8
* @module @deepseek-ai/dsh-storage-domain/src/spec9
*/11
import type { ZodType } from 'zod'12
import { UNIT_NAME_RE, type KvUnitDescriptor } from '@deepseek-ai/dsh-storage'14
/** Global singleton declaration: schema plus the value used before the first write. */15
export interface DomainGlobalSpec<G> {16
/** Validates the stored global at the durable boundary. */17
readonly schema: ZodType<G>18
/** Value served when the medium holds no global yet; not written until the first `set`. */19
readonly initial: G20
}22
/**23
* One table declaration. `K` is a phantom key type (typically a branded24
* string) carried for compile-time projection only; keys are plain strings on25
* the medium.26
*/27
export interface DomainTableSpec<K extends string = string, V = unknown> {28
/** Validates every stored record at the durable boundary. */29
readonly valueSchema: ZodType<V>30
/** Phantom carrier for the key type; never present at runtime. */31
readonly __key?: K32
}34
/** Static declaration of one domain: identity, version, and record layout. */35
export interface DomainSpec {36
/** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */37
readonly name: string38
/** Current domain format version; reads enforce it according to the selected layout. */39
readonly version: number40
/**41
* Medium layout for the backend unit: `single` (the default) stores the42
* whole unit as one document; `per-record` stores each record as its own43
* document, for units whose records are large, sparse, or individually44
* disposable — the projection cache — and scopes version checks per record45
* (an unaccepted record document is discarded, never migrated).46
*/47
readonly layout?: 'single' | 'per-record'48
/**49
* Older domain versions whose stored records the current record schemas50
* also accept (the declaring owner vouches for that, typically by51
* declaring the fields older records lack as optional). `per-record` backends52
* read documents stamped with a listed version instead of discarding them,53
* and accept a legacy whole-unit file so stamped for the one-time54
* bootstrap; writes always stamp {@link version}.55
*/56
readonly compatibleVersions?: readonly number[]57
/**58
* What `open` does with a stored table record that fails its zod schema.59
* Absent (the default), the whole open rejects with `invalid-record` —60
* right for authoritative data. `'backup-and-skip'` is for domains whose61
* records are disposable derived data: the backend moves the record's62
* document aside (`KvUnit.backupRecord`), the failure is logged with63
* its cause, and the open continues with the record absent. A backend64
* without `backupRecord` (no per-record document to move) falls back65
* to the rejecting default. The global slot always rejects.66
*/67
readonly invalidRecords?: 'backup-and-skip'68
/** Optional global singleton slot. */69
readonly global?: DomainGlobalSpec<unknown>70
/** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */71
readonly tables: Record<string, DomainTableSpec>72
}74
/** Key type of one declared table, recovered from its phantom carrier. */75
export type TableKeyOf<S extends DomainSpec, N extends keyof S['tables']> =76
S['tables'][N] extends DomainTableSpec<infer K> ? K : never78
/** Value type of one declared table. */79
export type TableValueOf<S extends DomainSpec, N extends keyof S['tables']> =80
S['tables'][N] extends DomainTableSpec<string, infer V> ? V : never82
/** Global value type of a spec; `never` when the spec declares no global. */83
export type GlobalValueOf<S extends DomainSpec> =84
S['global'] extends DomainGlobalSpec<infer G> ? G : never86
/**87
* Declare one table.88
* @param schema - zod schema validating every stored record of this table.89
* @returns the table declaration, key-typed by `K`.90
*/91
export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V> {92
return { valueSchema: schema }93
}95
/**96
* Identity helper that pins a spec's literal types and validates its fields.97
* Misconfiguration fails loud at the owning package's module load, before any98
* medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version99
* that is not a non-negative integer, or a global schema that accepts `null`100
* all throw. The `null` rejection guards round-tripping: backends store the101
* global as opaque JSON with `null` as the "never written" sentinel, so a102
* nullable global would be indistinguishable from an absent one on reopen103
* (a stored `null` silently reverts to `initial`).104
* @param spec - The domain declaration.105
* @returns the same spec, narrowed to its literal type.106
*/107
export function defineDomain<S extends DomainSpec>(spec: S): S {108
if (!UNIT_NAME_RE.test(spec.name)) {109
throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`)110
}111
if (!Number.isInteger(spec.version) || spec.version < 0) {112
throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)113
}114
for (const compat of spec.compatibleVersions ?? []) {115
if (!Number.isInteger(compat) || compat < 0 || compat >= spec.version) {116
throw new Error(117
`domain '${spec.name}' compatibleVersions entries must be non-negative integers below version ${spec.version}, got ${compat}`,118
)119
}120
}121
if (spec.layout !== undefined) {122
// Runtime boundary: the union type is compile-time only — a spec built123
// from config could carry any value, and a bad one must fail loud here.124
const layout: string = spec.layout125
if (layout !== 'single' && layout !== 'per-record') {126
throw new Error(`domain '${spec.name}' layout must be 'single' or 'per-record', got ${layout}`)127
}128
}129
if (spec.invalidRecords !== undefined) {130
const policy: string = spec.invalidRecords131
if (policy !== 'backup-and-skip') {132
throw new Error(`domain '${spec.name}' invalidRecords must be 'backup-and-skip' when present, got ${policy}`)133
}134
}135
for (const table of Object.keys(spec.tables)) {136
if (!UNIT_NAME_RE.test(table)) {137
throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)138
}139
}140
if (spec.global !== undefined && spec.global.schema.safeParse(null).success) {141
throw new Error(142
`domain '${spec.name}' global schema must not accept null: `143
+ 'null is the medium\'s "never written" sentinel, so a stored null could not round-trip',144
)145
}146
return spec147
}149
/**150
* Project a spec onto the backend-facing unit descriptor.151
* @param spec - The domain declaration.152
* @returns the descriptor handed to `KvFacet.open`.153
*/154
export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {155
return {156
name: spec.name,157
version: spec.version,158
tables: Object.keys(spec.tables),159
hasGlobal: spec.global !== undefined,160
...spec.layout === undefined ? {} : { layout: spec.layout },161
...spec.compatibleVersions === undefined ? {} : { compatibleVersions: spec.compatibleVersions },162
}163
}