spec.ts 4.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  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. import type { ZodType } from 'zod'
  11. import { UNIT_NAME_RE, type KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
  12. /** Global singleton declaration: schema plus the value used before the first write. */
  13. export interface DomainGlobalSpec<G> {
  14. /** Validates the stored global at the durable boundary. */
  15. readonly schema: ZodType<G>
  16. /** Value served when the medium holds no global yet; not written until the first `set`. */
  17. readonly initial: G
  18. }
  19. /**
  20. * One table declaration. `K` is a phantom key type (typically a branded
  21. * string) carried for compile-time projection only; keys are plain strings on
  22. * the medium.
  23. */
  24. export interface DomainTableSpec<K extends string = string, V = unknown> {
  25. /** Validates every stored record at the durable boundary. */
  26. readonly valueSchema: ZodType<V>
  27. /** Phantom carrier for the key type; never present at runtime. */
  28. readonly __key?: K
  29. }
  30. /** Static declaration of one domain: identity, version, and record layout. */
  31. export interface DomainSpec {
  32. /** Domain name; must match `UNIT_NAME_RE` (doubles as the backend unit name). */
  33. readonly name: string
  34. /** Domain format version; a medium stamped with a different version rejects at open. */
  35. readonly version: number
  36. /** Optional global singleton slot. */
  37. readonly global?: DomainGlobalSpec<unknown>
  38. /** Table declarations keyed by table name; each name must match `UNIT_NAME_RE`. */
  39. readonly tables: Record<string, DomainTableSpec>
  40. }
  41. /** Key type of one declared table, recovered from its phantom carrier. */
  42. export type TableKeyOf<S extends DomainSpec, N extends keyof S['tables']> =
  43. S['tables'][N] extends DomainTableSpec<infer K> ? K : never
  44. /** Value type of one declared table. */
  45. export type TableValueOf<S extends DomainSpec, N extends keyof S['tables']> =
  46. S['tables'][N] extends DomainTableSpec<string, infer V> ? V : never
  47. /** Global value type of a spec; `never` when the spec declares no global. */
  48. export type GlobalValueOf<S extends DomainSpec> =
  49. S['global'] extends DomainGlobalSpec<infer G> ? G : never
  50. /**
  51. * Declare one table.
  52. * @param schema - zod schema validating every stored record of this table.
  53. * @returns the table declaration, key-typed by `K`.
  54. */
  55. export function domainTable<K extends string, V>(schema: ZodType<V>): DomainTableSpec<K, V> {
  56. return { valueSchema: schema }
  57. }
  58. /**
  59. * Identity helper that pins a spec's literal types and validates its fields.
  60. * Misconfiguration fails loud at the owning package's module load, before any
  61. * medium is touched: a domain or table name outside `UNIT_NAME_RE`, a version
  62. * that is not a non-negative integer, or a global schema that accepts `null`
  63. * all throw. The `null` rejection guards round-tripping: backends store the
  64. * global as opaque JSON with `null` as the "never written" sentinel, so a
  65. * nullable global would be indistinguishable from an absent one on reopen
  66. * (a stored `null` silently reverts to `initial`).
  67. * @param spec - The domain declaration.
  68. * @returns the same spec, narrowed to its literal type.
  69. */
  70. export function defineDomain<S extends DomainSpec>(spec: S): S {
  71. if (!UNIT_NAME_RE.test(spec.name)) {
  72. throw new Error(`domain name '${spec.name}' must match ${UNIT_NAME_RE}`)
  73. }
  74. if (!Number.isInteger(spec.version) || spec.version < 0) {
  75. throw new Error(`domain '${spec.name}' version must be a non-negative integer, got ${spec.version}`)
  76. }
  77. for (const table of Object.keys(spec.tables)) {
  78. if (!UNIT_NAME_RE.test(table)) {
  79. throw new Error(`domain '${spec.name}' table name '${table}' must match ${UNIT_NAME_RE}`)
  80. }
  81. }
  82. if (spec.global !== undefined && spec.global.schema.safeParse(null).success) {
  83. throw new Error(
  84. `domain '${spec.name}' global schema must not accept null: `
  85. + 'null is the medium\'s "never written" sentinel, so a stored null could not round-trip',
  86. )
  87. }
  88. return spec
  89. }
  90. /**
  91. * Project a spec onto the backend-facing unit descriptor.
  92. * @param spec - The domain declaration.
  93. * @returns the descriptor handed to `KvFacet.open`.
  94. */
  95. export function descriptorOf(spec: DomainSpec): KvUnitDescriptor {
  96. return {
  97. name: spec.name,
  98. version: spec.version,
  99. tables: Object.keys(spec.tables),
  100. hasGlobal: spec.global !== undefined,
  101. }
  102. }