json.ts 3.9 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485
  1. /**
  2. * JSON-serializability validation for session event data.
  3. *
  4. * The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
  5. * `event.data` must round-trip losslessly through JSON so any persistence
  6. * backend can store and reload it byte-identically. This invariant belongs to
  7. * the log itself — `Session.append` enforces it at the source, so a
  8. * non-serializable event never enters `session.events` and the live log can
  9. * never diverge from what a backend can persist. Backends re-use the same
  10. * predicate to validate their own `append(events)` entry point (replay/fork
  11. * paths that do not go through a live `Session`).
  12. *
  13. * @module @deepseek-ai/dsh-session/json
  14. */
  15. /**
  16. * A value that round-trips losslessly through JSON: `null`, a boolean, a finite
  17. * number, a string, an array of such values, or a plain object whose values are
  18. * such values. The static type companion to {@link isJsonValue} (which validates
  19. * the same shape at runtime). Use it to type a payload that must survive
  20. * session-log persistence and replay byte-identically — e.g. a tool's private
  21. * presentation `meta`.
  22. */
  23. export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
  24. /**
  25. * Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
  26. * booleans, strings, plain arrays, and plain objects of such values. Rejects
  27. * `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
  28. * which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
  29. * `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
  30. * convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
  31. * so `[1, , 3]` would not round-trip. Detects circular references (which would
  32. * throw) and reports them as non-serializable rather than propagating the throw.
  33. *
  34. * Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE
  35. * STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and
  36. * non-enumerable properties are NOT examined, because `JSON.stringify` likewise
  37. * drops them — they never reach the durable form, so a non-serializable value
  38. * hiding under a symbol/non-enumerable key cannot make the round-trip lossy.
  39. * Getters are invoked during the check (again as `JSON.stringify` would), so the
  40. * contract is for plain data records, not objects with side-effecting accessors.
  41. * @param value - the candidate event data to test.
  42. * @param seen - objects on the current descent path, for circular-reference
  43. * detection; the recursion threads it — callers omit it.
  44. * @returns true when `value` survives a JSON round-trip losslessly.
  45. */
  46. export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
  47. if (value === null) return true
  48. switch (typeof value) {
  49. case 'boolean':
  50. case 'string':
  51. return true
  52. case 'number':
  53. return Number.isFinite(value)
  54. case 'bigint':
  55. case 'function':
  56. case 'symbol':
  57. case 'undefined':
  58. return false
  59. case 'object':
  60. break // handled below
  61. }
  62. // object
  63. if (seen.has(value)) return false // circular
  64. seen.add(value)
  65. try {
  66. if (Array.isArray(value)) {
  67. // Reject sparse arrays: a hole is skipped by `every`/`forEach` but
  68. // JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
  69. // lossily. Require every index 0..length-1 to be an OWN property.
  70. for (let i = 0; i < value.length; i++) {
  71. if (!Object.prototype.hasOwnProperty.call(value, i)) return false
  72. if (!isJsonValue(value[i], seen)) return false
  73. }
  74. return true
  75. }
  76. // Plain object only (reject Map/Set/Date/class instances).
  77. const proto = Object.getPrototypeOf(value) as unknown
  78. if (proto !== Object.prototype && proto !== null) return false
  79. return Object.values(value).every(v => isJsonValue(v, seen))
  80. } finally {
  81. seen.delete(value)
  82. }
  83. }