返回源码地图

packages/storage/storage-domain/src/spec.ts

main snapshot · da00f7f5358f · 正文引用章节 16;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Domain declaration vocabulary. A spec object is the single source of a
3 * domain's identity, layout, and record schemas: the owning package defines
4 * it once with {@link defineDomain} and both the type surface and the runtime
5 * (validation, descriptor projection) derive from it. Record schemas are zod
6 * (`z.infer` keeps types un-duplicated and the same schemas later project to
7 * RPC wire schemas); plugin `Config` stays schemastery.
8 * @module @deepseek-ai/dsh-storage-domain/src/spec
9 */
10
11import type { ZodType } from 'zod'
12import { UNIT_NAME_RE, type KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
13
14/** Global singleton declaration: schema plus the value used before the first write. */
15export 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: G
20}
21
22/**
23 * One table declaration. `K` is a phantom key type (typically a branded
24 * string) carried for compile-time projection only; keys are plain strings on
25 * the medium.
26 */
27export 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?: K
32}
33
34/** Static declaration of one domain: identity, version, and record layout. */
35export interface DomainSpec {
36 /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
37 readonly name: string
38 /** Current domain format version; reads enforce it according to the selected layout. */
39 readonly version: number
40 /**
41 * Medium layout for the backend unit: `single` (the default) stores the
42 * whole unit as one document; `per-record` stores each record as its own
43 * document, for units whose records are large, sparse, or individually
44 * disposable — the projection cache — and scopes version checks per record
45 * (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 schemas
50 * also accept (the declaring owner vouches for that, typically by
51 * declaring the fields older records lack as optional). `per-record` backends
52 * read documents stamped with a listed version instead of discarding them,
53 * and accept a legacy whole-unit file so stamped for the one-time
54 * 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 whose
61 * records are disposable derived data: the backend moves the record's
62 * document aside (`KvUnit.backupRecord`), the failure is logged with
63 * its cause, and the open continues with the record absent. A backend
64 * without `backupRecord` (no per-record document to move) falls back
65 * 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}
73
74/** Key type of one declared table, recovered from its phantom carrier. */
75export type TableKeyOf<S extends DomainSpec, N extends keyof S['tables']> =
76 S['tables'][N] extends DomainTableSpec<infer K> ? K : never
77
78/** Value type of one declared table. */
79export type TableValueOf<S extends DomainSpec, N extends keyof S['tables']> =
80 S['tables'][N] extends DomainTableSpec<string, infer V> ? V : never
81
82/** Global value type of a spec; `never` when the spec declares no global. */
83export type GlobalValueOf<S extends DomainSpec> =
84 S['global'] extends DomainGlobalSpec<infer G> ? G : never
85
86/**
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 */
91export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V> {
92 return { valueSchema: schema }
93}
94
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 any
98 * medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version
99 * 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 the
101 * global as opaque JSON with `null` as the "never written" sentinel, so a
102 * nullable global would be indistinguishable from an absent one on reopen
103 * (a stored `null` silently reverts to `initial`).
104 * @param spec - The domain declaration.
105 * @returns the same spec, narrowed to its literal type.
106 */
107export 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 built
123 // from config could carry any value, and a bad one must fail loud here.
124 const layout: string = spec.layout
125 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.invalidRecords
131 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 spec
147}
148
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 */
154export 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}