format.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * On-disk JSON unit format: the file is always the current net state, kept
  3. * human-readable (pretty-printed, stable key order from insertion) — that
  4. * legibility is this backend's reason to exist. `single`-layout units are
  5. * one document with a unit header; `per-record`-layout units are a directory
  6. * with one version-stamped document per record (`<table>/<key>.json`) plus a
  7. * `global.json` for the global slot, so a write rewrites one record instead
  8. * of the whole unit.
  9. * @module @deepseek-ai/dsh-storage-json/src/format
  10. */
  11. import { StorageError } from '@deepseek-ai/dsh-storage'
  12. import type { KvUnitDescriptor } from '@deepseek-ai/dsh-storage'
  13. /** In-memory authoritative state of one unit; the file is its projection. `global` is `null` until first written. */
  14. export interface UnitState {
  15. version: number
  16. global: unknown
  17. tables: Map<string, Map<string, unknown>>
  18. }
  19. /**
  20. * Serialize a unit state to file content.
  21. * @param name - Unit name, stamped into the header.
  22. * @param state - Authoritative in-memory state.
  23. * @returns pretty-printed JSON document with a trailing newline.
  24. */
  25. export function serialize(name: string, state: UnitState): string {
  26. const tables: Record<string, Record<string, unknown>> = {}
  27. for (const [table, records] of state.tables) {
  28. tables[table] = Object.fromEntries(records)
  29. }
  30. const document = {
  31. unit: { name, version: state.version },
  32. global: state.global,
  33. tables,
  34. }
  35. return `${JSON.stringify(document, null, 2)}\n`
  36. }
  37. /**
  38. * Parse file content into unit state, validating shape and version.
  39. * @param text - Raw file content.
  40. * @param descriptor - Expected identity; version mismatch rejects.
  41. * @returns the parsed state.
  42. */
  43. export function parse(text: string, descriptor: KvUnitDescriptor): UnitState {
  44. let document: unknown
  45. try {
  46. document = JSON.parse(text)
  47. } catch (error) {
  48. throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not valid JSON`, { cause: error })
  49. }
  50. if (typeof document !== 'object' || document === null) {
  51. throw new StorageError('malformed-medium', `unit '${descriptor.name}': file is not a JSON object`)
  52. }
  53. const { unit, global: globalValue, tables } = document as Record<string, unknown>
  54. if (
  55. typeof unit !== 'object' || unit === null ||
  56. (unit as Record<string, unknown>)['name'] !== descriptor.name ||
  57. typeof (unit as Record<string, unknown>)['version'] !== 'number'
  58. ) {
  59. throw new StorageError('malformed-medium', `unit '${descriptor.name}': missing or foreign unit header`)
  60. }
  61. const version = (unit as Record<string, unknown>)['version'] as number
  62. if (version !== descriptor.version) {
  63. throw new StorageError(
  64. 'version-mismatch',
  65. `unit '${descriptor.name}': stored version ${version} != expected ${descriptor.version}`,
  66. )
  67. }
  68. if (typeof tables !== 'object' || tables === null) {
  69. throw new StorageError('malformed-medium', `unit '${descriptor.name}': tables is not an object`)
  70. }
  71. const state: UnitState = { version, global: globalValue ?? null, tables: new Map() }
  72. for (const table of descriptor.tables) {
  73. const records = (tables as Record<string, unknown>)[table]
  74. if (records === undefined) {
  75. state.tables.set(table, new Map())
  76. continue
  77. }
  78. if (typeof records !== 'object' || records === null || Array.isArray(records)) {
  79. throw new StorageError('malformed-medium', `unit '${descriptor.name}': table '${table}' is not an object`)
  80. }
  81. state.tables.set(table, new Map(Object.entries(records as Record<string, unknown>)))
  82. }
  83. return state
  84. }
  85. /**
  86. * Serialize one per-record document: the unit's version stamp plus the
  87. * record value, pretty-printed like the whole-unit document.
  88. * @param version - Unit format version, stamped into the header.
  89. * @param value - The record value (or the global singleton value).
  90. * @returns pretty-printed JSON document with a trailing newline.
  91. */
  92. export function serializeRecord(version: number, value: unknown): string {
  93. return `${JSON.stringify({ version, record: value }, null, 2)}\n`
  94. }
  95. /**
  96. * Parse one per-record document, validating its version stamp. A document
  97. * that is malformed or stamped with an unaccepted version is FOREIGN and
  98. * reads as absent — the per-record contract: one bad or stale record file
  99. * must not brick the whole unit, and an unaccepted version stamp discards the
  100. * record instead of migrating it (the whole-unit format rejects instead,
  101. * because there is exactly one document).
  102. * @param text - Raw per-record document content.
  103. * @param versions - Accepted unit versions (the current one plus the
  104. * descriptor's compatibleVersions); any other stamp discards the
  105. * document.
  106. * @returns the record value, or `undefined` for a foreign document.
  107. */
  108. export function parseRecord(text: string, versions: readonly number[]): unknown {
  109. let document: unknown
  110. try {
  111. document = JSON.parse(text)
  112. } catch {
  113. return undefined
  114. }
  115. if (typeof document !== 'object' || document === null) return undefined
  116. const { version: stamped, record } = document as Record<string, unknown>
  117. if (typeof stamped !== 'number' || !versions.includes(stamped)) return undefined
  118. return record
  119. }