index.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. /**
  2. * SQLite durable session-persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`).
  3. *
  4. * A SECOND {@link SessionPersistence} implementation, built to validate that
  5. * the abstract seam + the shared `runPersistenceContract` suite are genuinely
  6. * backend-agnostic: the same append-only / contiguous-seq / lazy-materialization
  7. * / interrupted-turn-close-on-load semantics the JSONL backend expresses over
  8. * file bytes, expressed here over `node:sqlite` rows. Each `SessionEvent` maps
  9. * 1:1 onto a row `(session_id, seq, type, time, data)`; `append` is an INSERT
  10. * inside a transaction that asserts the contiguous-seq contract; the mutable
  11. * `SessionSummary` lives in the `sessions` metadata row.
  12. *
  13. * Like the JSONL backend it is also the write-path plugin: it installs the
  14. * `session/event` → buffer → `session/flush` drain, persists a fork's seed once
  15. * on `session/created`, keeps a per-session write cursor so a resumed session
  16. * never re-appends stored events, and seeds existing live sessions on apply
  17. * (HMR does not replay `session/created`).
  18. *
  19. * @module @deepseek-ai/dsh-session-persistence-sqlite
  20. */
  21. import { Context } from 'cordis'
  22. import z from 'schemastery'
  23. import { DatabaseSync } from 'node:sqlite'
  24. import { mkdir } from 'node:fs/promises'
  25. import { dirname, resolve } from 'node:path'
  26. import { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
  27. import { isJsonValue, interruptedTurnClosers } from '@deepseek-ai/dsh-session'
  28. import type { Session, SessionEvent, SessionId, SessionMeta, SessionSummary } from '@deepseek-ai/dsh-session'
  29. import {
  30. openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow,
  31. } from './schema.ts'
  32. export { SCHEMA_VERSION } from './schema.ts'
  33. /** Plugin configuration. */
  34. export interface Config {
  35. /**
  36. * Filesystem path to the SQLite database file. The special value `:memory:`
  37. * opens an in-process database (tests); a file path is created (with parent
  38. * dirs) on construction.
  39. */
  40. path: string
  41. }
  42. /** Backend bookkeeping for a session id (NOT the live Session object). */
  43. interface SessionState {
  44. meta: SessionMeta
  45. /** Next seq to write — equals the number of committed events. */
  46. cursor: number
  47. /** Whether the session has at least one persisted event (materialized). */
  48. materialized: boolean
  49. /** The live Session that owns this state (collision detection); see onCreated. */
  50. owner?: Session
  51. }
  52. /**
  53. * Whether a live session's `seed` reproduces a persisted `prefix` exactly (the
  54. * prefix is no longer than the seed and each event DEEP-equals the seed event
  55. * at the same index). Distinguishes a session legitimately continuing a
  56. * persisted log (HMR re-seeing its own session, or a resume) from a different
  57. * session that merely reuses the id. Mirrors the JSONL backend's check; both
  58. * sides are JSON-serializable by contract, so `JSON.stringify` is a sound
  59. * canonical form.
  60. */
  61. function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly SessionEvent[]): boolean {
  62. return prefix.length <= seed.length
  63. && prefix.every((e, i) => {
  64. const s = seed[i]
  65. return s !== undefined && JSON.stringify(s) === JSON.stringify(e)
  66. })
  67. }
  68. /** Reject non-JSON-serializable `event.data`, naming the offending type. */
  69. function assertSerializable(events: readonly SessionEvent[]): void {
  70. for (const event of events) {
  71. if (!isJsonValue(event.data)) {
  72. throw new Error(`event "${event.type}" carries non-JSON-serializable data (seq ${event.seq})`)
  73. }
  74. }
  75. }
  76. /**
  77. * The SQLite persistence backend. Load as a plugin; it registers as
  78. * `ctx.sessionPersistence` and installs the write-path listeners.
  79. */
  80. export class SessionPersistenceSqlite extends SessionPersistence {
  81. static inject = ['sessions']
  82. static Config: z<Config> = z.object({
  83. path: z.string().required(),
  84. })
  85. private db!: DatabaseSync
  86. private ready: Promise<void>
  87. /** Backend bookkeeping keyed by session id (NOT the live Session object). */
  88. private states = new Map<string, SessionState>()
  89. /** Write-behind buffers keyed by the live Session (write path). */
  90. private buffers = new Map<Session, SessionEvent[]>()
  91. /** Per-session serialization chain (keyed by session id). */
  92. private chains = new Map<string, Promise<unknown>>()
  93. /** Per-session init promise (onCreated), keyed by the LIVE Session object. */
  94. private inits = new Map<Session, Promise<void>>()
  95. constructor(ctx: Context, public config: Config) {
  96. super(ctx)
  97. // Open the database asynchronously (the parent directory may need creating);
  98. // every backend op awaits `ready` first. Opening synchronously in the ctor
  99. // would force a sync mkdir and block plugin apply.
  100. this.ready = this.openDb(config.path)
  101. this.installWritePath()
  102. }
  103. private async openDb(path: string): Promise<void> {
  104. if (path !== ':memory:') {
  105. const abs = resolve(path)
  106. await mkdir(dirname(abs), { recursive: true, mode: 0o700 })
  107. this.db = openDatabase(abs)
  108. } else {
  109. this.db = openDatabase(path)
  110. }
  111. }
  112. // --- SessionPersistence backend surface (all serialized per session id) ---
  113. create(meta: SessionMeta): Promise<void> {
  114. const snapshot: SessionMeta = { ...meta }
  115. return this.serialize(snapshot.id, () => this.createCore(snapshot))
  116. }
  117. private async createCore(meta: SessionMeta): Promise<void> {
  118. await this.ready
  119. if (this.states.has(meta.id)) {
  120. throw new Error(`session "${meta.id}" already exists in this backend`)
  121. }
  122. if (this.rowFor(meta.id) !== undefined) {
  123. throw new Error(`session "${meta.id}" already has a persisted row; load/resume it instead of creating`)
  124. }
  125. // Lazy: record intent in memory only. No row until the first append, so an
  126. // abandoned (never-appended) session leaves nothing behind and stays absent
  127. // from has()/list().
  128. this.states.set(meta.id, { meta, cursor: 0, materialized: false })
  129. }
  130. // `async` so the synchronous validate/clone below reject (not throw) per the
  131. // Promise<void> contract — callers use `await expect(...).rejects`.
  132. async append(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
  133. // Validate serializability BEFORE cloning so a bad event surfaces the typed
  134. // "non-JSON-serializable" error rather than an opaque DataCloneError from
  135. // structuredClone. Then deep-snapshot the batch HERE, before the op waits
  136. // behind the per-session chain: a caller that passes a live array (e.g.
  137. // session.events) and mutates it — OR mutates an event inside it — before
  138. // the op runs would otherwise have those changes persisted, or advance the
  139. // cursor past what was written. The clone is taken at call time (before the
  140. // first await), matching the JSONL backend.
  141. assertSerializable(events)
  142. const batch = events.map(e => structuredClone(e))
  143. return this.serialize(id, () => this.appendCore(id, batch))
  144. }
  145. private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise<void> {
  146. await this.ready
  147. if (events.length === 0) return
  148. let state = this.states.get(id)
  149. if (state === undefined) state = await this.adopt(id)
  150. // Contiguity contract: each event's seq must continue the stored log.
  151. for (const [i, event] of events.entries()) {
  152. if (event.seq !== state.cursor + i) {
  153. throw new Error(`append seq mismatch for "${id}": expected ${state.cursor + i} at index ${i}, got ${event.seq}`)
  154. }
  155. }
  156. // The transaction is the durability + atomicity boundary: materialize the
  157. // sessions row (if lazy) and INSERT every event, or roll back entirely. A
  158. // BEGIN/COMMIT around the batch means a mid-batch failure (a UNIQUE
  159. // violation on a duplicated seq from a concurrent writer) leaves the stored
  160. // log untouched, so the cursor stays truthful and a retry is clean. (A crash
  161. // tail is already gone: load() physically deletes the torn fragment and
  162. // durably closes the interrupted turn before returning, so by the time any
  163. // append runs the stored log is balanced and contiguous.)
  164. const insertEvent = this.db.prepare(
  165. 'INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)',
  166. )
  167. this.db.exec('BEGIN')
  168. try {
  169. if (!state.materialized) this.writeRow(state.meta)
  170. for (const event of events) {
  171. insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data))
  172. }
  173. // Bump updatedAt on every append (the mutable summary lives in the row).
  174. const updatedAt = Date.now()
  175. this.db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(updatedAt, id)
  176. this.db.exec('COMMIT')
  177. state.meta = { ...state.meta, updatedAt }
  178. } catch (error) {
  179. this.db.exec('ROLLBACK')
  180. throw error
  181. }
  182. state.materialized = true
  183. state.cursor += events.length
  184. }
  185. load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
  186. return this.serialize(id, () => this.loadCore(id))
  187. }
  188. private async loadCore(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }> {
  189. await this.ready
  190. const row = this.rowFor(id)
  191. if (row === undefined) throw new Error(`session "${id}" not found`)
  192. const meta = rowToMeta(row)
  193. this.assertVersion(meta)
  194. // Read every stored row ordered by seq, then scan for the preserved prefix:
  195. // the longest seq-contiguous, parseable run, INCLUDING the real events of an
  196. // interrupted final turn after the last turn/end (a turn can be huge — they
  197. // are never truncated). scanRows works off the seq+type COLUMNS for the
  198. // last-turn/end boundary, so a malformed `data` in a torn tail row is
  199. // discarded (not unloadable); only a parse error / seq gap in the COMMITTED
  200. // region (at or before the last turn/end) throws (genuine corruption).
  201. const eventRows = this.db
  202. .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
  203. .all(id) as unknown as EventRow[]
  204. const { preserved, tornFrom } = scanRows(eventRows)
  205. // Crash-recovery (mutating load, same as the JSONL backend): if the log ended
  206. // mid-turn, close it DURING load so disk, the returned log, and the cursor all
  207. // agree — both append routes then continue with no special-casing. Synthesize
  208. // the boundary events (a step/end if a step was open, then a
  209. // turn/end {kind:'interrupted'}); the interrupted turn's real events are
  210. // preserved, never truncated (ADR 0018).
  211. const closers = interruptedTurnClosers(preserved)
  212. const balanced = [...preserved, ...closers]
  213. // Physically repair the stored log inside one transaction: DELETE the torn
  214. // tail fragment (if any), then INSERT the synthetic closers. After COMMIT the
  215. // stored rows == balanced, so the cursor is truthful and the next append
  216. // continues cleanly with no deferred repair. The metadata row stays as-is
  217. // even when preserved.length === 0 (an all-tail crash): the session WAS
  218. // materialized by the partial append, so has()/list() still report it — the
  219. // same as the JSONL backend, whose file likewise survives a first append that
  220. // never reached turn/end.
  221. if (tornFrom !== undefined || closers.length > 0) {
  222. this.db.exec('BEGIN')
  223. try {
  224. if (tornFrom !== undefined) {
  225. this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(id, tornFrom)
  226. }
  227. if (closers.length > 0) {
  228. const insertEvent = this.db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
  229. for (const event of closers) {
  230. insertEvent.run(id, event.seq, event.type, event.time, JSON.stringify(event.data))
  231. }
  232. }
  233. this.db.exec('COMMIT')
  234. } catch (error) {
  235. // The DELETE+INSERT cannot collide (a row at a closer's seq is preserved
  236. // or deleted as torn first); this rolls back a DB-level failure (disk
  237. // full, etc.), unreachable in test.
  238. /* v8 ignore start */
  239. this.db.exec('ROLLBACK')
  240. throw error
  241. /* v8 ignore stop */
  242. }
  243. }
  244. // Record state at the balanced length. The state keeps its OWN copy of the
  245. // meta; the returned value is separate so a consumer mutating loaded.meta
  246. // cannot corrupt the backend's row metadata.
  247. this.states.set(id, {
  248. meta: { ...meta },
  249. cursor: balanced.length,
  250. materialized: true,
  251. })
  252. return { meta, events: balanced }
  253. }
  254. async list(): Promise<SessionMeta[]> {
  255. await this.ready
  256. // Every metadata row is a materialized session: the row is written only by
  257. // the first append (a created-but-never-appended session has no row), so
  258. // listing all rows is exactly the materialized set.
  259. const rows = this.db
  260. .prepare('SELECT * FROM sessions')
  261. .all() as unknown as SessionRow[]
  262. return rows.map(rowToMeta)
  263. }
  264. async has(id: SessionId): Promise<boolean> {
  265. await this.ready
  266. const state = this.states.get(id)
  267. if (state?.materialized) return true
  268. // A metadata row exists iff the session was materialized by a first append.
  269. return this.rowFor(id) !== undefined
  270. }
  271. delete(id: SessionId): Promise<void> {
  272. return this.serialize(id, () => this.deleteCore(id))
  273. }
  274. private async deleteCore(id: SessionId): Promise<void> {
  275. await this.ready
  276. // ON DELETE CASCADE drops the session's events with its row.
  277. this.db.prepare('DELETE FROM sessions WHERE id = ?').run(id)
  278. this.states.delete(id)
  279. }
  280. update(id: SessionId, summary: Partial<SessionSummary>): Promise<void> {
  281. return this.serialize(id, () => this.updateCore(id, summary))
  282. }
  283. private async updateCore(id: SessionId, summary: Partial<SessionSummary>): Promise<void> {
  284. await this.ready
  285. let state = this.states.get(id)
  286. if (state === undefined) state = await this.adopt(id)
  287. const nextMeta: SessionMeta = { ...state.meta, ...summary }
  288. // update's only durable effect is the summary fields; the event log is
  289. // untouched. If the row is not materialized yet (a lazy session updated
  290. // before its first append) there is nothing to write — keep the pending
  291. // summary in memory so the materializing append carries it.
  292. if (state.materialized) this.writeRow(nextMeta)
  293. state.meta = nextMeta
  294. }
  295. // --- row helpers ---
  296. /** Fetch a session's row, or undefined if absent. */
  297. private rowFor(id: SessionId): SessionRow | undefined {
  298. const row = this.db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as unknown as SessionRow | undefined
  299. return row
  300. }
  301. /**
  302. * Insert-or-replace a session's metadata row. The only callers are the first
  303. * materializing `append` and a post-materialization `update`, so writing the
  304. * row IS the materialization (its existence is the signal `has`/`list` read);
  305. * a never-appended session has no row at all.
  306. */
  307. private writeRow(meta: SessionMeta): void {
  308. this.db.prepare(`
  309. INSERT INTO sessions (id, version, created_at, cwd, parent_session, updated_at, title, first_prompt)
  310. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  311. ON CONFLICT(id) DO UPDATE SET
  312. version = excluded.version,
  313. created_at = excluded.created_at,
  314. cwd = excluded.cwd,
  315. parent_session = excluded.parent_session,
  316. updated_at = excluded.updated_at,
  317. title = excluded.title,
  318. first_prompt = excluded.first_prompt
  319. `).run(
  320. meta.id,
  321. meta.version,
  322. meta.createdAt,
  323. meta.cwd ?? null,
  324. meta.parentSession ?? null,
  325. meta.updatedAt,
  326. meta.title ?? null,
  327. meta.firstPrompt ?? null,
  328. )
  329. }
  330. /** Build a state for a session present in the DB but not yet in memory. */
  331. private async adopt(id: SessionId): Promise<SessionState> {
  332. await this.loadCore(id) // sets the state; load (serialized) would deadlock
  333. const state = this.states.get(id)
  334. /* v8 ignore next -- loadCore always sets the state for the id */
  335. if (!state) throw new Error(`failed to adopt session "${id}"`)
  336. return state
  337. }
  338. private assertVersion(meta: SessionMeta): void {
  339. if (meta.version !== 1) {
  340. throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v1 is supported)`)
  341. }
  342. }
  343. /**
  344. * Run `op` after any in-flight operation for the same session id, so writes
  345. * for one session never interleave. Errors do not poison the chain. NOTE:
  346. * serialized public methods must NOT call each other (deadlock); they call
  347. * the unserialized `*Core` helpers instead.
  348. */
  349. private serialize<T>(id: SessionId, op: () => Promise<T>): Promise<T> {
  350. const prior = this.chains.get(id) ?? Promise.resolve()
  351. const next = prior.then(op, op)
  352. this.chains.set(id, next.then(() => undefined, () => undefined))
  353. return next
  354. }
  355. // --- write path (session/event → flush drain) ---
  356. private installWritePath(): void {
  357. const ctx = this.ctx
  358. ctx.on('session/created', (session) => { void this.initFor(session) })
  359. // Snapshot + buffer every event (the live object is mutable; clone so a
  360. // later in-place mutation cannot rewrite a buffered event). Serializability
  361. // is guaranteed at the source (Session.append), so structuredClone is safe.
  362. ctx.on('session/event', (session, event) => {
  363. let buffer = this.buffers.get(session)
  364. if (!buffer) this.buffers.set(session, buffer = [])
  365. buffer.push(structuredClone(event))
  366. })
  367. ctx.on('session/flush', session => this.flush(session))
  368. // Dispose must reach quiescence: await every init + final drain, then close
  369. // the database, BEFORE returning, so no write lands after teardown.
  370. ctx.effect(() => async () => {
  371. await Promise.allSettled([...this.inits.values()])
  372. await Promise.allSettled([...this.buffers.keys()].map(s => this.flush(s)))
  373. await Promise.allSettled([...this.chains.values()])
  374. await this.ready
  375. this.db.close()
  376. }, 'session-persistence-sqlite write path')
  377. // HMR: a hot reload does not replay session/created, so seed existing live
  378. // sessions (mirrors dsh-invariants and the JSONL backend).
  379. for (const session of ctx.sessions.list()) void this.initFor(session)
  380. }
  381. /** Start (once) the async init for a session and remember its promise. */
  382. private initFor(session: Session): Promise<void> {
  383. const existing = this.inits.get(session)
  384. if (existing) return existing
  385. const seed = session.events.map(e => structuredClone(e))
  386. const p = this.onCreated(session, seed)
  387. p.catch(() => { /* observed by flush/dispose via the stored promise */ })
  388. this.inits.set(session, p)
  389. return p
  390. }
  391. /**
  392. * On session/created: sync the backend's state to a live Session. Cases
  393. * mirror the JSONL backend:
  394. * 1. Already tracked → no-op (or claim ownerless state if the seed matches).
  395. * 2. A row EXISTS and is a seq-aligned PREFIX of the live events → adopt
  396. * (HMR/resume), persisting any live suffix beyond the stored prefix.
  397. * 3. A row EXISTS but is NOT a prefix → reject (id collision).
  398. * 4. No row → a genuinely new session: register meta (lazy) + persist seed.
  399. */
  400. private async onCreated(session: Session, seed: readonly SessionEvent[]): Promise<void> {
  401. await this.ready
  402. const id = session.header.id
  403. const tracked = this.states.get(id)
  404. if (tracked !== undefined) {
  405. /* v8 ignore next -- initFor dedupes per session object; same-object re-entry can't occur */
  406. if (tracked.owner === session) return
  407. if (tracked.owner === undefined) {
  408. // Ownerless state from a public create()/load(). The first live session
  409. // claims it ONLY if its seed reproduces the persisted prefix.
  410. if (!await this.seedMatchesPersisted(id, seed, tracked.cursor)) {
  411. throw new Error(`session "${id}" is already persisted with ${tracked.cursor} event(s) that do not match this live session (id collision)`)
  412. }
  413. tracked.owner = session
  414. const suffix = seed.slice(tracked.cursor)
  415. if (suffix.length > 0) await this.append(id, suffix)
  416. return
  417. }
  418. // Owned by a DIFFERENT live session. Reclaim ONLY a truly-abandoned id
  419. // (never materialized, no pending buffer); else it is a real collision.
  420. const ownerBuffer = this.buffers.get(tracked.owner)
  421. if (!tracked.materialized && !ownerBuffer?.length) {
  422. this.states.delete(id)
  423. } else {
  424. throw new Error(`session "${id}" is already bound to a different live session in this backend (id collision)`)
  425. }
  426. }
  427. const row = this.rowFor(id)
  428. if (row !== undefined) {
  429. const stored = this.eventsFor(id)
  430. if (!seedCoversPrefix(seed, stored)) {
  431. throw new Error(`session "${id}" already has a persisted log that does not match this live session (id collision)`)
  432. }
  433. await this.serialize(id, () => this.loadCore(id))
  434. const adopted = this.states.get(id)
  435. /* v8 ignore next -- loadCore always sets the state for the id */
  436. if (adopted !== undefined) adopted.owner = session
  437. const suffix = seed.slice(stored.length)
  438. if (suffix.length > 0) await this.append(id, suffix)
  439. return
  440. }
  441. // case 4: a genuinely new session.
  442. const meta: SessionMeta = { ...session.header, updatedAt: Date.now() }
  443. await this.create(meta)
  444. const created = this.states.get(id)
  445. /* v8 ignore next -- create() always sets the state for the id */
  446. if (created !== undefined) created.owner = session
  447. if (seed.length > 0) await this.append(id, seed)
  448. }
  449. /** The preserved events for a session id (torn tail excluded, turn NOT yet closed). */
  450. private eventsFor(id: SessionId): SessionEvent[] {
  451. const rows = this.db
  452. .prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
  453. .all(id) as unknown as EventRow[]
  454. // Scan on seq+type columns, parsing `data` only for the preserved prefix (a
  455. // malformed torn tail must not throw here — same as loadCore). Returns the
  456. // preserved events WITHOUT the synthetic closers, so a collision check
  457. // compares a live seed against the real on-disk events, mirroring the JSONL
  458. // backend's scanLog use in onCreated.
  459. return scanRows(rows).preserved
  460. }
  461. /** Whether a live session's seed reproduces the first `cursor` stored events. */
  462. private async seedMatchesPersisted(id: SessionId, seed: readonly SessionEvent[], cursor: number): Promise<boolean> {
  463. await this.ready
  464. if (cursor === 0) return true
  465. return seedCoversPrefix(seed, this.eventsFor(id).slice(0, cursor))
  466. }
  467. private async flush(session: Session): Promise<void> {
  468. await this.inits.get(session)
  469. await this.serialize(session.header.id, () => this.drain(session))
  470. }
  471. /** Drain a session's write buffer to the database. Caller serializes per id. */
  472. private async drain(session: Session): Promise<void> {
  473. const buffer = this.buffers.get(session)
  474. if (!buffer?.length) return
  475. const batch = buffer.slice()
  476. const state = this.states.get(session.header.id)
  477. /* v8 ignore next -- state is always set by the awaited init before flush */
  478. const cursor = state?.cursor ?? 0
  479. const fresh = batch.filter(e => e.seq >= cursor)
  480. if (fresh.length > 0) await this.appendCore(session.header.id, fresh)
  481. buffer.splice(0, batch.length)
  482. }
  483. }
  484. export default SessionPersistenceSqlite