/** * Durable session-persistence seam (`ctx.sessionPersistence`). Backends store * {@link SessionEvent}s as the event-sourced log and carry non-replayable * {@link SessionHeader} metadata separately. * @module @deepseek-ai/dsh-session-persistence */ import { Context, Service } from 'cordis' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so consumers import it from the seam. export type { SessionHeader } from '@deepseek-ai/dsh-session' export { SessionPersistenceRevision } from './revision.ts' /** Lightweight immutable source identity returned without loading a full log. */ export interface SessionPersistenceSnapshot { /** Detached metadata for one materialized session. */ header: SessionHeader /** Opaque source-qualified token that changes whenever this stored log changes. */ revision: SessionPersistenceRevision } // The backend-agnostic write-path orchestration first-party backends compose. export { PersistenceCoordinator } from './coordinator.ts' export type { PersistenceBackend, StoredPrefix, StoredSuffix } from './coordinator.ts' declare module 'cordis' { interface Context { sessionPersistence: SessionPersistence } } /** * A backend-resolved, per-session local artifact location. The path is an * absolute target path and can name an artifact that has not materialized yet. * Consumers must treat it as a location hint, never as an authorization token. */ export interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ readonly kind: string /** Absolute path to this session's backend-owned artifact. */ readonly path: string } /** * Durable append-only session storage. Implementations preserve contiguous, * losslessly JSON-serializable events; {@link append} resolves only after * durability, and {@link load} balances a complete interrupted tail without * rewriting committed events. */ export abstract class SessionPersistence extends Service { constructor(ctx: Context) { super(ctx, 'sessionPersistence') } /** * Resolve this backend's independent local artifact for a session without * reading, creating, flushing, or otherwise materializing it. Backends such * as SQLite that do not own one artifact per session return `undefined`. * @param meta - the immutable session header whose artifact is requested. * @returns the backend-specific absolute location, when one exists. */ abstract locate(meta: SessionHeader): SessionLocation | undefined /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. * @param meta - the immutable header (id, version, cwd, lineage) to record. */ abstract create(meta: SessionHeader): Promise /** * Durably persist a batch of events. Honors the append-only and contiguous- * seq contracts: the first event's `seq` MUST equal the stored next-seq * (after `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. * @param id - the session the batch belongs to. * @param events - the contiguous batch to persist, in seq order. */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise /** * Load a header and balanced contiguous log. A complete interrupted final * turn is preserved and durably closed with missing tool errors plus any open * step and turn boundaries; only a torn final record is discarded. Unknown * versions and corruption in the committed prefix reject. Implementations * MUST NOT crash-repair an identity still bound to a live Session: a balanced * live log may return with its stored header as a durable snapshot, while an * open live turn rejects. * A coordinator-backed cold load reserves the identity across storage awaits, * so concurrent publication of a same-id live Session rejects. * Returned events are detached, and every identified message is deeply * frozen. Coordinator-backed implementations upgrade supported pre-identity * message events before validation; other malformed messages reject before * any stored event is returned. * @param id - the persisted session to reload. * @returns the header and a log ending on a balanced `turn/end`. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Inspect a header and its valid contiguous stored prefix without repairing * a torn tail, closing an interrupted turn, or publishing coordinator state. * This read is serialized with writes for the same id and returns detached * values with upgraded, deeply frozen identified messages, so observers * cannot mutate message identity/content or backend-owned state. Other * malformed messages reject. * @param id - the persisted session to inspect. * @param signal - optional cancellation for queued and backend read work. * @returns the header and valid stored event prefix exactly as observed. */ abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Read the stored events from `fromSeq` onward — the read-from-seq * primitive for read models that resume from a watermark (e.g. a persisted * projection cache folding only the tail past its checkpoint). Like * {@link inspect} it is non-mutating and detached: no torn-tail truncation, * no synthetic closers, no coordinator-state publication; only events from * the valid contiguous stored prefix are returned, so a torn fragment never * reaches the caller. `fromSeq` at or beyond the stored prefix returns an * empty event list (never an error). Backends whose medium can seek by seq * (SQLite) read only the suffix; sequential media (JSONL, both encodings) * still parse the whole artifact and skip forward — the primitive bounds * what is RETURNED and refolded, not every backend's physical read. * @param id - the persisted session to read. * @param fromSeq - first event seq to include; a non-negative safe integer. * @param signal - optional cancellation for queued and backend read work. * @returns the header and the stored events with `seq >= fromSeq`. */ abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> /** * Lightweight listing from metadata, without a full-log parse. * @param signal - optional cancellation for backend listing work. * @returns one header per materialized session. */ abstract list(signal?: AbortSignal): Promise /** * List materialized sessions with cheap per-log change tokens. * * Repeated observations of an unchanged log return the same revision. A * successful mutating {@link load} repair changes the next listed revision. * Revisions also distinguish independently backed stores so backend-local * counters cannot compare equal across different persistence sources. * @param signal - optional cancellation for backend snapshot-listing work. * @returns one header and opaque revision per materialized session without loading full logs. */ abstract listSnapshots(signal?: AbortSignal): Promise } export default SessionPersistence