format-decoder.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. /**
  2. * Static Session format decoding from backend-owned JSON records to the
  3. * current durable header and event types.
  4. * @module @deepseek-ai/dsh-session-persistence/format-decoder
  5. */
  6. import {
  7. adoptSessionEvent,
  8. KNOWN_SESSION_EVENT_TYPES,
  9. SESSION_FORMAT_VERSION,
  10. Session,
  11. SessionId,
  12. snapshotJsonValue,
  13. } from '@deepseek-ai/dsh-session'
  14. import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  15. import {
  16. unversionedFormatCompatibility,
  17. } from './format-v0-compat.ts'
  18. import type { UnversionedFormatCompatibility } from './format-v0-compat.ts'
  19. import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts'
  20. import type { SessionLocation } from './index.ts'
  21. import { SESSION_FORMAT_MIGRATIONS } from './format-migrations/index.ts'
  22. import type { SessionPersistenceRevision } from './revision.ts'
  23. /** One single-use adjacent-version migration instance. */
  24. interface SessionFormatMigrationInstance {
  25. /**
  26. * Transform and validate the header fields understood by this migration.
  27. * The detached result must carry the constructor's `to` version and preserve
  28. * the source id and cwd.
  29. * @param meta - detached input header for the constructor's `from` version.
  30. * @returns detached header JSON carrying the constructor's `to` version.
  31. */
  32. header(meta: unknown): unknown
  33. /**
  34. * Transform exactly one event into detached lossless JSON while retaining
  35. * its sequence number. Instance fields may accumulate facts from the header
  36. * and earlier events.
  37. * @param event - detached input event in durable sequence order.
  38. * @returns exactly one detached event for the same sequence number.
  39. */
  40. event(event: unknown): unknown
  41. /**
  42. * Validate accumulated state after the complete input stream reaches EOF.
  43. * Header-only reads do not call this method; it cannot emit another event.
  44. */
  45. finish?(): void
  46. }
  47. /** Static identity and constructor for one adjacent-version migration. */
  48. export interface SessionFormatMigration {
  49. /** Input Session format version. */
  50. readonly from: number
  51. /** Output Session format version; must equal `from + 1`. */
  52. readonly to: number
  53. /**
  54. * Create fresh state for one header decode and its optional complete event
  55. * stream. Instances are never shared across sessions or decode attempts.
  56. * @returns a single-use migration instance.
  57. */
  58. new(): SessionFormatMigrationInstance
  59. }
  60. /** Options for one physical event read. */
  61. export interface StoredEventReadOptions {
  62. /** First physical event sequence to request. */
  63. readonly fromSeq?: number
  64. }
  65. /** Completion metadata produced after a physical event stream reaches EOF. */
  66. export interface StoredEventReadCompletion<TornMarker> {
  67. /** Backend-owned token for a recoverable physical tail. */
  68. readonly tornMarker?: TornMarker
  69. }
  70. /** One revision-bound physical event stream. */
  71. export interface StoredEventRead<TornMarker> {
  72. /** Parsed JSON records from the exact source revision. */
  73. readonly events: AsyncIterable<unknown>
  74. /** Resolves only after the stream reaches EOF at the same revision. */
  75. readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
  76. }
  77. /** Repeatable access to one stored header and exact durable revision. */
  78. export interface StoredSessionSource<TornMarker> {
  79. /** Parsed header JSON; format validation belongs to the decoder. */
  80. readonly meta: unknown
  81. /** Exact backend revision every event read must reproduce or reject. */
  82. readonly revision: SessionPersistenceRevision
  83. /** Raw artifact location used to enrich unsupported-format diagnostics. */
  84. readonly location?: SessionLocation
  85. /**
  86. * Open a new event read bound to {@link revision}. A concurrent replacement
  87. * rejects the read instead of returning events from another revision.
  88. * @param options - optional suffix request.
  89. * @returns one independently consumable physical event read.
  90. */
  91. readEvents(options?: StoredEventReadOptions): StoredEventRead<TornMarker>
  92. }
  93. /**
  94. * Build the standard lazy event stream and EOF metadata around one backend
  95. * read, shared by every first-party backend.
  96. * @param load - revision-checked batch loader owned by the backend.
  97. * @param include - whether one loaded event belongs in this physical read.
  98. * @param signal - optional cancellation checked between yielded events.
  99. * @returns an independently consumable event read.
  100. */
  101. export function createStoredEventRead<TornMarker>(
  102. load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>,
  103. include: (event: unknown) => boolean,
  104. signal?: AbortSignal,
  105. ): StoredEventRead<TornMarker> {
  106. const completed = Promise.withResolvers<StoredEventReadCompletion<TornMarker>>()
  107. const events = (async function* (): AsyncIterable<unknown> {
  108. try {
  109. const batch = await load()
  110. for (const event of batch.events) {
  111. signal?.throwIfAborted()
  112. if (include(event)) yield event
  113. }
  114. completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker })
  115. } catch (error: unknown) {
  116. completed.reject(error)
  117. throw error
  118. }
  119. })()
  120. return { events, completed: completed.promise }
  121. }
  122. /** One decoded current-format read bound to an exact stored revision. */
  123. export interface DecodedSession<TornMarker> {
  124. /** Validated current-format header. */
  125. readonly meta: SessionHeader
  126. /** Version observed before any format migration ran. */
  127. readonly sourceVersion: number
  128. /** Exact backend revision represented by this source. */
  129. readonly revision: SessionPersistenceRevision
  130. /** Validated current-format events at or past the requested sequence. */
  131. readonly events: AsyncIterable<SessionEvent>
  132. /**
  133. * Completion metadata from the physical read supplying the events. Settles
  134. * only after the events iterable is fully consumed or fails.
  135. */
  136. readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
  137. }
  138. /**
  139. * The stored log is intact but this runtime cannot faithfully interpret its
  140. * format version or required event vocabulary.
  141. */
  142. export class SessionFormatUnsupportedError extends Error {
  143. /**
  144. * @param message - stable refusal reason, including the raw location when available.
  145. * @param location - backend artifact location when one exists.
  146. */
  147. constructor(message: string, readonly location?: SessionLocation) {
  148. super(message)
  149. this.name = 'SessionFormatUnsupportedError'
  150. }
  151. }
  152. /**
  153. * Direction-aware refusal text for a stored format version this build cannot
  154. * decode.
  155. * @param id - stored session identity.
  156. * @param version - stored format version.
  157. * @returns stable refusal text without a raw-location suffix.
  158. */
  159. export function sessionFormatVersionRefusal(id: string, version: number): string {
  160. return version > SESSION_FORMAT_VERSION
  161. ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`
  162. : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it`
  163. }
  164. function buildMigrationIndex(
  165. migrations: readonly SessionFormatMigration[],
  166. ): ReadonlyMap<number, SessionFormatMigration> {
  167. const byFrom = new Map<number, SessionFormatMigration>()
  168. for (const Migration of migrations) {
  169. if (!Number.isSafeInteger(Migration.from) || Migration.from < 0 || Migration.to !== Migration.from + 1) {
  170. throw new TypeError(`Session format migration must be an adjacent non-negative version, got v${Migration.from} -> v${Migration.to}`)
  171. }
  172. if (byFrom.has(Migration.from)) {
  173. throw new TypeError(`duplicate Session format migration from v${Migration.from}`)
  174. }
  175. if (Migration.to > SESSION_FORMAT_VERSION) {
  176. throw new TypeError(`Session format migration v${Migration.from} -> v${Migration.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`)
  177. }
  178. byFrom.set(Migration.from, Migration)
  179. }
  180. // A missing migration is a per-session concern, decided by planMigrations() at decode
  181. // time: it refuses sessions at or below the gap, while later versions whose
  182. // path to the current version is complete still upgrade. Initialization
  183. // therefore checks only migration legality and duplicates here.
  184. return byFrom
  185. }
  186. const MIGRATION_BY_FROM = buildMigrationIndex(SESSION_FORMAT_MIGRATIONS)
  187. type PlannedMigration = readonly [SessionFormatMigration, SessionFormatMigrationInstance]
  188. interface DecodedHeader {
  189. readonly meta: SessionHeader
  190. readonly sourceVersion: number
  191. readonly migrations: readonly PlannedMigration[]
  192. readonly unversionedCompatibility?: UnversionedFormatCompatibility
  193. }
  194. interface StoredHeaderSource {
  195. readonly meta: unknown
  196. readonly location?: SessionLocation
  197. }
  198. function unsupported(
  199. source: StoredHeaderSource,
  200. reason: string,
  201. ): SessionFormatUnsupportedError {
  202. const location = source.location
  203. return new SessionFormatUnsupportedError(
  204. location === undefined ? reason : `${reason} (raw log: ${location.path})`,
  205. location,
  206. )
  207. }
  208. function readSourceHeader(
  209. source: StoredHeaderSource,
  210. expectedId: SessionId,
  211. ): { meta: Record<string, unknown>; version: number; id: SessionId } {
  212. const snapshot = snapshotJsonValue(source.meta)
  213. const meta = asStoredRecord(snapshot)
  214. if (meta === undefined) throw new Error('stored session header is not a lossless JSON record')
  215. if (!Number.isSafeInteger(meta['version'])) {
  216. throw new Error(`stored session header has invalid format version ${String(meta['version'])}`)
  217. }
  218. const version = meta['version'] as number
  219. if (version > SESSION_FORMAT_VERSION) {
  220. throw unsupported(source, sessionFormatVersionRefusal(String(meta['id']), version))
  221. }
  222. if (typeof meta['id'] !== 'string') throw new Error('stored session header has no string id')
  223. const id = SessionId(meta['id'])
  224. if (id !== expectedId) {
  225. throw new Error(`stored session identity mismatch: requested "${expectedId}", header contains "${id}"`)
  226. }
  227. return { meta, version, id }
  228. }
  229. function planMigrations(
  230. source: StoredHeaderSource,
  231. id: SessionId,
  232. fromVersion: number,
  233. ): readonly SessionFormatMigration[] {
  234. const migrations: SessionFormatMigration[] = []
  235. for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) {
  236. const Migration = MIGRATION_BY_FROM.get(version)
  237. if (Migration === undefined) {
  238. throw unsupported(
  239. source,
  240. `session "${id}" uses log format v${fromVersion}, older than the supported v${SESSION_FORMAT_VERSION}, and this build has no upgrade path to it: missing v${version} -> v${version + 1}`,
  241. )
  242. }
  243. migrations.push(Migration)
  244. }
  245. return migrations
  246. }
  247. function decodeHeader(
  248. source: StoredHeaderSource,
  249. expectedId: SessionId,
  250. ): DecodedHeader {
  251. const stored = readSourceHeader(source, expectedId)
  252. const migrations: PlannedMigration[] = []
  253. let meta: unknown = stored.meta
  254. for (const Migration of planMigrations(source, stored.id, stored.version)) {
  255. let instance: SessionFormatMigrationInstance
  256. try {
  257. instance = new Migration()
  258. meta = snapshotJsonValue(instance.header(meta))
  259. } catch (error: unknown) {
  260. throw new Error(
  261. `session "${stored.id}" header migration v${Migration.from} -> v${Migration.to} failed`,
  262. { cause: error },
  263. )
  264. }
  265. const record = asStoredRecord(meta)
  266. const actual = record?.['version']
  267. if (actual !== Migration.to) {
  268. throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} returned header version ${String(actual)}`)
  269. }
  270. if (record === undefined
  271. || record['id'] !== stored.id
  272. || record['cwd'] !== stored.meta['cwd']) {
  273. throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} changed session storage identity`)
  274. }
  275. migrations.push([Migration, instance])
  276. }
  277. const current = Session.create(stored.id, undefined, meta as SessionHeader).header
  278. const compatibility = unversionedFormatCompatibility(stored.version)
  279. return {
  280. meta: current,
  281. sourceVersion: stored.version,
  282. migrations,
  283. ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }),
  284. }
  285. }
  286. /**
  287. * Decode one stored header without opening its event log. Listing uses the
  288. * same static format path as full Session reads.
  289. * @param meta - parsed backend header JSON.
  290. * @param expectedId - identity selected by the backend or caller.
  291. * @param location - optional raw artifact location for refusal diagnostics.
  292. * @returns the validated current-format header.
  293. */
  294. export function decodeStoredSessionHeader(
  295. meta: unknown,
  296. expectedId: SessionId,
  297. location?: SessionLocation,
  298. ): SessionHeader {
  299. return decodeHeader({ meta, ...location === undefined ? {} : { location } }, expectedId).meta
  300. }
  301. function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent {
  302. const snapshot = snapshotJsonValue(value)
  303. return readStoredEventEnvelope(snapshot, id)
  304. }
  305. function assertCurrentEventSupported<TornMarker>(
  306. source: StoredSessionSource<TornMarker>,
  307. meta: SessionHeader,
  308. event: SessionEvent,
  309. ): void {
  310. assertNoRetiredSessionEvent(event, meta.id)
  311. if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return
  312. throw unsupported(
  313. source,
  314. `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`,
  315. )
  316. }
  317. async function* decodeCurrentEvents<TornMarker>(
  318. source: StoredSessionSource<TornMarker>,
  319. meta: SessionHeader,
  320. events: AsyncIterable<unknown>,
  321. expectedSeq: number,
  322. ): AsyncIterable<SessionEvent> {
  323. let nextSeq = expectedSeq
  324. for await (const raw of events) {
  325. const event = assertCurrentEnvelope(raw, meta.id)
  326. if (event.seq !== nextSeq) {
  327. throw new Error(`session "${meta.id}" event seq mismatch: expected ${nextSeq}, got ${event.seq}`)
  328. }
  329. const current = adoptSessionEvent(event)
  330. assertCurrentEventSupported(source, meta, current)
  331. nextSeq += 1
  332. yield current
  333. }
  334. }
  335. async function* transformEvents(
  336. events: AsyncIterable<unknown>,
  337. migrations: readonly PlannedMigration[],
  338. id: SessionId,
  339. ): AsyncIterable<unknown> {
  340. for await (let value of events) {
  341. for (const [Migration, instance] of migrations) {
  342. const sourceSeq = asStoredRecord(value)?.['seq']
  343. let output: unknown
  344. try {
  345. output = snapshotJsonValue(instance.event(value))
  346. if (output === undefined) {
  347. throw new Error('migration returned an event that is not losslessly JSON-serializable')
  348. }
  349. } catch (error: unknown) {
  350. throw new Error(
  351. `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`,
  352. { cause: error },
  353. )
  354. }
  355. const targetSeq = asStoredRecord(output)?.['seq']
  356. if (targetSeq !== sourceSeq) {
  357. throw new Error(`session "${id}" event migration v${Migration.from} -> v${Migration.to} changed event seq ${String(sourceSeq)} to ${String(targetSeq)}`)
  358. }
  359. value = output
  360. }
  361. yield value
  362. }
  363. for (const [Migration, instance] of migrations) {
  364. try {
  365. instance.finish?.()
  366. } catch (error: unknown) {
  367. throw new Error(
  368. `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at EOF`,
  369. { cause: error },
  370. )
  371. }
  372. }
  373. }
  374. async function* snapshotStoredEvents(
  375. events: AsyncIterable<unknown>,
  376. id: SessionId,
  377. ): AsyncIterable<unknown> {
  378. for await (const event of events) {
  379. const snapshot = snapshotJsonValue(event)
  380. if (snapshot === undefined) {
  381. throw new Error(`session "${id}" contains an event that is not losslessly JSON-serializable`)
  382. }
  383. yield snapshot
  384. }
  385. }
  386. function decodedRead<TornMarker>(
  387. source: StoredSessionSource<TornMarker>,
  388. header: DecodedHeader,
  389. requestedFromSeq: number,
  390. ): {
  391. readonly events: AsyncIterable<SessionEvent>
  392. readonly completed: Promise<StoredEventReadCompletion<TornMarker>>
  393. } {
  394. const completion = Promise.withResolvers<StoredEventReadCompletion<TornMarker>>()
  395. const migrating = header.migrations.length > 0
  396. const compatibility = header.unversionedCompatibility
  397. let physical: StoredEventRead<TornMarker> | undefined
  398. const events = (async function* (): AsyncIterable<SessionEvent> {
  399. try {
  400. let physicalFromSeq = migrating ? 0 : requestedFromSeq
  401. physical = source.readEvents({ fromSeq: physicalFromSeq })
  402. void physical.completed.catch(() => undefined)
  403. let raw: AsyncIterable<unknown> = physical.events
  404. let physicalCompletion: StoredEventReadCompletion<TornMarker> | undefined
  405. if (!migrating && requestedFromSeq > 0 && compatibility !== undefined) {
  406. const suffix: unknown[] = []
  407. for await (const value of raw) suffix.push(value)
  408. physicalCompletion = await physical.completed
  409. if (suffix.some(value => compatibility.requiresPrefix(value))) {
  410. physicalFromSeq = 0
  411. physical = source.readEvents({ fromSeq: 0 })
  412. void physical.completed.catch(() => undefined)
  413. raw = physical.events
  414. physicalCompletion = undefined
  415. } else {
  416. raw = (async function* () {
  417. for (const value of suffix) yield await Promise.resolve(value)
  418. })()
  419. }
  420. }
  421. const storedEvents = snapshotStoredEvents(raw, header.meta.id)
  422. const canonicalEvents = compatibility === undefined
  423. ? storedEvents
  424. : compatibility.canonicalizeEvents(storedEvents, header.meta.id)
  425. const transformed = transformEvents(
  426. canonicalEvents,
  427. header.migrations,
  428. header.meta.id,
  429. )
  430. const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq)
  431. for await (const event of current) {
  432. if (event.seq >= requestedFromSeq) yield event
  433. }
  434. completion.resolve(physicalCompletion ?? await physical.completed)
  435. } catch (error: unknown) {
  436. completion.reject(error)
  437. throw error
  438. }
  439. })()
  440. return { events, completed: completion.promise }
  441. }
  442. /**
  443. * Decode one backend source through the static adjacent-version migrations and
  444. * the current header/event validators. Format selection is complete before any
  445. * consumer-specific recovery runs.
  446. * @param source - backend-owned header, revision, and event reader factory.
  447. * @param expectedId - session identity selected by the caller.
  448. * @param fromSeq - first current-format event sequence to return.
  449. * @returns one decoded current-format stream bound to the stored revision.
  450. */
  451. export function decodeStoredSession<TornMarker>(
  452. source: StoredSessionSource<TornMarker>,
  453. expectedId: SessionId,
  454. fromSeq = 0,
  455. ): DecodedSession<TornMarker> {
  456. if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) {
  457. throw new TypeError(`stored event fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`)
  458. }
  459. const header = decodeHeader(source, expectedId)
  460. const read = decodedRead(source, header, fromSeq)
  461. return {
  462. meta: header.meta,
  463. sourceVersion: header.sourceVersion,
  464. revision: source.revision,
  465. events: read.events,
  466. completed: read.completed,
  467. }
  468. }