model.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. /**
  2. * Schema introspection and draft-editing helpers behind settings editors.
  3. * The serialized schemastery envelope (`schema.toJSON()`) rehydrates into a
  4. * live validator whose node relations (`dict`/`inner`) editors probe for
  5. * field presence and roles; drafts are edited immutably by path.
  6. * @module @deepseek-ai/dsh-client-schema-form/model
  7. */
  8. import Schema from 'schemastery'
  9. /** Live schemastery node; the renderer reads only its structural relations. */
  10. export type SchemaNode = Schema
  11. /**
  12. * Rehydrate a serialized schema envelope into a live validator/node tree.
  13. * @param serialized - `schema.toJSON()` output received over the wire.
  14. * @returns the root schema node.
  15. */
  16. export function rehydrateSchema(serialized: unknown): SchemaNode {
  17. return new Schema(serialized as Schema)
  18. }
  19. /**
  20. * Validate a draft against a rehydrated schema.
  21. * @param schema - rehydrated root node.
  22. * @param draft - candidate value.
  23. * @returns the validation failure message, or `undefined` when the draft passes.
  24. */
  25. export function validateDraft(schema: SchemaNode, draft: unknown): string | undefined {
  26. try {
  27. ;(schema as unknown as (value: unknown) => unknown)(draft)
  28. return undefined
  29. } catch (error) {
  30. return error instanceof Error ? error.message : String(error)
  31. }
  32. }
  33. /**
  34. * Resolve the schema node at a settings path (the configurable-provider
  35. * directory's `settingsPath` vocabulary): object properties by name, dict
  36. * entries through `inner`. An unresolvable segment returns `undefined` so
  37. * the caller falls back instead of rendering a wrong subtree.
  38. * @param root - rehydrated section root node.
  39. * @param path - key path from the section root.
  40. * @returns the node describing that position, or `undefined`.
  41. */
  42. export function nodeAtPath(root: SchemaNode, path: readonly string[]): SchemaNode | undefined {
  43. let node: SchemaNode | undefined = root
  44. for (const key of path) {
  45. if (node === undefined) return undefined
  46. if (node.type === 'object') node = (node.dict as Record<string, SchemaNode> | undefined)?.[key]
  47. else if (node.type === 'dict' || node.type === 'array') node = node.inner as SchemaNode | undefined
  48. else return undefined
  49. }
  50. return node
  51. }
  52. /**
  53. * Read a nested value by path.
  54. * @param value - root value (draft or fallback layer).
  55. * @param path - key path from the root; array indexes as strings.
  56. * @returns the value at the path, or `undefined` along a missing branch.
  57. */
  58. export function getPath(value: unknown, path: readonly string[]): unknown {
  59. let current: unknown = value
  60. for (const key of path) {
  61. if (Array.isArray(current)) {
  62. current = current[Number(key)]
  63. continue
  64. }
  65. if (typeof current !== 'object' || current === null) return undefined
  66. current = (current as Record<string, unknown>)[key]
  67. }
  68. return current
  69. }
  70. /**
  71. * Whether a draft explicitly carries the path (its presence marks a user
  72. * override, independent of the value stored there).
  73. * @param value - root value (draft or fallback layer).
  74. * @param path - key path from the root; array indexes as strings.
  75. * @returns whether the path's final key exists on its parent.
  76. */
  77. export function hasPath(value: unknown, path: readonly string[]): boolean {
  78. if (path.length === 0) return value !== undefined
  79. const parent = getPath(value, path.slice(0, -1))
  80. const key = path[path.length - 1] as string
  81. if (Array.isArray(parent)) return Number(key) < parent.length
  82. if (typeof parent !== 'object' || parent === null) return false
  83. return key in parent
  84. }
  85. function cloneContainer(container: unknown, key: string): Record<string, unknown> | unknown[] {
  86. if (Array.isArray(container)) return [...container as unknown[]]
  87. if (typeof container === 'object' && container !== null) return { ...container as Record<string, unknown> }
  88. // A missing intermediate materializes as the container the next key needs.
  89. return /^\d+$/.test(key) ? [] : {}
  90. }
  91. /** Clone the container spine down to the leaf's parent, materializing missing intermediates. */
  92. function cloneSpine(root: Record<string, unknown>, path: readonly string[]): {
  93. result: Record<string, unknown>
  94. parent: Record<string, unknown> | unknown[]
  95. leaf: string
  96. } {
  97. const result = { ...root }
  98. let target: Record<string, unknown> | unknown[] = result
  99. for (let i = 0; i < path.length - 1; i++) {
  100. const key = path[i] as string
  101. const child = cloneContainer(
  102. Array.isArray(target) ? target[Number(key)] : (target)[key],
  103. path[i + 1] as string,
  104. )
  105. if (Array.isArray(target)) target[Number(key)] = child
  106. else (target)[key] = child
  107. target = child
  108. }
  109. return { result, parent: target, leaf: path[path.length - 1] as string }
  110. }
  111. /**
  112. * Immutably set a nested value, materializing missing intermediate containers.
  113. * @param root - draft root (never mutated).
  114. * @param path - non-empty key path.
  115. * @param value - value to store at the path.
  116. * @returns the new draft root.
  117. */
  118. export function setPath(root: Record<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
  119. if (path.length === 0) throw new Error('schema-form: setPath needs a non-empty path')
  120. const { result, parent, leaf } = cloneSpine(root, path)
  121. if (Array.isArray(parent)) parent[Number(leaf)] = value
  122. else parent[leaf] = value
  123. return result
  124. }
  125. /**
  126. * Immutably remove a nested key (the per-field reset: the resolved value
  127. * falls back to the composition base and schema defaults). Removing along a
  128. * missing branch returns the root unchanged.
  129. * @param root - draft root (never mutated).
  130. * @param path - non-empty key path.
  131. * @returns the new draft root.
  132. */
  133. export function deletePath(root: Record<string, unknown>, path: readonly string[]): Record<string, unknown> {
  134. if (path.length === 0) throw new Error('schema-form: deletePath needs a non-empty path')
  135. if (!hasPath(root, path)) return root
  136. const { result, parent, leaf } = cloneSpine(root, path)
  137. if (Array.isArray(parent)) parent.splice(Number(leaf), 1)
  138. else Reflect.deleteProperty(parent, leaf)
  139. return result
  140. }