persistence-schema-model.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. /**
  2. * Source-independent JSON type graphs and canonical persistence fingerprints.
  3. * Numeric references are local to one graph; names and source locations are metadata.
  4. */
  5. import { createHash } from 'node:crypto'
  6. /** One JSON property, with absence represented independently of its value type. */
  7. export interface SchemaProperty {
  8. readonly name: string
  9. readonly type: number
  10. readonly optional: boolean
  11. }
  12. /** One positional tuple element. */
  13. export interface SchemaTupleElement {
  14. readonly type: number
  15. readonly optional: boolean
  16. readonly rest: boolean
  17. }
  18. /** A resolved persisted type; references address nodes in the enclosing graph. */
  19. export type SchemaNode =
  20. | { readonly kind: 'primitive'; readonly type: 'null' | 'boolean' | 'number' | 'string' | 'never' }
  21. | { readonly kind: 'literal'; readonly value: string | number | boolean }
  22. | { readonly kind: 'opaque'; readonly reason: 'any' | 'unknown' }
  23. | { readonly kind: 'array'; readonly element: number }
  24. | { readonly kind: 'tuple'; readonly elements: readonly SchemaTupleElement[] }
  25. | { readonly kind: 'object'; readonly properties: readonly SchemaProperty[]; readonly indices: readonly { readonly key: number; readonly value: number }[] }
  26. | { readonly kind: 'union'; readonly types: readonly number[] }
  27. /** A self-contained minimized graph with deterministic root-first node numbering. */
  28. export interface CanonicalSchema {
  29. readonly root: 0
  30. readonly nodes: readonly SchemaNode[]
  31. }
  32. /** One independently tracked persistent record vocabulary. */
  33. export interface PersistenceRoot {
  34. readonly key: string
  35. readonly kind: 'header' | 'envelope' | 'event'
  36. readonly event?: string
  37. readonly surface?: boolean
  38. readonly digest: string
  39. readonly schema: CanonicalSchema
  40. }
  41. /** One reachable structural type and its non-fingerprinted source declaration metadata. */
  42. export interface PersistenceType {
  43. readonly digest: string
  44. readonly schema: CanonicalSchema
  45. readonly names: readonly string[]
  46. readonly sources: readonly string[]
  47. }
  48. /** Complete current-source persistence inventory; version pins normalization too. */
  49. export interface PersistenceSchemaInventory {
  50. readonly formatVersion: 1
  51. readonly roots: readonly PersistenceRoot[]
  52. readonly types: readonly PersistenceType[]
  53. }
  54. /**
  55. * Visit direct graph edges in their normalized semantic order.
  56. * @param node - resolved graph node.
  57. * @returns referenced node indices, including repeated ordered edges.
  58. */
  59. export function schemaChildren(node: SchemaNode): number[] {
  60. switch (node.kind) {
  61. case 'array': return [node.element]
  62. case 'tuple': return node.elements.map(element => element.type)
  63. case 'object': return [...node.properties.map(property => property.type), ...node.indices.flatMap(index => [index.key, index.value])]
  64. case 'union': return [...node.types]
  65. case 'primitive':
  66. case 'literal':
  67. case 'opaque': return []
  68. default: return assertNever(node)
  69. }
  70. }
  71. function mapNode(node: SchemaNode, ref: (id: number) => number): SchemaNode {
  72. switch (node.kind) {
  73. case 'array': return { kind: 'array', element: ref(node.element) }
  74. case 'tuple': return { kind: 'tuple', elements: node.elements.map(element => ({ type: ref(element.type), optional: element.optional, rest: element.rest })) }
  75. case 'object': return {
  76. kind: 'object',
  77. properties: [...node.properties].sort((left, right) => compare(left.name, right.name))
  78. .map(property => ({ name: property.name, type: ref(property.type), optional: property.optional })),
  79. indices: node.indices.map(index => ({ key: ref(index.key), value: ref(index.value) }))
  80. .sort((left, right) => left.key - right.key || left.value - right.value),
  81. }
  82. case 'union': return { kind: 'union', types: [...new Set(node.types.map(ref))].sort((left, right) => left - right) }
  83. case 'primitive': return { kind: 'primitive', type: node.type }
  84. case 'literal': return { kind: 'literal', value: node.value }
  85. case 'opaque': return { kind: 'opaque', reason: node.reason }
  86. default: return assertNever(node)
  87. }
  88. }
  89. function partition(nodes: readonly SchemaNode[]): number[] {
  90. let colors = nodes.map(() => 0)
  91. for (;;) {
  92. const signatures = nodes.map((node, index) => JSON.stringify([colors[index], mapNode(node, id => colors[id] as number)]))
  93. const ordered = [...new Set(signatures)].sort(compare)
  94. const ids = new Map(ordered.map((value, index) => [value, index]))
  95. const next = signatures.map(value => ids.get(value) as number)
  96. const unchanged = new Set(next).size === new Set(colors).size
  97. colors = next
  98. if (unchanged) return colors
  99. }
  100. }
  101. /**
  102. * Minimize bisimilar recursive nodes and number the reachable graph deterministically.
  103. * @param input - resolved nodes; property and union order may be arbitrary.
  104. * @param root - index of the requested root.
  105. * @returns canonical graph excluding unreachable nodes and duplicate structures.
  106. */
  107. export function canonicalizeSchema(input: readonly SchemaNode[], root: number): CanonicalSchema {
  108. input = normalizeUnions(input)
  109. const selected: number[] = []
  110. const positions = new Map<number, number>()
  111. const select = (id: number): void => {
  112. if (!Number.isInteger(id) || id < 0 || id >= input.length) throw new Error(`persistence schema: missing node ${String(id)}`)
  113. if (positions.has(id)) return
  114. positions.set(id, selected.length)
  115. selected.push(id)
  116. for (const child of schemaChildren(input[id] as SchemaNode)) select(child)
  117. }
  118. select(root)
  119. let nodes = selected.map(id => mapNode(input[id] as SchemaNode, child => positions.get(child) as number))
  120. let rootIndex = 0
  121. for (;;) {
  122. const colors = partition(nodes)
  123. const representatives = new Map<number, number>()
  124. colors.forEach((color, index) => { if (!representatives.has(color)) representatives.set(color, index) })
  125. const aliases = new Map<number, number>()
  126. nodes.forEach((node, index) => {
  127. if (node.kind !== 'union') return
  128. const members = [...new Set(node.types.map(child => colors[child] as number))]
  129. if (members.length === 1) aliases.set(index, representatives.get(members[0] as number) as number)
  130. })
  131. if (aliases.size > 0) {
  132. const resolve = (id: number): number => {
  133. const seen = new Set<number>()
  134. while (aliases.has(id)) {
  135. if (seen.has(id)) throw new Error('persistence schema: union cycle has no material type')
  136. seen.add(id)
  137. id = aliases.get(id) as number
  138. }
  139. return id
  140. }
  141. rootIndex = resolve(rootIndex)
  142. nodes = nodes.map((node, index) => aliases.has(index) ? nodes[resolve(index)] as SchemaNode : mapNode(node, resolve))
  143. continue
  144. }
  145. const emitted = new Map<number, number>()
  146. const result: SchemaNode[] = []
  147. const visit = (id: number): number => {
  148. const color = colors[id] as number
  149. const existing = emitted.get(color)
  150. if (existing !== undefined) return existing
  151. const position = result.length
  152. emitted.set(color, position)
  153. result.push({ kind: 'primitive', type: 'never' })
  154. const representative = representatives.get(color) as number
  155. const colored = mapNode(nodes[representative] as SchemaNode, child => colors[child] as number)
  156. result[position] = mapNode(colored, child => visit(representatives.get(child) as number))
  157. return position
  158. }
  159. visit(rootIndex)
  160. return { root: 0, nodes: result }
  161. }
  162. }
  163. function normalizeUnions(input: readonly SchemaNode[]): SchemaNode[] {
  164. const result = [...input]
  165. const flattened = (id: number, visiting: Set<number>): number[] => {
  166. const node = input[id]
  167. if (node === undefined) throw new Error(`persistence schema: missing node ${String(id)}`)
  168. if (node.kind !== 'union') return [id]
  169. if (visiting.has(id)) throw new Error('persistence schema: union cycle has no material type')
  170. const next = new Set(visiting).add(id)
  171. return node.types.flatMap(child => flattened(child, next))
  172. }
  173. for (const [id, node] of input.entries()) {
  174. if (node.kind !== 'union') continue
  175. let members = [...new Set(flattened(id, new Set()))]
  176. const primitives = new Set(members.flatMap((child) => {
  177. const value = input[child] as SchemaNode
  178. return value.kind === 'primitive' ? [value.type] : []
  179. }))
  180. const literals = members.flatMap((child) => {
  181. const value = input[child] as SchemaNode
  182. return value.kind === 'literal' ? [value.value] : []
  183. })
  184. if (!primitives.has('boolean') && literals.includes(true) && literals.includes(false)) {
  185. primitives.add('boolean')
  186. members.push(result.length)
  187. result.push({ kind: 'primitive', type: 'boolean' })
  188. }
  189. members = members.filter((child) => {
  190. const value = result[child] as SchemaNode
  191. if (value.kind === 'primitive') return value.type !== 'never'
  192. return value.kind !== 'literal' || !primitives.has(typeof value.value as 'string' | 'number' | 'boolean')
  193. })
  194. result[id] = members.length === 0 ? { kind: 'primitive', type: 'never' } : { kind: 'union', types: members }
  195. }
  196. return result
  197. }
  198. /**
  199. * Compute the versioned SHA-256 fingerprint of a canonical graph.
  200. * @param schema - canonical resolved persisted type.
  201. * @returns lowercase hexadecimal digest.
  202. */
  203. export function schemaDigest(schema: CanonicalSchema): string {
  204. return createHash('sha256').update('dsh-persistence-schema-v1\n').update(JSON.stringify(schema)).digest('hex')
  205. }
  206. /**
  207. * Recognize the complete recursive JSON value language without relying on type names.
  208. * @param schema - canonical resolved type.
  209. * @returns whether the type permits arbitrary JSON values.
  210. */
  211. export function isArbitraryJsonSchema(schema: CanonicalSchema): boolean {
  212. return schemaDigest(schema) === JSON_VALUE_DIGEST
  213. }
  214. const JSON_VALUE_DIGEST = schemaDigest(canonicalizeSchema([
  215. { kind: 'union', types: [1, 2, 3, 4, 5, 6] },
  216. { kind: 'primitive', type: 'null' },
  217. { kind: 'primitive', type: 'boolean' },
  218. { kind: 'primitive', type: 'number' },
  219. { kind: 'primitive', type: 'string' },
  220. { kind: 'array', element: 0 },
  221. { kind: 'object', properties: [], indices: [{ key: 4, value: 0 }] },
  222. ], 0))
  223. function compare(left: string, right: string): number {
  224. return left < right ? -1 : left > right ? 1 : 0
  225. }
  226. function assertNever(value: never): never {
  227. throw new Error(`persistence schema: unsupported node ${JSON.stringify(value)}`)
  228. }