/** * Static Session format decoding from backend-owned JSON records to the * current durable header and event types. * @module @deepseek-ai/dsh-session-persistence/format-decoder */ import { adoptSessionEvent, KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, Session, SessionId, snapshotJsonValue, } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { unversionedFormatCompatibility, } from './format-v0-compat.ts' import type { UnversionedFormatCompatibility } from './format-v0-compat.ts' import { asStoredRecord, assertNoRetiredSessionEvent, readStoredEventEnvelope } from './format-json.ts' import type { SessionLocation } from './index.ts' import { SESSION_FORMAT_MIGRATIONS } from './format-migrations/index.ts' import type { SessionPersistenceRevision } from './revision.ts' /** One single-use adjacent-version migration instance. */ interface SessionFormatMigrationInstance { /** * Transform and validate the header fields understood by this migration. * The detached result must carry the constructor's `to` version and preserve * the source id and cwd. * @param meta - detached input header for the constructor's `from` version. * @returns detached header JSON carrying the constructor's `to` version. */ header(meta: unknown): unknown /** * Transform exactly one event into detached lossless JSON while retaining * its sequence number. Instance fields may accumulate facts from the header * and earlier events. * @param event - detached input event in durable sequence order. * @returns exactly one detached event for the same sequence number. */ event(event: unknown): unknown /** * Validate accumulated state after the complete input stream reaches EOF. * Header-only reads do not call this method; it cannot emit another event. */ finish?(): void } /** Static identity and constructor for one adjacent-version migration. */ export interface SessionFormatMigration { /** Input Session format version. */ readonly from: number /** Output Session format version; must equal `from + 1`. */ readonly to: number /** * Create fresh state for one header decode and its optional complete event * stream. Instances are never shared across sessions or decode attempts. * @returns a single-use migration instance. */ new(): SessionFormatMigrationInstance } /** Options for one physical event read. */ export interface StoredEventReadOptions { /** First physical event sequence to request. */ readonly fromSeq?: number } /** Completion metadata produced after a physical event stream reaches EOF. */ export interface StoredEventReadCompletion { /** Backend-owned token for a recoverable physical tail. */ readonly tornMarker?: TornMarker } /** One revision-bound physical event stream. */ export interface StoredEventRead { /** Parsed JSON records from the exact source revision. */ readonly events: AsyncIterable /** Resolves only after the stream reaches EOF at the same revision. */ readonly completed: Promise> } /** Repeatable access to one stored header and exact durable revision. */ export interface StoredSessionSource { /** Parsed header JSON; format validation belongs to the decoder. */ readonly meta: unknown /** Exact backend revision every event read must reproduce or reject. */ readonly revision: SessionPersistenceRevision /** Raw artifact location used to enrich unsupported-format diagnostics. */ readonly location?: SessionLocation /** * Open a new event read bound to {@link revision}. A concurrent replacement * rejects the read instead of returning events from another revision. * @param options - optional suffix request. * @returns one independently consumable physical event read. */ readEvents(options?: StoredEventReadOptions): StoredEventRead } /** * Build the standard lazy event stream and EOF metadata around one backend * read, shared by every first-party backend. * @param load - revision-checked batch loader owned by the backend. * @param include - whether one loaded event belongs in this physical read. * @param signal - optional cancellation checked between yielded events. * @returns an independently consumable event read. */ export function createStoredEventRead( load: () => Promise<{ readonly events: readonly unknown[]; readonly tornMarker?: TornMarker }>, include: (event: unknown) => boolean, signal?: AbortSignal, ): StoredEventRead { const completed = Promise.withResolvers>() const events = (async function* (): AsyncIterable { try { const batch = await load() for (const event of batch.events) { signal?.throwIfAborted() if (include(event)) yield event } completed.resolve(batch.tornMarker === undefined ? {} : { tornMarker: batch.tornMarker }) } catch (error: unknown) { completed.reject(error) throw error } })() return { events, completed: completed.promise } } /** One decoded current-format read bound to an exact stored revision. */ export interface DecodedSession { /** Validated current-format header. */ readonly meta: SessionHeader /** Version observed before any format migration ran. */ readonly sourceVersion: number /** Exact backend revision represented by this source. */ readonly revision: SessionPersistenceRevision /** Validated current-format events at or past the requested sequence. */ readonly events: AsyncIterable /** * Completion metadata from the physical read supplying the events. Settles * only after the events iterable is fully consumed or fails. */ readonly completed: Promise> } /** * The stored log is intact but this runtime cannot faithfully interpret its * format version or required event vocabulary. */ export class SessionFormatUnsupportedError extends Error { /** * @param message - stable refusal reason, including the raw location when available. * @param location - backend artifact location when one exists. */ constructor(message: string, readonly location?: SessionLocation) { super(message) this.name = 'SessionFormatUnsupportedError' } } /** * Direction-aware refusal text for a stored format version this build cannot * decode. * @param id - stored session identity. * @param version - stored format version. * @returns stable refusal text without a raw-location suffix. */ export function sessionFormatVersionRefusal(id: string, version: number): string { return version > SESSION_FORMAT_VERSION ? `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` : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` } function buildMigrationIndex( migrations: readonly SessionFormatMigration[], ): ReadonlyMap { const byFrom = new Map() for (const Migration of migrations) { if (!Number.isSafeInteger(Migration.from) || Migration.from < 0 || Migration.to !== Migration.from + 1) { throw new TypeError(`Session format migration must be an adjacent non-negative version, got v${Migration.from} -> v${Migration.to}`) } if (byFrom.has(Migration.from)) { throw new TypeError(`duplicate Session format migration from v${Migration.from}`) } if (Migration.to > SESSION_FORMAT_VERSION) { throw new TypeError(`Session format migration v${Migration.from} -> v${Migration.to} targets a version newer than this build's v${SESSION_FORMAT_VERSION}`) } byFrom.set(Migration.from, Migration) } // A missing migration is a per-session concern, decided by planMigrations() at decode // time: it refuses sessions at or below the gap, while later versions whose // path to the current version is complete still upgrade. Initialization // therefore checks only migration legality and duplicates here. return byFrom } const MIGRATION_BY_FROM = buildMigrationIndex(SESSION_FORMAT_MIGRATIONS) type PlannedMigration = readonly [SessionFormatMigration, SessionFormatMigrationInstance] interface DecodedHeader { readonly meta: SessionHeader readonly sourceVersion: number readonly migrations: readonly PlannedMigration[] readonly unversionedCompatibility?: UnversionedFormatCompatibility } interface StoredHeaderSource { readonly meta: unknown readonly location?: SessionLocation } function unsupported( source: StoredHeaderSource, reason: string, ): SessionFormatUnsupportedError { const location = source.location return new SessionFormatUnsupportedError( location === undefined ? reason : `${reason} (raw log: ${location.path})`, location, ) } function readSourceHeader( source: StoredHeaderSource, expectedId: SessionId, ): { meta: Record; version: number; id: SessionId } { const snapshot = snapshotJsonValue(source.meta) const meta = asStoredRecord(snapshot) if (meta === undefined) throw new Error('stored session header is not a lossless JSON record') if (!Number.isSafeInteger(meta['version'])) { throw new Error(`stored session header has invalid format version ${String(meta['version'])}`) } const version = meta['version'] as number if (version > SESSION_FORMAT_VERSION) { throw unsupported(source, sessionFormatVersionRefusal(String(meta['id']), version)) } if (typeof meta['id'] !== 'string') throw new Error('stored session header has no string id') const id = SessionId(meta['id']) if (id !== expectedId) { throw new Error(`stored session identity mismatch: requested "${expectedId}", header contains "${id}"`) } return { meta, version, id } } function planMigrations( source: StoredHeaderSource, id: SessionId, fromVersion: number, ): readonly SessionFormatMigration[] { const migrations: SessionFormatMigration[] = [] for (let version = fromVersion; version < SESSION_FORMAT_VERSION; version++) { const Migration = MIGRATION_BY_FROM.get(version) if (Migration === undefined) { throw unsupported( source, `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}`, ) } migrations.push(Migration) } return migrations } function decodeHeader( source: StoredHeaderSource, expectedId: SessionId, ): DecodedHeader { const stored = readSourceHeader(source, expectedId) const migrations: PlannedMigration[] = [] let meta: unknown = stored.meta for (const Migration of planMigrations(source, stored.id, stored.version)) { let instance: SessionFormatMigrationInstance try { instance = new Migration() meta = snapshotJsonValue(instance.header(meta)) } catch (error: unknown) { throw new Error( `session "${stored.id}" header migration v${Migration.from} -> v${Migration.to} failed`, { cause: error }, ) } const record = asStoredRecord(meta) const actual = record?.['version'] if (actual !== Migration.to) { throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} returned header version ${String(actual)}`) } if (record === undefined || record['id'] !== stored.id || record['cwd'] !== stored.meta['cwd']) { throw new Error(`Session format migration v${Migration.from} -> v${Migration.to} changed session storage identity`) } migrations.push([Migration, instance]) } const current = Session.create(stored.id, undefined, meta as SessionHeader).header const compatibility = unversionedFormatCompatibility(stored.version) return { meta: current, sourceVersion: stored.version, migrations, ...(compatibility === undefined ? {} : { unversionedCompatibility: compatibility }), } } /** * Decode one stored header without opening its event log. Listing uses the * same static format path as full Session reads. * @param meta - parsed backend header JSON. * @param expectedId - identity selected by the backend or caller. * @param location - optional raw artifact location for refusal diagnostics. * @returns the validated current-format header. */ export function decodeStoredSessionHeader( meta: unknown, expectedId: SessionId, location?: SessionLocation, ): SessionHeader { return decodeHeader({ meta, ...location === undefined ? {} : { location } }, expectedId).meta } function assertCurrentEnvelope(value: unknown, id: SessionId): SessionEvent { const snapshot = snapshotJsonValue(value) return readStoredEventEnvelope(snapshot, id) } function assertCurrentEventSupported( source: StoredSessionSource, meta: SessionHeader, event: SessionEvent, ): void { assertNoRetiredSessionEvent(event, meta.id) if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) return throw unsupported( source, `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`, ) } async function* decodeCurrentEvents( source: StoredSessionSource, meta: SessionHeader, events: AsyncIterable, expectedSeq: number, ): AsyncIterable { let nextSeq = expectedSeq for await (const raw of events) { const event = assertCurrentEnvelope(raw, meta.id) if (event.seq !== nextSeq) { throw new Error(`session "${meta.id}" event seq mismatch: expected ${nextSeq}, got ${event.seq}`) } const current = adoptSessionEvent(event) assertCurrentEventSupported(source, meta, current) nextSeq += 1 yield current } } async function* transformEvents( events: AsyncIterable, migrations: readonly PlannedMigration[], id: SessionId, ): AsyncIterable { for await (let value of events) { for (const [Migration, instance] of migrations) { const sourceSeq = asStoredRecord(value)?.['seq'] let output: unknown try { output = snapshotJsonValue(instance.event(value)) if (output === undefined) { throw new Error('migration returned an event that is not losslessly JSON-serializable') } } catch (error: unknown) { throw new Error( `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at seq ${String(sourceSeq)}`, { cause: error }, ) } const targetSeq = asStoredRecord(output)?.['seq'] if (targetSeq !== sourceSeq) { throw new Error(`session "${id}" event migration v${Migration.from} -> v${Migration.to} changed event seq ${String(sourceSeq)} to ${String(targetSeq)}`) } value = output } yield value } for (const [Migration, instance] of migrations) { try { instance.finish?.() } catch (error: unknown) { throw new Error( `session "${id}" event migration v${Migration.from} -> v${Migration.to} failed at EOF`, { cause: error }, ) } } } async function* snapshotStoredEvents( events: AsyncIterable, id: SessionId, ): AsyncIterable { for await (const event of events) { const snapshot = snapshotJsonValue(event) if (snapshot === undefined) { throw new Error(`session "${id}" contains an event that is not losslessly JSON-serializable`) } yield snapshot } } function decodedRead( source: StoredSessionSource, header: DecodedHeader, requestedFromSeq: number, ): { readonly events: AsyncIterable readonly completed: Promise> } { const completion = Promise.withResolvers>() const migrating = header.migrations.length > 0 const compatibility = header.unversionedCompatibility let physical: StoredEventRead | undefined const events = (async function* (): AsyncIterable { try { let physicalFromSeq = migrating ? 0 : requestedFromSeq physical = source.readEvents({ fromSeq: physicalFromSeq }) void physical.completed.catch(() => undefined) let raw: AsyncIterable = physical.events let physicalCompletion: StoredEventReadCompletion | undefined if (!migrating && requestedFromSeq > 0 && compatibility !== undefined) { const suffix: unknown[] = [] for await (const value of raw) suffix.push(value) physicalCompletion = await physical.completed if (suffix.some(value => compatibility.requiresPrefix(value))) { physicalFromSeq = 0 physical = source.readEvents({ fromSeq: 0 }) void physical.completed.catch(() => undefined) raw = physical.events physicalCompletion = undefined } else { raw = (async function* () { for (const value of suffix) yield await Promise.resolve(value) })() } } const storedEvents = snapshotStoredEvents(raw, header.meta.id) const canonicalEvents = compatibility === undefined ? storedEvents : compatibility.canonicalizeEvents(storedEvents, header.meta.id) const transformed = transformEvents( canonicalEvents, header.migrations, header.meta.id, ) const current = decodeCurrentEvents(source, header.meta, transformed, physicalFromSeq) for await (const event of current) { if (event.seq >= requestedFromSeq) yield event } completion.resolve(physicalCompletion ?? await physical.completed) } catch (error: unknown) { completion.reject(error) throw error } })() return { events, completed: completion.promise } } /** * Decode one backend source through the static adjacent-version migrations and * the current header/event validators. Format selection is complete before any * consumer-specific recovery runs. * @param source - backend-owned header, revision, and event reader factory. * @param expectedId - session identity selected by the caller. * @param fromSeq - first current-format event sequence to return. * @returns one decoded current-format stream bound to the stored revision. */ export function decodeStoredSession( source: StoredSessionSource, expectedId: SessionId, fromSeq = 0, ): DecodedSession { if (!Number.isSafeInteger(fromSeq) || fromSeq < 0) { throw new TypeError(`stored event fromSeq must be a non-negative safe integer, got ${String(fromSeq)}`) } const header = decodeHeader(source, expectedId) const read = decodedRead(source, header, fromSeq) return { meta: header.meta, sourceVersion: header.sourceVersion, revision: source.revision, events: read.events, completed: read.completed, } }