index.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285
  1. /**
  2. * Durable session-persistence Service Definition (`ctx.sessionPersistence`). Backends store
  3. * {@link SessionEvent}s as the event-sourced log and carry non-replayable
  4. * {@link SessionHeader} metadata separately.
  5. * @module @deepseek-ai/dsh-session-persistence
  6. */
  7. import { Context, Service } from '@deepseek-ai/cordis'
  8. import { SessionPreparation } from '@deepseek-ai/dsh-session'
  9. import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
  10. import type { SessionPersistenceRevision } from './revision.ts'
  11. // Re-export the metadata vocabulary so Consumers import it from the Service Definition.
  12. export type { SessionHeader } from '@deepseek-ai/dsh-session'
  13. export { SessionPersistenceRevision } from './revision.ts'
  14. export { SessionPersistenceNotFoundError } from './errors.ts'
  15. /** Lightweight immutable source identity returned without loading a full log. */
  16. export interface SessionPersistenceSnapshot {
  17. /** Detached metadata for one materialized session. */
  18. header: SessionHeader
  19. /** Opaque source-qualified token that changes whenever this stored log changes. */
  20. revision: SessionPersistenceRevision
  21. }
  22. /** Immutable logical session prepared from persistence or a live owner. */
  23. export interface SessionInspection {
  24. /** Validated immutable session metadata. */
  25. readonly meta: SessionHeader
  26. /** Validated contiguous logical event log. */
  27. readonly events: readonly SessionEvent[]
  28. }
  29. /** A borrowed exact Session source returned from a cold materialization or concurrent live owner. */
  30. export type BorrowedSessionSource = Disposable & (
  31. | {
  32. /** A reusable unpublished Session is pinned until this observation is disposed. */
  33. readonly source: 'prepared'
  34. /** Immutable header and logical event prefix observed together. */
  35. readonly inspection: SessionInspection
  36. /** Durable revision represented by the prepared source. */
  37. readonly revision: SessionPersistenceRevision
  38. /** Exact unpublished Session retained for a later {@link prepare}. */
  39. readonly preparedSession: Session
  40. }
  41. | {
  42. /** A live Session won source resolution while the persistence read was starting. */
  43. readonly source: 'live'
  44. /** Immutable live header and event prefix observed together. */
  45. readonly inspection: SessionInspection
  46. }
  47. )
  48. /** A backend's own raw artifact text for one session, verbatim. */
  49. export interface SessionRawArtifact {
  50. /** The session header parsed from the artifact's own first line. */
  51. readonly meta: SessionHeader
  52. /** The artifact's base filename on disk, without any physical encoding suffix. */
  53. readonly filename: string
  54. /** The artifact's full text content, decoded from the backend's physical encoding. */
  55. readonly content: string
  56. }
  57. // The backend-agnostic write-path orchestration first-party backends compose.
  58. export {
  59. DEFAULT_PREPARED_SESSION_CACHE_SIZE,
  60. DEFAULT_WRITE_BATCH_MAX_DELAY_MS,
  61. MAX_WRITE_BATCH_DELAY_MS,
  62. PersistenceCoordinator,
  63. SessionFormatUnsupportedError,
  64. SessionPersistenceCorruptionError,
  65. sessionFormatVersionRefusal,
  66. } from './coordinator.ts'
  67. export type {
  68. PersistenceBackend,
  69. PersistenceCoordinatorOptions,
  70. StoredPrefix,
  71. StoredSuffix,
  72. } from './coordinator.ts'
  73. declare module '@deepseek-ai/cordis' {
  74. interface Context {
  75. sessionPersistence: SessionPersistence
  76. }
  77. }
  78. /**
  79. * A backend-resolved, per-session local artifact location. The path is an
  80. * absolute target path and can name an artifact that has not materialized yet.
  81. * Consumers must treat it as a location hint, never as an authorization token.
  82. */
  83. export interface SessionLocation {
  84. /** Backend-specific artifact kind, for example `jsonl`. */
  85. readonly kind: string
  86. /** Absolute path to this session's backend-owned artifact. */
  87. readonly path: string
  88. }
  89. /**
  90. * Durable append-only session storage. Implementations preserve contiguous,
  91. * losslessly JSON-serializable events; {@link append} resolves only after
  92. * durability, and {@link load} balances a complete interrupted tail without
  93. * rewriting committed events.
  94. */
  95. export abstract class SessionPersistence extends Service {
  96. constructor(ctx: Context) {
  97. super(ctx, 'sessionPersistence')
  98. }
  99. /**
  100. * Resolve this backend's independent local artifact for a session without
  101. * reading, creating, flushing, or otherwise materializing it. Backends such
  102. * as SQLite that do not own one artifact per session return `undefined`.
  103. * @param meta - the immutable session header whose artifact is requested.
  104. * @returns the backend-specific absolute location, when one exists.
  105. */
  106. abstract locate(meta: SessionHeader): SessionLocation | undefined
  107. /**
  108. * Whether this backend exposes one verbatim raw artifact per session.
  109. * A backend that declares `true` must override {@link readRaw}.
  110. */
  111. abstract readonly supportsRawArtifacts: boolean
  112. /**
  113. * Read a session's backend-owned artifact text verbatim — the exact durable
  114. * bytes the backend wrote (decoded from its physical encoding, e.g. a
  115. * decompressed JSONL). The returned `content` is the raw text, not a
  116. * reconstruction from parsed events, so it preserves backend-specific
  117. * serialization (chunk packing, key order, line breaks). Callers first test
  118. * {@link supportsRawArtifacts}; `undefined` then means only that the requested
  119. * session has no materialized artifact.
  120. * @param _id - the persisted session to read (unused by the default: no
  121. * per-session artifact).
  122. * @param signal - optional cancellation for backend read work.
  123. * @returns the raw artifact plus its parsed header, or `undefined` when the
  124. * session is absent.
  125. * @throws when this backend does not expose per-session raw artifacts.
  126. */
  127. readRaw(_id: SessionId, signal?: AbortSignal): Promise<SessionRawArtifact | undefined> {
  128. if (signal?.aborted === true) {
  129. return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted'))
  130. }
  131. return Promise.reject(new Error('this session persistence backend does not expose raw artifacts'))
  132. }
  133. /**
  134. * Register a new session's metadata. A backend MAY defer the physical write
  135. * until the first {@link append} (lazy materialization), in which case a
  136. * created-but-never-appended session is absent from {@link list}
  137. * — abandoned sessions leave nothing behind.
  138. * @param meta - the immutable header (id, version, cwd, lineage) to record.
  139. */
  140. abstract create(meta: SessionHeader): Promise<void>
  141. /**
  142. * Ensure a live session has a durable header even when it has no events.
  143. * Ordinary sessions remain lazily materialized; lifecycle frontends call
  144. * this only when an empty session itself is a durable resumable resource.
  145. * @param _session - exact live session whose registered header is materialized.
  146. */
  147. ensureMaterialized(_session: Session): Promise<void> {
  148. return Promise.reject(new Error('this session persistence backend cannot materialize an empty session'))
  149. }
  150. /**
  151. * Durably persist a batch of events. Honors the append-only and contiguous-
  152. * seq contracts: the first event's `seq` MUST equal the stored next-seq
  153. * (after `load` has durably closed any interrupted turn). Rejects non-JSON-
  154. * serializable `event.data` with an error naming the offending event type.
  155. * @param id - the session the batch belongs to.
  156. * @param events - the contiguous batch to persist, in seq order.
  157. */
  158. abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void>
  159. /**
  160. * Prepare the exact unpublished Session used by resume. Implementations may
  161. * reuse object graphs retained by an earlier {@link inspect} after confirming
  162. * their durable revision is still current; disposal releases an unpublished
  163. * reservation. Revision retries require the durable log to remain unchanged
  164. * for one read/check round trip; continuous external writers may delay completion.
  165. * @param id - persisted session to prepare.
  166. * @param signal - optional cancellation for preparation work.
  167. * @returns one owned unpublished Session preparation.
  168. */
  169. async prepare(id: SessionId, signal?: AbortSignal): Promise<SessionPreparation> {
  170. signal?.throwIfAborted()
  171. const loaded = await this.load(id)
  172. signal?.throwIfAborted()
  173. const sessions = this.ctx.get('sessions')
  174. if (sessions === undefined) {
  175. throw new Error('cannot prepare a session: SessionStore is not configured')
  176. }
  177. return SessionPreparation.create(sessions.prepare(id, {
  178. seed: loaded.events.map(event => structuredClone(event)),
  179. meta: structuredClone(loaded.meta),
  180. seedSource: 'persistence',
  181. }))
  182. }
  183. /**
  184. * Load an immutable balanced logical view and commit any required cold
  185. * recovery. A complete interrupted final turn is preserved and durably
  186. * closed with missing tool errors plus any open step and turn boundaries;
  187. * only a torn final record is discarded. Unknown versions and corruption in
  188. * the committed prefix reject. Implementations MUST NOT crash-repair an
  189. * identity still bound to a live Session: a balanced live log may return as a
  190. * durable snapshot, while an open live turn rejects. Returned values may be
  191. * shared with immutable live or prepared state and must not be mutated.
  192. * Revision-based implementations may wait for one stable read/check round trip.
  193. * @param id - the persisted session to reload.
  194. * @returns the header and a log ending on a balanced `turn/end`.
  195. */
  196. abstract load(id: SessionId): Promise<SessionInspection>
  197. /**
  198. * Inspect an immutable logical session without committing recovery or
  199. * publishing it. A cold complete interrupted turn receives synthetic closers
  200. * in memory and a torn physical tail remains untouched. An already-live
  201. * Session instead yields its current immutable snapshot, which may contain an
  202. * open turn and its `session/end-seed` boundary. Coordinator-backed
  203. * implementations retain the exact cold unpublished Session for bounded
  204. * reuse by a later {@link prepare}. A stale ready source is reloaded; a source
  205. * already committing or reserved for resume remains exclusive, and inspection
  206. * may borrow its immutable view. Callers borrow only the immutable header and
  207. * log. Continuous external writers may delay revision convergence.
  208. * @param id - the persisted session to inspect.
  209. * @param signal - optional cancellation for queued and backend read work.
  210. * @returns the validated header and current logical event log.
  211. */
  212. abstract inspect(id: SessionId, signal?: AbortSignal): Promise<SessionInspection>
  213. /**
  214. * Borrow one exact inspection while retaining any reusable prepared source.
  215. * A cold observation must pin the exact prepared Session that a later
  216. * {@link prepare} reserves. Implementations must not degrade this operation
  217. * to a detached {@link inspect} result.
  218. * @param id - persisted session to observe.
  219. * @param signal - optional cancellation for preparation work.
  220. * @returns a disposable immutable observation.
  221. */
  222. abstract borrowSession(id: SessionId, signal?: AbortSignal): Promise<BorrowedSessionSource>
  223. /**
  224. * Read the stored events from `fromSeq` onward — the read-from-seq
  225. * primitive for read models that resume from a watermark (e.g. a persisted
  226. * projection cache folding only the tail past its checkpoint). Unlike
  227. * {@link inspect}, it is a detached physical suffix read: no preparation
  228. * cache, torn-tail truncation, synthetic closers, or coordinator-state
  229. * publication. Only events from the valid contiguous stored prefix are
  230. * returned, so a torn fragment never reaches the caller. `fromSeq` at or
  231. * beyond the stored prefix returns an empty event list (never an error).
  232. * Backends whose medium can seek by seq
  233. * (SQLite) read only the suffix; sequential media (JSONL, both encodings)
  234. * still parse the whole artifact and skip forward — the primitive bounds
  235. * what is RETURNED and refolded, not every backend's physical read.
  236. * @param id - the persisted session to read.
  237. * @param fromSeq - first event seq to include; a non-negative safe integer.
  238. * @param signal - optional cancellation for queued and backend read work.
  239. * @returns the header and the stored events with `seq >= fromSeq`.
  240. */
  241. abstract readFrom(id: SessionId, fromSeq: number, signal?: AbortSignal):
  242. Promise<{ meta: SessionHeader; events: SessionEvent[] }>
  243. /**
  244. * Lightweight listing from metadata, without a full-log parse.
  245. * @param signal - optional cancellation for backend listing work.
  246. * @returns one header per materialized session.
  247. */
  248. abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
  249. /**
  250. * List materialized sessions with cheap per-log change tokens.
  251. *
  252. * Repeated observations of an unchanged log return the same revision. A
  253. * successful mutating {@link load} repair changes the next listed revision.
  254. * Revisions also distinguish independently backed stores so backend-local
  255. * counters cannot compare equal across different persistence sources.
  256. * @param signal - optional cancellation for backend snapshot-listing work.
  257. * @returns one header and opaque revision per materialized session without loading full logs.
  258. */
  259. abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
  260. }
  261. export default SessionPersistence