compression.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. /**
  2. * Fixed physical-record compression for SQLite. Schema-owned functions
  3. * encode logical events and decode tagged rows before persistence consumers
  4. * observe them.
  5. * @module @deepseek-ai/dsh-session-persistence-sqlite/compression
  6. */
  7. import { readFileSync } from 'node:fs'
  8. import { TextDecoder } from 'node:util'
  9. import { constants, zstdCompressSync, zstdDecompressSync } from 'node:zlib'
  10. import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
  11. import {
  12. decodeSerializedChunkRow,
  13. type ChunkRow,
  14. MAX_PACKED_DATA_BYTES,
  15. type StorageRecord,
  16. } from './codec.ts'
  17. import type { EventRow } from './schema.ts'
  18. /** One physical row ready for SQLite parameter binding. */
  19. export interface BoundRecord {
  20. readonly seq: number
  21. readonly type: string
  22. readonly time: number
  23. readonly data: string | Uint8Array
  24. readonly sourceEventSeqs: Uint8Array | null
  25. readonly surfaceOp: string | null
  26. readonly isPacked: 0 | 1
  27. }
  28. const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
  29. const ZSTD_COMPRESSION_LEVEL = 3
  30. const DELTA_TAG = 0
  31. const RUN_TAG = 1
  32. const MAX_SAFE_INTEGER = BigInt(Number.MAX_SAFE_INTEGER)
  33. const MAX_ZIGZAG_INTEGER = MAX_SAFE_INTEGER * 2n
  34. /**
  35. * Schema-19 raw-content zstd dictionary for independently decodable data rows.
  36. * Its exact bytes are part of the physical format; changing the resource
  37. * requires a schema-version bump.
  38. */
  39. const ZSTD_DICTIONARY = readFileSync(new URL('../resources/zstd-dictionary.bin', import.meta.url))
  40. /** Compress options shared by every data-column frame. */
  41. const DATA_ZSTD_OPTIONS = {
  42. dictionary: ZSTD_DICTIONARY,
  43. params: { [constants.ZSTD_c_compressionLevel]: ZSTD_COMPRESSION_LEVEL },
  44. } as const
  45. const CHUNK_TAGS = ['text-chunks', 'reasoning-chunks', 'tool-call-chunks'] as const
  46. type ChunkTag = typeof CHUNK_TAGS[number]
  47. function isChunkTag(value: string): value is ChunkTag {
  48. return (CHUNK_TAGS as readonly string[]).includes(value)
  49. }
  50. /**
  51. * Decode one physical SQLite row into its complete logical event span.
  52. * @param row - detached SQLite event row.
  53. * @returns every logical event represented by the row.
  54. */
  55. export function decodeRow(row: EventRow): SessionEvent[] {
  56. if (row.is_packed === 0) return [decodeScalarRow(row)]
  57. if (!isChunkTag(row.type)) {
  58. throw new Error(`malformed ${row.type} storage row: packed discriminator requires a chunk tag`)
  59. }
  60. if (row.source_event_seqs !== null || row.surface_op !== null) {
  61. throw new Error(`malformed ${row.type} storage row: packed surface fields must be null`)
  62. }
  63. return decodeSerializedChunkRow(
  64. row.type,
  65. row.seq,
  66. row.time,
  67. decodeData(row.data, MAX_PACKED_DATA_BYTES),
  68. )
  69. }
  70. /**
  71. * Convert a storage record to SQLite column values.
  72. * @param record - scalar event or packed chunk record.
  73. * @returns column values for one physical insert.
  74. */
  75. export function bindRecord(record: StorageRecord): BoundRecord {
  76. if (isChunkRow(record)) {
  77. return {
  78. seq: record.seq0,
  79. type: record.type,
  80. time: record.time0,
  81. data: encodeData(JSON.stringify(record.data)),
  82. sourceEventSeqs: null,
  83. surfaceOp: null,
  84. isPacked: 1,
  85. }
  86. }
  87. const event = record
  88. const surface = event as SessionEvent<SurfaceEventType>
  89. return {
  90. seq: event.seq,
  91. type: event.type,
  92. time: event.time,
  93. data: encodeData(JSON.stringify(event.data)),
  94. sourceEventSeqs: surface.sourceEventSeqs === undefined
  95. ? null
  96. : encodeSourceEventSeqs(surface.sourceEventSeqs),
  97. surfaceOp: surface.surfaceOp === undefined ? null : JSON.stringify(surface.surfaceOp),
  98. isPacked: 0,
  99. }
  100. }
  101. function encodeData(serialized: string): string | Uint8Array {
  102. const bytes = Buffer.from(serialized)
  103. const compressed = zstdCompressSync(bytes, DATA_ZSTD_OPTIONS)
  104. return compressed.length < bytes.length ? compressed : serialized
  105. }
  106. function decodeData(value: string | Uint8Array, maxOutputLength?: number): string {
  107. if (typeof value === 'string') return value
  108. const decoded = maxOutputLength === undefined
  109. ? zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY })
  110. : zstdDecompressSync(value, { dictionary: ZSTD_DICTIONARY, maxOutputLength })
  111. return UTF8_DECODER.decode(decoded)
  112. }
  113. function encodeSourceEventSeqs(values: readonly number[]): Uint8Array {
  114. if (values.length === 0) return new Uint8Array()
  115. const deltas = [DELTA_TAG]
  116. let previous = 0n
  117. for (let index = 0; index < values.length; index += 1) {
  118. const value = values[index] as number
  119. if (!Number.isSafeInteger(value) || value < 0) {
  120. throw new TypeError('sourceEventSeqs must contain non-negative safe integers')
  121. }
  122. const current = BigInt(value)
  123. const encoded = index === 0
  124. ? current
  125. : current >= previous
  126. ? (current - previous) * 2n
  127. : ((previous - current) * 2n) - 1n
  128. appendVarint(deltas, encoded)
  129. previous = current
  130. }
  131. if (!isStrictlyIncreasing(values)) return Uint8Array.from(deltas)
  132. const runs = [RUN_TAG]
  133. let start = values[0] as number
  134. let end = start
  135. for (let index = 1; index < values.length; index += 1) {
  136. const value = values[index] as number
  137. if (value === end + 1) {
  138. end = value
  139. continue
  140. }
  141. appendVarint(runs, BigInt(start))
  142. appendVarint(runs, BigInt(end - start + 1))
  143. start = value
  144. end = start
  145. }
  146. appendVarint(runs, BigInt(start))
  147. appendVarint(runs, BigInt(end - start + 1))
  148. return Uint8Array.from(runs.length < deltas.length ? runs : deltas)
  149. }
  150. function isStrictlyIncreasing(values: readonly number[]): boolean {
  151. return values.every((value, index) => index === 0 || value > (values[index - 1] as number))
  152. }
  153. function appendVarint(bytes: number[], value: bigint): void {
  154. let remaining = value
  155. while (remaining >= 0x80n) {
  156. bytes.push(Number(remaining & 0x7fn) | 0x80)
  157. remaining >>= 7n
  158. }
  159. bytes.push(Number(remaining))
  160. }
  161. function decodeSourceEventSeqs(bytes: Uint8Array, maxEntries: number): number[] {
  162. if (bytes.length === 0) return []
  163. if (bytes.length === 1) {
  164. throw new Error('malformed source_event_seqs storage value: truncated tagged payload')
  165. }
  166. switch (bytes[0]) {
  167. case DELTA_TAG: return decodeDeltaVarints(bytes, 1)
  168. case RUN_TAG: return decodeRunVarints(bytes, 1, maxEntries)
  169. default: throw new Error('malformed source_event_seqs storage value: unknown encoding tag')
  170. }
  171. }
  172. function decodeDeltaVarints(bytes: Uint8Array, offset: number): number[] {
  173. const values: number[] = []
  174. let previous = 0n
  175. while (offset < bytes.length) {
  176. const first = values.length === 0
  177. const decoded = readVarint(bytes, offset, first ? MAX_SAFE_INTEGER : MAX_ZIGZAG_INTEGER)
  178. offset = decoded.offset
  179. const delta = first
  180. ? decoded.value
  181. : (decoded.value & 1n) === 0n
  182. ? decoded.value / 2n
  183. : -((decoded.value + 1n) / 2n)
  184. const value = first ? delta : previous + delta
  185. if (value < 0n || value > MAX_SAFE_INTEGER) {
  186. throw new Error('malformed source_event_seqs storage value: decoded seq is out of range')
  187. }
  188. values.push(Number(value))
  189. previous = value
  190. }
  191. return values
  192. }
  193. function decodeRunVarints(bytes: Uint8Array, offset: number, maxEntries: number): number[] {
  194. const values: number[] = []
  195. let previousEnd = -1
  196. while (offset < bytes.length) {
  197. const start = readVarint(bytes, offset, MAX_SAFE_INTEGER)
  198. const count = readVarint(bytes, start.offset, MAX_SAFE_INTEGER)
  199. offset = count.offset
  200. const first = Number(start.value)
  201. const length = Number(count.value)
  202. if (length < 1) {
  203. throw new Error('malformed source_event_seqs storage value: run count must be positive')
  204. }
  205. if (first <= previousEnd || !Number.isSafeInteger(first + length - 1)) {
  206. throw new Error('malformed source_event_seqs storage value: runs must ascend within safe integers')
  207. }
  208. if (length > maxEntries - values.length) {
  209. throw new Error('malformed source_event_seqs storage value: run exceeds its event sequence')
  210. }
  211. for (let index = 0; index < length; index += 1) values.push(first + index)
  212. previousEnd = first + length - 1
  213. }
  214. return values
  215. }
  216. function readVarint(
  217. bytes: Uint8Array,
  218. offset: number,
  219. limit: bigint,
  220. ): { readonly value: bigint; readonly offset: number } {
  221. let value = 0n
  222. let shift = 0n
  223. while (offset < bytes.length) {
  224. const byte = bytes[offset] as number
  225. offset += 1
  226. value |= BigInt(byte & 0x7f) << shift
  227. if ((byte & 0x80) === 0) {
  228. if (shift > 0n && (byte & 0x7f) === 0) {
  229. throw new Error('malformed source_event_seqs storage value: non-canonical varint')
  230. }
  231. if (value > limit) {
  232. throw new Error('malformed source_event_seqs storage value: varint is out of range')
  233. }
  234. return { value, offset }
  235. }
  236. shift += 7n
  237. if (shift > 56n) {
  238. throw new Error('malformed source_event_seqs storage value: varint is out of range')
  239. }
  240. }
  241. throw new Error('malformed source_event_seqs storage value: truncated varint')
  242. }
  243. function isChunkRow(record: StorageRecord): record is ChunkRow {
  244. return isChunkTag(record.type) && 'seq0' in record && !('seq' in record)
  245. }
  246. function decodeScalarRow(row: EventRow): SessionEvent {
  247. const surfaceFields = {
  248. ...row.source_event_seqs === null
  249. ? {}
  250. : { sourceEventSeqs: decodeSourceEventSeqs(row.source_event_seqs, row.seq) },
  251. ...row.surface_op === null
  252. ? {}
  253. : { surfaceOp: JSON.parse(row.surface_op) as SessionEvent<SurfaceEventType>['surfaceOp'] },
  254. }
  255. return {
  256. type: row.type as SessionEvent['type'],
  257. seq: row.seq,
  258. time: row.time,
  259. data: JSON.parse(decodeData(row.data)) as SessionEvent['data'],
  260. ...surfaceFields,
  261. } as SessionEvent
  262. }
  263. /**
  264. * Validate and flatten physical rows into their logical prefix. A malformed
  265. * row or logical gap is committed corruption when a later valid turn end
  266. * exists; otherwise it starts a removable physical tail.
  267. * @param rows - physical rows ordered by their first logical sequence.
  268. * @param base - logical sequence expected from the first selected row.
  269. * @returns the contiguous logical prefix and optional physical deletion base.
  270. */
  271. export function scanRows(
  272. rows: readonly EventRow[],
  273. base = 0,
  274. ): { preserved: SessionEvent[]; tornFrom?: number } {
  275. let lastTurnEndRow = -1
  276. for (let index = rows.length - 1; index >= 0; index -= 1) {
  277. try {
  278. if (decodeRow(rows[index] as EventRow).some(event => event.type === 'turn/end')) {
  279. lastTurnEndRow = index
  280. break
  281. }
  282. } catch {
  283. // A malformed row cannot prove that an earlier physical prefix committed.
  284. }
  285. }
  286. const preserved: SessionEvent[] = []
  287. let expected = base
  288. for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
  289. const physical = rows[rowIndex] as EventRow
  290. let logicalEvents: SessionEvent[] | undefined
  291. try {
  292. logicalEvents = decodeRow(physical)
  293. } catch {
  294. // The committed-prefix rule below owns whether this invalid row is fatal or repairable.
  295. }
  296. if (logicalEvents === undefined) {
  297. if (rowIndex <= lastTurnEndRow) {
  298. throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
  299. }
  300. return { preserved, tornFrom: physical.seq }
  301. }
  302. let contiguous = true
  303. for (const event of logicalEvents) {
  304. if (event.seq !== expected) {
  305. contiguous = false
  306. break
  307. }
  308. expected += 1
  309. }
  310. if (!contiguous) {
  311. if (rowIndex <= lastTurnEndRow) {
  312. throw new Error(`corrupt session log: invalid committed physical row at seq ${physical.seq}`)
  313. }
  314. return { preserved, tornFrom: physical.seq }
  315. }
  316. preserved.push(...logicalEvents)
  317. }
  318. return { preserved }
  319. }