Skip to main content

mj_controller/database/
materialized.rs

1use super::*;
2
3/// Load a session's whole projection, transcript and all.
4///
5/// Crate-private on purpose. The cost of this call is everything that has ever
6/// happened in the conversation, and the callers that made that a visible
7/// problem — the runtime poll and the resume reply — were both outside this
8/// crate. What they wanted was [`load_materialized_projection_tail`]; what
9/// they reached for was this, because it was public and its name did not say
10/// otherwise. The remaining caller owns a live projection and genuinely needs
11/// all of it.
12pub fn load_materialized_session(session_id: &str) -> Result<Option<MaterializedSession>> {
13    load_materialized_session_from(&database_path(), session_id)
14}
15
16/// Load only the projection fields needed by dashboard session summaries.
17/// Transcript bodies for tools, plans, thoughts, and old messages stay in
18/// SQLite, which keeps dashboard startup independent of transcript size.
19pub fn load_materialized_session_summary(
20    session_id: &str,
21) -> Result<Option<MaterializedSessionSummary>> {
22    load_materialized_session_summary_from(&database_path(), session_id)
23}
24
25pub(super) fn load_materialized_session_summary_from(
26    path: &Path,
27    session_id: &str,
28) -> Result<Option<MaterializedSessionSummary>> {
29    let connection = open_reader(path)?;
30    let row = connection
31        .query_row(
32            "SELECT applied_event_ordinal, last_activity_at_ms, execution_state,
33                    running_started_at_ms, session_title
34             FROM materialized_sessions WHERE session_id = ?1",
35            [session_id],
36            |row| {
37                Ok((
38                    row.get::<_, u64>(0)?,
39                    row.get::<_, Option<i64>>(1)?,
40                    row.get::<_, String>(2)?,
41                    row.get::<_, Option<i64>>(3)?,
42                    row.get::<_, Option<String>>(4)?,
43                ))
44            },
45        )
46        .optional()?;
47    let Some((
48        applied_event_ordinal,
49        last_activity_at_ms,
50        execution,
51        running_started_at_ms,
52        session_title,
53    )) = row
54    else {
55        return Ok(None);
56    };
57
58    let last_user_message = last_materialized_user_message(&connection, session_id)?;
59    let last_agent_message = last_materialized_agent_message(&connection, session_id)?;
60    let last_agent_message_follows_last_user =
61        last_agent_message
62            .as_ref()
63            .is_some_and(|(agent_position, _)| {
64                last_user_message
65                    .as_ref()
66                    .is_none_or(|(user_position, _)| agent_position > user_position)
67            });
68    let mut ordinal_statement = connection.prepare(
69        "SELECT latest_content_event_ordinal
70         FROM materialized_transcript_items
71         WHERE session_id = ?1
72           AND latest_content_event_ordinal IS NOT NULL
73           AND EXISTS (
74               SELECT 1 FROM json_each(
75                   CASE
76                       WHEN latest_content_event_ordinal IS NOT NULL
77                           AND json_valid(body_json)
78                       THEN body_json
79                       ELSE '{}'
80                   END,
81                   '$.chunks'
82               ) AS chunk
83               WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
84                 AND (
85                     json_extract(chunk.value, '$.content.type') <> 'text'
86                     OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
87                 )
88           )
89         ORDER BY position, stable_id",
90    )?;
91    let agent_message_latest_content_ordinals = ordinal_statement
92        .query_map([session_id], |row| row.get::<_, u64>(0))?
93        .collect::<rusqlite::Result<Vec<_>>>()?;
94    let restart_pattern = format!("{}*", mj_core::transcript::SESSION_RESTART_ITEM_PREFIX);
95    let mut restart_statement = connection.prepare(
96        "SELECT position
97         FROM materialized_transcript_items
98         WHERE session_id = ?1 AND stable_id GLOB ?2
99         ORDER BY position, stable_id",
100    )?;
101    let session_restart_event_ordinals = restart_statement
102        .query_map((session_id, restart_pattern), |row| row.get::<_, u64>(0))?
103        .collect::<rusqlite::Result<Vec<_>>>()?;
104
105    Ok(Some(MaterializedSessionSummary {
106        session_id: session_id.to_owned(),
107        applied_event_ordinal,
108        last_activity_at_ms,
109        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
110        session_title,
111        last_agent_message: last_agent_message.map(|(_, message)| message),
112        last_user_message: last_user_message.map(|(_, message)| message),
113        last_agent_message_follows_last_user,
114        agent_message_latest_content_ordinals,
115        session_restart_event_ordinals,
116    }))
117}
118
119/// The oldest visible user message, which is where a session's provisional
120/// title comes from. It sits at the head of the transcript, so a projection
121/// loaded as a tail cannot find it by scanning; this reads it directly.
122pub(super) fn first_materialized_user_message(
123    connection: &Connection,
124    session_id: &str,
125) -> Result<Option<(u64, String)>> {
126    materialized_user_message(connection, session_id, true)
127}
128
129pub(super) fn last_materialized_user_message(
130    connection: &Connection,
131    session_id: &str,
132) -> Result<Option<(u64, String)>> {
133    materialized_user_message(connection, session_id, false)
134}
135
136pub(super) fn materialized_user_message(
137    connection: &Connection,
138    session_id: &str,
139    oldest_first: bool,
140) -> Result<Option<(u64, String)>> {
141    let mut statement = connection.prepare(if oldest_first {
142        "SELECT position, body_json
143         FROM materialized_transcript_items
144         WHERE session_id = ?1
145           AND json_extract(
146               CASE
147                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
148                   THEN body_json
149                   ELSE '{}'
150               END,
151               '$.kind'
152           ) = 'user'
153         ORDER BY position, stable_id"
154    } else {
155        "SELECT position, body_json
156         FROM materialized_transcript_items
157         WHERE session_id = ?1
158           AND json_extract(
159               CASE
160                   WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
161                   THEN body_json
162                   ELSE '{}'
163               END,
164               '$.kind'
165           ) = 'user'
166         ORDER BY position DESC, stable_id DESC"
167    })?;
168    let rows = statement.query_map([session_id], |row| {
169        Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?))
170    })?;
171    for row in rows {
172        let (position, body_json) = row?;
173        let body: TranscriptBody = serde_json::from_str(&body_json)
174            .with_context(|| format!("parse materialized user message for session {session_id}"))?;
175        let TranscriptBody::User { content } = body else {
176            continue;
177        };
178        let text = mj_core::transcript::materialized_content_text(&content);
179        if !text.trim().is_empty() {
180            return Ok(Some((position, text)));
181        }
182    }
183    Ok(None)
184}
185
186/// Where the newest turn began: a user message, or the marker for a turn the
187/// harness started on its own. This is the recovery boundary, so it reads a
188/// position only and never has to decode a transcript body.
189pub(super) fn last_materialized_turn_start(
190    connection: &Connection,
191    session_id: &str,
192) -> Result<Option<u64>> {
193    Ok(connection
194        .query_row(
195            "SELECT position
196             FROM materialized_transcript_items
197             WHERE session_id = ?1
198               AND (
199                   stable_id GLOB ?2
200                   OR json_extract(
201                       CASE
202                           WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
203                           THEN body_json
204                           ELSE '{}'
205                       END,
206                       '$.kind'
207                   ) = 'user'
208               )
209             ORDER BY position DESC, stable_id DESC
210             LIMIT 1",
211            params![
212                session_id,
213                format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX)
214            ],
215            |row| row.get::<_, u64>(0),
216        )
217        .optional()?)
218}
219
220pub(super) fn last_materialized_agent_message(
221    connection: &Connection,
222    session_id: &str,
223) -> Result<Option<(u64, String)>> {
224    last_materialized_agent_message_in(connection, session_id, 0)
225}
226
227/// The newest nonempty agent message strictly after `after_position`, flattened
228/// to text. Restricting by position is how one turn's final message is read.
229pub(super) fn last_materialized_agent_message_after(
230    connection: &Connection,
231    session_id: &str,
232    after_position: u64,
233) -> Result<Option<String>> {
234    Ok(
235        last_materialized_agent_message_in(connection, session_id, after_position)?
236            .map(|(_, text)| text),
237    )
238}
239
240pub(super) fn last_materialized_agent_message_in(
241    connection: &Connection,
242    session_id: &str,
243    after_position: u64,
244) -> Result<Option<(u64, String)>> {
245    let row = connection
246        .query_row(
247            "SELECT position, body_json
248             FROM materialized_transcript_items
249             WHERE session_id = ?1
250               AND position > ?2
251               AND latest_content_event_ordinal IS NOT NULL
252               AND EXISTS (
253                   SELECT 1 FROM json_each(
254                       CASE
255                           WHEN latest_content_event_ordinal IS NOT NULL
256                               AND json_valid(body_json)
257                           THEN body_json
258                           ELSE '{}'
259                       END,
260                       '$.chunks'
261                   ) AS chunk
262                   WHERE json_extract(chunk.value, '$.content.type') IS NOT NULL
263                     AND (
264                         json_extract(chunk.value, '$.content.type') <> 'text'
265                         OR trim(coalesce(json_extract(chunk.value, '$.content.text'), '')) <> ''
266                     )
267               )
268             ORDER BY position DESC, stable_id DESC
269             LIMIT 1",
270            params![session_id, after_position],
271            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
272        )
273        .optional()?;
274    let Some((position, body_json)) = row else {
275        return Ok(None);
276    };
277    let body: TranscriptBody = serde_json::from_str(&body_json)
278        .with_context(|| format!("parse materialized agent message for session {session_id}"))?;
279    let TranscriptBody::Agent { chunks, .. } = body else {
280        return Ok(None);
281    };
282    let text = mj_core::transcript::materialized_chunks_text(&chunks);
283    Ok((!text.trim().is_empty()).then_some((position, text)))
284}
285
286/// Execution state, the running turn, and the last finished turn's outcome.
287pub type MaterializedTurnState = (
288    MaterializedExecutionState,
289    Option<MaterializedTurn>,
290    Option<MaterializedTurnOutcome>,
291);
292
293/// Where a session stands turn by turn: what is running now, and how the last
294/// finished prompt ended. Returns `None` when the session has no projection
295/// row. The API's wait loop reads this for sessions whose actor is gone.
296pub fn load_materialized_turn_outcome(session_id: &str) -> Result<Option<MaterializedTurnState>> {
297    load_materialized_turn_outcome_from(&database_path(), session_id)
298}
299
300pub(super) fn load_materialized_turn_outcome_from(
301    path: &Path,
302    session_id: &str,
303) -> Result<Option<MaterializedTurnState>> {
304    let connection = open_reader(path)?;
305    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
306        return Ok(None);
307    };
308    Ok(Some((
309        fields.execution,
310        fields.active_turn,
311        fields.last_turn_outcome,
312    )))
313}
314
315/// Summarize the turn that began at `turn_start_position`.
316pub fn load_materialized_turn_summary(
317    session_id: &str,
318    turn_start_position: u64,
319) -> Result<TurnSummary> {
320    load_materialized_turn_summary_from(&database_path(), session_id, turn_start_position)
321}
322
323pub(super) fn load_materialized_turn_summary_from(
324    path: &Path,
325    session_id: &str,
326    turn_start_position: u64,
327) -> Result<TurnSummary> {
328    let connection = open_reader(path)?;
329    let turn_number = connection.query_row(
330        "SELECT COUNT(*)
331         FROM materialized_transcript_items
332         WHERE session_id = ?1
333           AND position <= ?3
334           AND (
335               stable_id GLOB ?2
336               OR json_extract(
337                   CASE
338                       WHEN stable_id GLOB 'user:*' OR stable_id GLOB 'user-*'
339                       THEN body_json
340                       ELSE '{}'
341                   END,
342                   '$.kind'
343               ) = 'user'
344           )",
345        params![
346            session_id,
347            format!("{}*", mj_core::transcript::HARNESS_TURN_ITEM_PREFIX),
348            turn_start_position
349        ],
350        |row| row.get::<_, u64>(0),
351    )?;
352    let (turn_started_at_ms, last_changed_at_ms) = connection.query_row(
353        "SELECT COALESCE(MIN(created_at_ms), 0), COALESCE(MAX(last_changed_at_ms), 0)
354         FROM materialized_transcript_items
355         WHERE session_id = ?1 AND position >= ?2",
356        params![session_id, turn_start_position],
357        |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
358    )?;
359    let final_message =
360        last_materialized_agent_message_after(&connection, session_id, turn_start_position)?;
361    Ok(TurnSummary {
362        turn_number,
363        turn_started_at_ms,
364        last_changed_at_ms,
365        final_message,
366    })
367}
368
369pub fn load_materialized_transcript_filtered(
370    session_id: &str,
371    after_seq: u64,
372    limit: usize,
373    role: Option<mj_core::transcript::TranscriptRole>,
374) -> Result<Option<TranscriptPage>> {
375    load_materialized_transcript_filtered_from(&database_path(), session_id, after_seq, limit, role)
376}
377
378pub(super) fn load_materialized_transcript_filtered_from(
379    path: &Path,
380    session_id: &str,
381    after_seq: u64,
382    limit: usize,
383    role: Option<mj_core::transcript::TranscriptRole>,
384) -> Result<Option<TranscriptPage>> {
385    let mut reader = open_reader(path)?;
386    let connection = reader.transaction()?;
387    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
388        return Ok(None);
389    };
390    let role = role.map(|r| r.storage_kind());
391    let mut statement = connection.prepare(
392        "WITH matches AS (
393             SELECT *, COALESCE(latest_content_event_ordinal, position) AS seq
394             FROM materialized_transcript_items WHERE session_id = ?1
395             AND COALESCE(latest_content_event_ordinal, position) > ?2
396             AND (?4 IS NULL OR json_extract(body_json, '$.kind') = ?4)
397         ), boundary AS (SELECT MAX(seq) AS seq FROM (SELECT seq FROM matches ORDER BY seq LIMIT ?3))
398         SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
399                last_changed_at_ms, body_json
400         FROM matches WHERE seq <= (SELECT seq FROM boundary)
401         ORDER BY seq, stable_id",
402    )?;
403    let rows = statement
404        .query_map(
405            params![session_id, after_seq, limit.clamp(1, 1000) as i64, role],
406            |row| {
407                Ok((
408                    row.get::<_, String>(0)?,
409                    row.get::<_, u64>(1)?,
410                    row.get::<_, Option<u64>>(2)?,
411                    row.get::<_, i64>(3)?,
412                    row.get::<_, i64>(4)?,
413                    row.get::<_, String>(5)?,
414                ))
415            },
416        )?
417        .collect::<rusqlite::Result<Vec<_>>>()?;
418    let items = rows
419        .into_iter()
420        .map(
421            |(
422                stable_id,
423                position,
424                latest_content_event_ordinal,
425                created_at_ms,
426                last_changed_at_ms,
427                body_json,
428            )| {
429                Ok(Arc::new(TranscriptItem {
430                    stable_id,
431                    position,
432                    latest_content_event_ordinal,
433                    created_at_ms,
434                    last_changed_at_ms,
435                    body: serde_json::from_str(&body_json).with_context(|| {
436                        format!("parse materialized transcript body for session {session_id}")
437                    })?,
438                }))
439            },
440        )
441        .collect::<Result<Vec<_>>>()?;
442    let latest_seq = connection.query_row(
443        "SELECT COALESCE(MAX(COALESCE(latest_content_event_ordinal, position)), 0)
444         FROM materialized_transcript_items
445         WHERE session_id = ?1",
446        [session_id],
447        |row| row.get::<_, u64>(0),
448    )?;
449    let last_seq = items.last().map_or(after_seq, |item| item.seq());
450    let more: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM materialized_transcript_items WHERE session_id = ?1 AND COALESCE(latest_content_event_ordinal, position) > ?2 AND (?3 IS NULL OR json_extract(body_json, '$.kind') = ?3))", params![session_id, last_seq, role], |r| r.get(0))?;
451    Ok(Some(TranscriptPage {
452        next_after_seq: if more {
453            last_seq
454        } else {
455            latest_seq.max(after_seq)
456        },
457        items,
458        latest_seq,
459        execution: fields.execution,
460    }))
461}
462
463/// How many transcript rows one retention pass rewrites.
464///
465/// The daemon is the single database writer, so a pass that rewrote every row
466/// of a long session would stall every other write behind it. A capped pass
467/// leaves the rest for the next checkpoint, which is the next time any of it
468/// becomes redundant anyway.
469pub(super) const RETENTION_BATCH_ITEMS: usize = 4_096;
470
471/// Rows below this are already small enough that rewriting them would cost
472/// more than it reclaims.
473pub(super) const RETENTION_BODY_FLOOR_BYTES: usize = 4 * 1024;
474
475/// Drop tool output that a verified checkpoint already holds.
476///
477/// The projection only ever grew: the only deletes were a per-item remove, a
478/// whole-session wipe, and the `sessions` cascade. One measured session reached
479/// 28,066 items and 635 MiB, of which 561 MB was tool-call content.
480///
481/// A checkpoint archive carries the complete transcript up to its event
482/// frontier, and one checkpoint per session is retained, so every item at or
483/// below `event_frontier` is durably recorded elsewhere. What stays here is
484/// what the transcript still shows: which tool ran, on what, with what result,
485/// and each edit's diffstat. See
486/// [`mj_transcript::transcript::compact_tool_call_for_retention`].
487pub fn compact_materialized_transcript_through(
488    session_id: &str,
489    event_frontier: u64,
490) -> Result<TranscriptRetention> {
491    let session_id = session_id.to_owned();
492    submit_database_write("compact_materialized_transcript", move |_| {
493        compact_materialized_transcript_in(&database_path(), &session_id, event_frontier)
494    })
495}
496
497pub(super) fn compact_materialized_transcript_in(
498    path: &Path,
499    session_id: &str,
500    event_frontier: u64,
501) -> Result<TranscriptRetention> {
502    let mut connection = open(path)?;
503    let candidates = {
504        let mut statement = connection.prepare(
505            "SELECT stable_id, body_json
506             FROM materialized_transcript_items
507             WHERE session_id = ?1
508               AND position <= ?2
509               AND length(body_json) > ?3
510               AND json_extract(
511                   CASE WHEN json_valid(body_json) THEN body_json ELSE '{}' END,
512                   '$.kind'
513               ) = 'tool'
514             ORDER BY position, stable_id
515             LIMIT ?4",
516        )?;
517        statement
518            .query_map(
519                params![
520                    session_id,
521                    event_frontier,
522                    RETENTION_BODY_FLOOR_BYTES as i64,
523                    RETENTION_BATCH_ITEMS as i64 + 1
524                ],
525                |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
526            )?
527            .collect::<rusqlite::Result<Vec<_>>>()?
528    };
529    let remaining = candidates.len() > RETENTION_BATCH_ITEMS;
530    let mut retention = TranscriptRetention {
531        remaining,
532        ..TranscriptRetention::default()
533    };
534    let transaction =
535        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
536    for (stable_id, body_json) in candidates.into_iter().take(RETENTION_BATCH_ITEMS) {
537        let mut body: TranscriptBody = match serde_json::from_str(&body_json) {
538            Ok(body) => body,
539            // A row this cannot read is a row it must not rewrite.
540            Err(error) => {
541                tracing::warn!(%session_id, %stable_id, %error, "skipping unreadable transcript body");
542                continue;
543            }
544        };
545        if !mj_transcript::transcript::compact_tool_call_for_retention(&mut body) {
546            continue;
547        }
548        let compacted = serde_json::to_string(&body)
549            .with_context(|| format!("serialize compacted transcript body {stable_id}"))?;
550        if compacted.len() >= body_json.len() {
551            continue;
552        }
553        transaction.execute(
554            "UPDATE materialized_transcript_items SET body_json = ?3
555             WHERE session_id = ?1 AND stable_id = ?2",
556            params![session_id, stable_id, compacted],
557        )?;
558        retention.items += 1;
559        retention.bytes += body_json.len() - compacted.len();
560    }
561    transaction.commit()?;
562    Ok(retention)
563}
564
565/// How many transcript items a polled projection carries.
566///
567/// Every viewer of a polled projection is bounded already: the conversation
568/// pane keeps `chat::TAIL_SEED_ITEMS` (256) entries, and the browser
569/// transcript keeps 1,000 rendered lines. This is set above both, since an
570/// entry renders to at least one line, so the window is the whole of what any
571/// of them would show.
572pub const PROJECTION_TAIL_ITEMS: usize = 1_024;
573
574/// Load a projection carrying only the end of its transcript.
575///
576/// The steady-state poll reloads a session's projection every time anything
577/// about it moves. Loading the whole transcript to do that is work
578/// proportional to everything that has ever happened in the conversation —
579/// 635 MiB and 28,066 items on one measured session — for a view that shows
580/// the last few hundred entries. This reads the window instead, plus the two
581/// facts that live outside it, each with one indexed query. See
582/// [`ProjectionWindow`].
583pub fn load_materialized_projection_tail(
584    session_id: &str,
585    transcript_limit: usize,
586) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
587    load_materialized_projection_tail_from(&database_path(), session_id, transcript_limit)
588}
589
590pub(super) fn load_materialized_projection_tail_from(
591    path: &Path,
592    session_id: &str,
593    transcript_limit: usize,
594) -> Result<Option<(MaterializedSession, ProjectionWindow)>> {
595    let connection = open_reader(path)?;
596    let Some(fields) = read_materialized_session_fields(&connection, session_id)? else {
597        return Ok(None);
598    };
599    let transcript = read_materialized_transcript(&connection, session_id, Some(transcript_limit))?;
600    let total_items = connection.query_row(
601        "SELECT COUNT(*) FROM materialized_transcript_items WHERE session_id = ?1",
602        [session_id],
603        |row| row.get::<_, usize>(0),
604    )?;
605    let window = ProjectionWindow {
606        omitted_items: total_items.saturating_sub(transcript.len()),
607        provisional_title: first_materialized_user_message(&connection, session_id)?
608            .and_then(|(_, text)| mj_core::state::provisional_session_title(&text)),
609        latest_turn_start_position: last_materialized_turn_start(&connection, session_id)?,
610    };
611    let materialized = MaterializedSession {
612        session_id: session_id.to_owned(),
613        applied_event_ordinal: fields.applied_event_ordinal,
614        applied_event_digest: fields.applied_event_digest,
615        last_activity_at_ms: fields.last_activity_at_ms,
616        execution: fields.execution,
617        session_title: fields.session_title,
618        configuration: fields.configuration,
619        transcript,
620        queued_prompts: read_materialized_queued_prompts(&connection, session_id)?,
621        pending_elicitations: fields.pending_elicitations,
622        active_turn: fields.active_turn,
623        last_turn_outcome: fields.last_turn_outcome,
624    };
625    materialized.validate()?;
626    Ok(Some((materialized, window)))
627}
628
629/// Read only the projection's event frontier. Deciding whether a stored
630/// projection already matches an archive costs one row this way, instead of
631/// deserializing every transcript item to compare two integers.
632pub fn materialized_event_frontier(session_id: &str) -> Result<Option<(u64, String)>> {
633    materialized_event_frontier_from(&database_path(), session_id)
634}
635
636pub(super) fn materialized_event_frontier_from(
637    path: &Path,
638    session_id: &str,
639) -> Result<Option<(u64, String)>> {
640    Ok(open_reader(path)?
641        .query_row(
642            "SELECT applied_event_ordinal, applied_event_digest
643             FROM materialized_sessions WHERE session_id = ?1",
644            [session_id],
645            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
646        )
647        .optional()?)
648}
649
650/// Replace a session's durable prompt queue without touching its transcript or
651/// event frontier. Resume uses this when it keeps the stored projection but
652/// still has to drop the queue the archive carried.
653pub fn replace_materialized_queued_prompts(
654    session_id: &str,
655    queued_prompts: &[MaterializedQueuedPrompt],
656) -> Result<()> {
657    let session_id = session_id.to_owned();
658    let queued_prompts = queued_prompts.to_vec();
659    submit_database_write("replace_materialized_queued_prompts", move |_| {
660        replace_materialized_queued_prompts_in(&database_path(), &session_id, &queued_prompts)
661    })
662}
663
664pub(super) fn replace_materialized_queued_prompts_in(
665    path: &Path,
666    session_id: &str,
667    queued_prompts: &[MaterializedQueuedPrompt],
668) -> Result<()> {
669    let mut connection = open(path)?;
670    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
671    if !session_exists(&tx, session_id)? {
672        bail!("unknown session {session_id}");
673    }
674    replace_materialized_queue(&tx, session_id, queued_prompts)?;
675    tx.commit()?;
676    Ok(())
677}
678
679/// The activity watermark of every session whose projection holds at least one
680/// transcript item, by session id.
681///
682/// This is a change token, not a projection: session indexing needs to know
683/// which live conversations have moved since it last looked, and loading each
684/// one's transcript to find out would cost the whole corpus every sync.
685pub fn load_transcribed_session_activity() -> Result<BTreeMap<String, Option<i64>>> {
686    load_transcribed_session_activity_from(&database_path())
687}
688
689fn load_transcribed_session_activity_from(path: &Path) -> Result<BTreeMap<String, Option<i64>>> {
690    let connection = open_reader(path)?;
691    let mut statement = connection.prepare(
692        "SELECT session_id, last_activity_at_ms
693         FROM materialized_sessions s
694         WHERE EXISTS (
695             SELECT 1 FROM materialized_transcript_items i
696             WHERE i.session_id = s.session_id
697         )",
698    )?;
699    let rows = statement.query_map([], |row| {
700        Ok((row.get::<_, String>(0)?, row.get::<_, Option<i64>>(1)?))
701    })?;
702    let mut activity = BTreeMap::new();
703    for row in rows {
704        let (session_id, last_activity_at_ms) = row?;
705        activity.insert(session_id, last_activity_at_ms);
706    }
707    Ok(activity)
708}
709
710/// Load only the durable prompt queues without deserializing transcript rows.
711/// Dashboard startup uses this path so work is proportional to queued prompts,
712/// not to the complete retained conversation history.
713pub fn load_materialized_queued_prompts() -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>>
714{
715    load_materialized_queued_prompts_from(&database_path())
716}
717
718pub(super) fn load_materialized_queued_prompts_from(
719    path: &Path,
720) -> Result<BTreeMap<String, Vec<MaterializedQueuedPrompt>>> {
721    let connection = open_reader(path)?;
722    let mut statement = connection.prepare(
723        "SELECT session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
724         FROM materialized_queued_prompts
725         ORDER BY session_id, ordinal",
726    )?;
727    let rows = statement.query_map([], |row| {
728        Ok((
729            row.get::<_, String>(0)?,
730            row.get::<_, String>(1)?,
731            row.get::<_, String>(2)?,
732            row.get::<_, String>(3)?,
733            row.get::<_, i64>(4)?,
734            row.get::<_, Option<u64>>(5)?,
735        ))
736    })?;
737    let mut queues = BTreeMap::<String, Vec<MaterializedQueuedPrompt>>::new();
738    for row in rows {
739        let (session_id, command_id, kind_json, content_json, queued_at_ms, accepted_ordinal) =
740            row?;
741        let content = serde_json::from_str(&content_json).with_context(|| {
742            format!("parse materialized queued prompt for session {session_id}")
743        })?;
744        let kind = serde_json::from_str(&kind_json).with_context(|| {
745            format!("parse materialized queue entry kind for session {session_id}")
746        })?;
747        queues
748            .entry(session_id)
749            .or_default()
750            .push(MaterializedQueuedPrompt {
751                command_id,
752                kind,
753                content,
754                queued_at_ms,
755                accepted_ordinal,
756            });
757    }
758    Ok(queues)
759}
760
761pub(super) fn load_materialized_session_from(
762    path: &Path,
763    session_id: &str,
764) -> Result<Option<MaterializedSession>> {
765    let connection = open_reader(path)?;
766    load_materialized_session_with(&connection, session_id)
767}
768
769pub(super) fn load_materialized_session_with(
770    connection: &Connection,
771    session_id: &str,
772) -> Result<Option<MaterializedSession>> {
773    let Some(fields) = read_materialized_session_fields(connection, session_id)? else {
774        return Ok(None);
775    };
776    let materialized = MaterializedSession {
777        session_id: session_id.to_owned(),
778        applied_event_ordinal: fields.applied_event_ordinal,
779        applied_event_digest: fields.applied_event_digest,
780        last_activity_at_ms: fields.last_activity_at_ms,
781        execution: fields.execution,
782        session_title: fields.session_title,
783        configuration: fields.configuration,
784        transcript: read_materialized_transcript(connection, session_id, None)?,
785        queued_prompts: read_materialized_queued_prompts(connection, session_id)?,
786        pending_elicitations: fields.pending_elicitations,
787        active_turn: fields.active_turn,
788        last_turn_outcome: fields.last_turn_outcome,
789    };
790    materialized.validate()?;
791    Ok(Some(materialized))
792}
793
794/// Everything a projection holds apart from its transcript and its queue.
795pub(super) struct MaterializedSessionFields {
796    pub(super) applied_event_ordinal: u64,
797    pub(super) applied_event_digest: String,
798    pub(super) last_activity_at_ms: Option<i64>,
799    pub(super) execution: MaterializedExecutionState,
800    pub(super) session_title: Option<String>,
801    pub(super) configuration: BTreeMap<String, serde_json::Value>,
802    pub(super) pending_elicitations: Vec<mj_core::elicitation::ElicitationRequest>,
803    pub(super) active_turn: Option<MaterializedTurn>,
804    pub(super) last_turn_outcome: Option<MaterializedTurnOutcome>,
805}
806
807pub(super) fn read_materialized_session_fields(
808    connection: &Connection,
809    session_id: &str,
810) -> Result<Option<MaterializedSessionFields>> {
811    let row = connection
812        .query_row(
813            "SELECT applied_event_ordinal, applied_event_digest, last_activity_at_ms,
814                    execution_state, running_started_at_ms, session_title, configuration_json,
815                    pending_elicitations_json, active_turn_json, last_turn_outcome_json
816             FROM materialized_sessions WHERE session_id = ?1",
817            [session_id],
818            |row| {
819                Ok((
820                    row.get::<_, u64>(0)?,
821                    row.get::<_, String>(1)?,
822                    row.get::<_, Option<i64>>(2)?,
823                    row.get::<_, String>(3)?,
824                    row.get::<_, Option<i64>>(4)?,
825                    row.get::<_, Option<String>>(5)?,
826                    row.get::<_, String>(6)?,
827                    row.get::<_, String>(7)?,
828                    row.get::<_, Option<String>>(8)?,
829                    row.get::<_, Option<String>>(9)?,
830                ))
831            },
832        )
833        .optional()?;
834    let Some((
835        applied_event_ordinal,
836        applied_event_digest,
837        last_activity_at_ms,
838        execution,
839        running_started_at_ms,
840        session_title,
841        configuration_json,
842        pending_elicitations_json,
843        active_turn_json,
844        last_turn_outcome_json,
845    )) = row
846    else {
847        return Ok(None);
848    };
849    Ok(Some(MaterializedSessionFields {
850        applied_event_ordinal,
851        applied_event_digest,
852        last_activity_at_ms,
853        execution: parse_materialized_execution(&execution, running_started_at_ms)?,
854        session_title,
855        configuration: serde_json::from_str(&configuration_json).with_context(|| {
856            format!("parse materialized configuration for session {session_id}")
857        })?,
858        pending_elicitations: serde_json::from_str(&pending_elicitations_json)
859            .with_context(|| format!("parse pending elicitations for session {session_id}"))?,
860        active_turn: active_turn_json
861            .as_deref()
862            .map(serde_json::from_str)
863            .transpose()
864            .with_context(|| format!("parse active turn for session {session_id}"))?,
865        last_turn_outcome: last_turn_outcome_json
866            .as_deref()
867            .map(serde_json::from_str)
868            .transpose()
869            .with_context(|| format!("parse last turn outcome for session {session_id}"))?,
870    }))
871}
872
873/// Read a session's transcript, oldest first. `limit` reads only that many
874/// items from the end, walking the `materialized_transcript_position` index
875/// backwards so the read costs the rows it returns.
876pub(super) fn read_materialized_transcript(
877    connection: &Connection,
878    session_id: &str,
879    limit: Option<usize>,
880) -> Result<Vec<Arc<TranscriptItem>>> {
881    let mut statement = connection.prepare(match limit {
882        Some(_) => {
883            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
884                    last_changed_at_ms, body_json
885             FROM materialized_transcript_items
886             WHERE session_id = ?1
887             ORDER BY position DESC, stable_id DESC
888             LIMIT ?2"
889        }
890        None => {
891            "SELECT stable_id, position, latest_content_event_ordinal, created_at_ms,
892                    last_changed_at_ms, body_json
893             FROM materialized_transcript_items
894             WHERE session_id = ?1
895             ORDER BY position, stable_id"
896        }
897    })?;
898    let read = |row: &rusqlite::Row<'_>| {
899        Ok((
900            row.get::<_, String>(0)?,
901            row.get::<_, u64>(1)?,
902            row.get::<_, Option<u64>>(2)?,
903            row.get::<_, i64>(3)?,
904            row.get::<_, i64>(4)?,
905            row.get::<_, String>(5)?,
906        ))
907    };
908    let rows = match limit {
909        Some(limit) => statement
910            .query_map(params![session_id, limit as i64], read)?
911            .collect::<rusqlite::Result<Vec<_>>>()?,
912        None => statement
913            .query_map([session_id], read)?
914            .collect::<rusqlite::Result<Vec<_>>>()?,
915    };
916    let mut transcript = rows
917        .into_iter()
918        .map(
919            |(
920                stable_id,
921                position,
922                latest_content_event_ordinal,
923                created_at_ms,
924                last_changed_at_ms,
925                body_json,
926            )| {
927                Ok(Arc::new(TranscriptItem {
928                    stable_id,
929                    position,
930                    latest_content_event_ordinal,
931                    created_at_ms,
932                    last_changed_at_ms,
933                    body: serde_json::from_str(&body_json).with_context(|| {
934                        format!("parse materialized transcript body for session {session_id}")
935                    })?,
936                }))
937            },
938        )
939        .collect::<Result<Vec<_>>>()?;
940    if limit.is_some() {
941        // The bounded query walks the index backwards to bound what it reads;
942        // every caller wants the transcript in the order it was written.
943        transcript.reverse();
944    }
945    Ok(transcript)
946}
947
948pub(super) fn read_materialized_queued_prompts(
949    connection: &Connection,
950    session_id: &str,
951) -> Result<Vec<MaterializedQueuedPrompt>> {
952    let mut statement = connection.prepare(
953        "SELECT command_id, kind_json, content_json, queued_at_ms, accepted_ordinal
954         FROM materialized_queued_prompts
955         WHERE session_id = ?1
956         ORDER BY ordinal",
957    )?;
958    let rows = statement
959        .query_map([session_id], |row| {
960            Ok((
961                row.get::<_, String>(0)?,
962                row.get::<_, String>(1)?,
963                row.get::<_, String>(2)?,
964                row.get::<_, i64>(3)?,
965                row.get::<_, Option<u64>>(4)?,
966            ))
967        })?
968        .collect::<rusqlite::Result<Vec<_>>>()?;
969    rows.into_iter()
970        .map(
971            |(command_id, kind_json, content_json, queued_at_ms, accepted_ordinal)| {
972                Ok(MaterializedQueuedPrompt {
973                    command_id,
974                    kind: serde_json::from_str(&kind_json).with_context(|| {
975                        format!("parse materialized queue entry kind for session {session_id}")
976                    })?,
977                    content: serde_json::from_str(&content_json).with_context(|| {
978                        format!("parse materialized queued prompt for session {session_id}")
979                    })?,
980                    queued_at_ms,
981                    accepted_ordinal,
982                })
983            },
984        )
985        .collect()
986}
987
988/// Replace a complete projection, primarily when seeding a restored
989/// checkpoint. Operational `SessionRecord` metadata and read receipts are not
990/// modified.
991pub fn save_materialized_session(materialized: &MaterializedSession) -> Result<()> {
992    let materialized = materialized.clone();
993    submit_database_write("save_materialized_session", move |_| {
994        save_materialized_session_to(&database_path(), &materialized)
995    })
996}
997
998pub(super) fn save_materialized_session_to(
999    path: &Path,
1000    materialized: &MaterializedSession,
1001) -> Result<()> {
1002    materialized.validate()?;
1003    let mut connection = open(path)?;
1004    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1005    if !session_exists(&tx, &materialized.session_id)? {
1006        bail!("unknown session {}", materialized.session_id);
1007    }
1008    write_materialized_session(&tx, materialized)?;
1009    tx.commit()?;
1010    Ok(())
1011}
1012
1013/// One relay page being applied inside a single write transaction. The relay
1014/// retains everything past the last acknowledgement, so a page that fails
1015/// part-way rolls back to the previous durable frontier and is simply
1016/// redelivered. Only a committed page may be acknowledged.
1017pub struct ProjectionPage<'a> {
1018    pub(super) session_id: &'a str,
1019    pub(super) transaction: Transaction<'a>,
1020    pub(super) applied_ordinal: u64,
1021    pub(super) applied_digest: String,
1022    pub(super) dirty: bool,
1023    pub(super) pending: MaterializedSessionMutation,
1024    pub(super) pending_transcript: BTreeMap<String, PendingTranscriptMutation>,
1025    pub(super) pending_turns: Vec<MaterializedTurnOutcome>,
1026    pub(super) pending_events: Vec<(i64, ApiEventData)>,
1027}
1028
1029pub(super) struct PendingTranscriptMutation {
1030    pub(super) final_mutation: TranscriptMutation,
1031    pub(super) remove_before_upsert: bool,
1032}
1033
1034impl ProjectionPage<'_> {
1035    /// Apply the projection effects of the next relay event to the open page.
1036    /// The event must continue the chain the page has reached so far, which is
1037    /// the persisted frontier plus every event already applied to this page.
1038    pub fn apply(
1039        &mut self,
1040        event_ordinal: u64,
1041        previous_event_digest: &str,
1042        event_digest: &str,
1043        mutation: &MaterializedSessionMutation,
1044    ) -> Result<ProjectionApplyOutcome> {
1045        if event_ordinal == 0 {
1046            bail!("relay event ordinal must be positive");
1047        }
1048        // A v2 event carries no chain link (empty previous digest). Its
1049        // continuity to the projection frontier is proven by ordinal
1050        // contiguity plus the attach cursor the controller validated against
1051        // the worker, not by an in-record back-reference; divergence is caught
1052        // there, before any event is applied.
1053        let chained = !previous_event_digest.is_empty();
1054        if chained {
1055            validate_relay_event_digest(previous_event_digest, "previous relay event digest")?;
1056        }
1057        validate_relay_event_frontier(event_ordinal, event_digest, "relay event frontier")?;
1058        let session_id = self.session_id;
1059        let applied = self.applied_ordinal;
1060        if event_ordinal < applied {
1061            return Ok(ProjectionApplyOutcome::AlreadyApplied);
1062        }
1063        if event_ordinal == applied {
1064            if event_digest != self.applied_digest {
1065                bail!(
1066                    "relay event digest mismatch for session {session_id} at ordinal {event_ordinal}: projection has {}, received {event_digest}",
1067                    self.applied_digest
1068                );
1069            }
1070            return Ok(ProjectionApplyOutcome::AlreadyApplied);
1071        }
1072        let expected = applied
1073            .checked_add(1)
1074            .context("materialized event ordinal overflow")?;
1075        if event_ordinal != expected {
1076            bail!(
1077                "relay event gap for session {session_id}: expected ordinal {expected}, received {event_ordinal}"
1078            );
1079        }
1080        if chained && previous_event_digest != self.applied_digest {
1081            bail!(
1082                "relay event chain diverged for session {session_id} before ordinal {event_ordinal}: projection has {}, event follows {previous_event_digest}",
1083                self.applied_digest
1084            );
1085        }
1086
1087        if let Some(activity_at_ms) = mutation.last_activity_at_ms {
1088            self.pending.last_activity_at_ms = Some(
1089                self.pending
1090                    .last_activity_at_ms
1091                    .map_or(activity_at_ms, |existing| existing.max(activity_at_ms)),
1092            );
1093        }
1094        if let Some(execution) = mutation.execution {
1095            self.pending.execution = Some(execution);
1096        }
1097        if let Some(title) = &mutation.session_title {
1098            if title.as_ref().is_some_and(|title| title.trim().is_empty()) {
1099                bail!("materialized session title cannot be empty");
1100            }
1101            self.pending.session_title = Some(title.clone());
1102        }
1103        if let Some(configuration) = &mutation.configuration {
1104            self.pending.configuration = Some(configuration.clone());
1105        }
1106        for item_mutation in &mutation.transcript {
1107            match item_mutation {
1108                TranscriptMutation::Upsert(item) => {
1109                    item.validate(event_ordinal)?;
1110                    let stable_id = item.stable_id.clone();
1111                    let entry = self.pending_transcript.entry(stable_id).or_insert_with(|| {
1112                        PendingTranscriptMutation {
1113                            final_mutation: TranscriptMutation::Upsert(item.clone()),
1114                            remove_before_upsert: false,
1115                        }
1116                    });
1117                    entry.remove_before_upsert |=
1118                        matches!(&entry.final_mutation, TranscriptMutation::Remove { .. });
1119                    entry.final_mutation = TranscriptMutation::Upsert(item.clone());
1120                }
1121                TranscriptMutation::Remove { stable_id } => {
1122                    if stable_id.trim().is_empty() {
1123                        bail!("cannot remove a transcript item with an empty stable id");
1124                    }
1125                    let removed = TranscriptMutation::Remove {
1126                        stable_id: stable_id.clone(),
1127                    };
1128                    self.pending_transcript
1129                        .entry(stable_id.clone())
1130                        .and_modify(|entry| entry.final_mutation = removed.clone())
1131                        .or_insert(PendingTranscriptMutation {
1132                            final_mutation: removed,
1133                            remove_before_upsert: false,
1134                        });
1135                }
1136            }
1137        }
1138        if let Some(queued_prompts) = &mutation.queued_prompts {
1139            self.pending.queued_prompts = Some(queued_prompts.clone());
1140        }
1141        if let Some(pending_elicitations) = &mutation.pending_elicitations {
1142            self.pending.pending_elicitations = Some(pending_elicitations.clone());
1143        }
1144        self.pending
1145            .config_results
1146            .extend(mutation.config_results.clone());
1147        if let Some(active_turn) = &mutation.active_turn {
1148            self.pending.active_turn = Some(active_turn.clone());
1149        }
1150        if let Some(last_turn_outcome) = &mutation.last_turn_outcome {
1151            self.pending_turns.push(last_turn_outcome.clone());
1152            self.pending.last_turn_outcome = Some(last_turn_outcome.clone());
1153        }
1154        if let Some(cost) = &mutation.provider_cost {
1155            self.pending.provider_cost = Some(cost.clone());
1156        }
1157        self.pending_events.extend(
1158            mutation
1159                .api_events
1160                .iter()
1161                .cloned()
1162                .map(|event| (mutation.last_activity_at_ms.unwrap_or(0), event)),
1163        );
1164        self.applied_ordinal = event_ordinal;
1165        event_digest.clone_into(&mut self.applied_digest);
1166        self.dirty = true;
1167        Ok(ProjectionApplyOutcome::Applied)
1168    }
1169
1170    /// Persist the coalesced final state of this page. Intermediate event
1171    /// frontiers are useful only for chain validation: a page commits or rolls
1172    /// back as a unit, so writing them individually adds no recovery value.
1173    pub(super) fn flush(&mut self) -> Result<()> {
1174        if !self.dirty {
1175            return Ok(());
1176        }
1177        let tx = &self.transaction;
1178        let session_id = self.session_id;
1179        if let Some(execution) = self.pending.execution {
1180            let (state, started_at_ms) = materialized_execution_columns(execution);
1181            tx.execute(
1182                "UPDATE materialized_sessions
1183                 SET execution_state = ?2, running_started_at_ms = ?3
1184                 WHERE session_id = ?1",
1185                params![session_id, state, started_at_ms],
1186            )?;
1187        }
1188        if let Some(title) = &self.pending.session_title {
1189            tx.execute(
1190                "UPDATE materialized_sessions SET session_title = ?2 WHERE session_id = ?1",
1191                params![session_id, title],
1192            )?;
1193        }
1194        if let Some(configuration) = &self.pending.configuration {
1195            tx.execute(
1196                "UPDATE materialized_sessions SET configuration_json = ?2 WHERE session_id = ?1",
1197                params![session_id, serde_json::to_string(configuration)?],
1198            )?;
1199        }
1200        for pending in self.pending_transcript.values() {
1201            match &pending.final_mutation {
1202                TranscriptMutation::Upsert(item) => {
1203                    // A remove followed by an upsert deliberately starts a new
1204                    // item identity. Preserve that boundary even though other
1205                    // repeated updates are coalesced to one write.
1206                    if pending.remove_before_upsert {
1207                        tx.execute(
1208                            "DELETE FROM materialized_transcript_items
1209                             WHERE session_id = ?1 AND stable_id = ?2",
1210                            params![session_id, item.stable_id],
1211                        )?;
1212                    }
1213                    upsert_transcript_item(tx, session_id, item)?;
1214                }
1215                TranscriptMutation::Remove { stable_id } => {
1216                    tx.execute(
1217                        "DELETE FROM materialized_transcript_items
1218                         WHERE session_id = ?1 AND stable_id = ?2",
1219                        params![session_id, stable_id],
1220                    )?;
1221                }
1222            }
1223        }
1224        if let Some(queued_prompts) = &self.pending.queued_prompts {
1225            replace_materialized_queue(tx, session_id, queued_prompts)?;
1226        }
1227        if let Some(pending_elicitations) = &self.pending.pending_elicitations {
1228            tx.execute(
1229                "UPDATE materialized_sessions
1230                 SET pending_elicitations_json = ?2 WHERE session_id = ?1",
1231                params![session_id, serde_json::to_string(pending_elicitations)?],
1232            )?;
1233        }
1234        for (recorded_at_ms, event) in &self.pending_events {
1235            events::insert_api_event(tx, session_id, *recorded_at_ms, event)?;
1236        }
1237        for turn in &self.pending_turns {
1238            tx.execute("INSERT OR REPLACE INTO session_turn_usage(session_id, command_id, completed_ordinal, turn_start_position, body) VALUES (?1, ?2, ?3, ?4, ?5)", params![session_id, turn.command_id, turn.completed_ordinal, turn.turn_start_position, serde_json::to_string(turn)?])?;
1239        }
1240        if let Some(cost) = &self.pending.provider_cost {
1241            tx.execute(
1242                "INSERT OR REPLACE INTO session_provider_cost(session_id, body) VALUES (?1, ?2)",
1243                params![session_id, serde_json::to_string(cost)?],
1244            )?;
1245        }
1246        for (command_id, error) in &self.pending.config_results {
1247            tx.execute("INSERT OR REPLACE INTO api_config_results(session_id, command_id, error) VALUES (?1, ?2, ?3)", params![session_id, command_id, error])?;
1248        }
1249        if let Some(active_turn) = &self.pending.active_turn {
1250            tx.execute(
1251                "UPDATE materialized_sessions SET active_turn_json = ?2 WHERE session_id = ?1",
1252                params![
1253                    session_id,
1254                    active_turn
1255                        .as_ref()
1256                        .map(serde_json::to_string)
1257                        .transpose()?
1258                ],
1259            )?;
1260        }
1261        if let Some(last_turn_outcome) = &self.pending.last_turn_outcome {
1262            tx.execute(
1263                "UPDATE materialized_sessions
1264                 SET last_turn_outcome_json = ?2 WHERE session_id = ?1",
1265                params![session_id, serde_json::to_string(last_turn_outcome)?],
1266            )?;
1267        }
1268        tx.execute(
1269            "UPDATE materialized_sessions
1270             SET last_activity_at_ms = CASE
1271                     WHEN ?2 IS NULL THEN last_activity_at_ms
1272                     WHEN last_activity_at_ms IS NULL OR last_activity_at_ms < ?2 THEN ?2
1273                     ELSE last_activity_at_ms
1274                 END,
1275                 applied_event_ordinal = ?3,
1276                 applied_event_digest = ?4
1277             WHERE session_id = ?1",
1278            params![
1279                session_id,
1280                self.pending.last_activity_at_ms,
1281                self.applied_ordinal,
1282                self.applied_digest,
1283            ],
1284        )?;
1285        Ok(())
1286    }
1287}
1288
1289/// Apply one relay page in a single transaction. `fill` feeds the page's
1290/// events through [`ProjectionPage::apply`]; the projection changes and the
1291/// event frontier commit together only when `fill` succeeds, so callers may
1292/// acknowledge the page's last ordinal to the relay after this returns.
1293pub fn apply_projection_page<T>(
1294    session_id: &str,
1295    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T> + Send + 'static,
1296) -> Result<T>
1297where
1298    T: Send + 'static,
1299{
1300    let session_id = session_id.to_owned();
1301    submit_database_write("apply_projection_page", move |connection| {
1302        apply_projection_page_with(connection, &session_id, fill)
1303    })
1304}
1305
1306#[cfg(test)]
1307pub(super) fn apply_projection_page_to<T>(
1308    path: &Path,
1309    session_id: &str,
1310    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1311) -> Result<T> {
1312    let mut connection = open(path)?;
1313    apply_projection_page_with(&mut connection, session_id, fill)
1314}
1315
1316pub(super) fn apply_projection_page_with<T>(
1317    connection: &mut Connection,
1318    session_id: &str,
1319    fill: impl FnOnce(&mut ProjectionPage<'_>) -> Result<T>,
1320) -> Result<T> {
1321    let transaction =
1322        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1323    let (applied_ordinal, applied_digest) = transaction
1324        .query_row(
1325            "SELECT applied_event_ordinal, applied_event_digest
1326             FROM materialized_sessions WHERE session_id = ?1",
1327            [session_id],
1328            |row| Ok((row.get::<_, u64>(0)?, row.get::<_, String>(1)?)),
1329        )
1330        .optional()?
1331        .with_context(|| format!("unknown session {session_id}"))?;
1332    validate_relay_event_frontier(
1333        applied_ordinal,
1334        &applied_digest,
1335        "persisted relay event frontier",
1336    )?;
1337    let mut page = ProjectionPage {
1338        session_id,
1339        transaction,
1340        applied_ordinal,
1341        applied_digest,
1342        dirty: false,
1343        pending: MaterializedSessionMutation::default(),
1344        pending_transcript: BTreeMap::new(),
1345        pending_turns: Vec::new(),
1346        pending_events: Vec::new(),
1347    };
1348    // Dropping the page on failure rolls the whole transaction back, leaving
1349    // the projection at the frontier the relay last saw acknowledged.
1350    let filled = fill(&mut page)?;
1351    page.flush()?;
1352    page.transaction.commit()?;
1353    Ok(filled)
1354}
1355
1356/// Apply exactly one relay event, as a page of one.
1357pub fn apply_projection_event(
1358    session_id: &str,
1359    event_ordinal: u64,
1360    previous_event_digest: &str,
1361    event_digest: &str,
1362    mutation: &MaterializedSessionMutation,
1363) -> Result<ProjectionApplyOutcome> {
1364    let session_id = session_id.to_owned();
1365    let previous_event_digest = previous_event_digest.to_owned();
1366    let event_digest = event_digest.to_owned();
1367    let mutation = mutation.clone();
1368    submit_database_write("apply_projection_event", move |connection| {
1369        apply_projection_page_with(connection, &session_id, |page| {
1370            page.apply(
1371                event_ordinal,
1372                &previous_event_digest,
1373                &event_digest,
1374                &mutation,
1375            )
1376        })
1377    })
1378}
1379
1380#[cfg(test)]
1381pub(super) fn apply_projection_event_to(
1382    path: &Path,
1383    session_id: &str,
1384    event_ordinal: u64,
1385    previous_event_digest: &str,
1386    event_digest: &str,
1387    mutation: &MaterializedSessionMutation,
1388) -> Result<ProjectionApplyOutcome> {
1389    apply_projection_page_to(path, session_id, |page| {
1390        page.apply(event_ordinal, previous_event_digest, event_digest, mutation)
1391    })
1392}
1393
1394/// Advance the persisted detach/read receipt monotonically. A receipt cannot
1395/// acknowledge an event the controller projection has not durably applied.
1396pub fn advance_viewed_through_event_ordinal(session_id: &str, through: u64) -> Result<u64> {
1397    let session_id = session_id.to_owned();
1398    submit_database_write("advance_viewed_through_event_ordinal", move |_| {
1399        advance_viewed_through_event_ordinal_to(&database_path(), &session_id, through)
1400    })
1401}
1402
1403pub(super) fn advance_viewed_through_event_ordinal_to(
1404    path: &Path,
1405    session_id: &str,
1406    through: u64,
1407) -> Result<u64> {
1408    let mut connection = open(path)?;
1409    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1410    let applied = tx
1411        .query_row(
1412            "SELECT applied_event_ordinal FROM materialized_sessions WHERE session_id = ?1",
1413            [session_id],
1414            |row| row.get::<_, u64>(0),
1415        )
1416        .optional()?
1417        .with_context(|| format!("unknown session {session_id}"))?;
1418    if through > applied {
1419        bail!(
1420            "cannot acknowledge event ordinal {through} for session {session_id}; projection is at {applied}"
1421        );
1422    }
1423    tx.execute(
1424        "UPDATE sessions
1425         SET viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?2)
1426         WHERE session_id = ?1",
1427        params![session_id, through],
1428    )?;
1429    let receipt = tx.query_row(
1430        "SELECT viewed_through_event_ordinal FROM sessions WHERE session_id = ?1",
1431        [session_id],
1432        |row| row.get::<_, u64>(0),
1433    )?;
1434    tx.commit()?;
1435    Ok(receipt)
1436}