Skip to main content

semantic_memory/
journal.rs

1//! Mutation journal for device-owned replication.
2//!
3//! Provides a durable, contiguous, append-only journal that wraps existing
4//! semantic-memory mutation paths. Each journal entry records:
5//! - device/store identity
6//! - monotonic sequence number
7//! - operation kind (add_fact, add_message, etc.)
8//! - canonical payload for replication
9//!
10//! The journal is committed atomically with the semantic mutation in the
11//! same SQLite transaction. Export and replay operations provide the
12//! foundation for device-primary → server-replica synchronization.
13
14use crate::error::MemoryError;
15use rusqlite::Connection;
16
17/// A single journal entry representing one semantic mutation.
18#[derive(Debug, Clone)]
19pub struct JournalEntry {
20    pub journal_id: i64,
21    pub home_device_id: String,
22    pub store_id: String,
23    pub sequence: i64,
24    pub operation_kind: String,
25    pub payload: Vec<u8>,
26    pub created_at: String,
27}
28
29/// V37 migration: mutation journal for device-owned replication.
30pub const MIGRATION_V37: &str = "\
31CREATE TABLE IF NOT EXISTS mutation_journal (
32    journal_id      INTEGER PRIMARY KEY AUTOINCREMENT,
33    home_device_id  TEXT NOT NULL,
34    store_id        TEXT NOT NULL,
35    sequence        INTEGER NOT NULL,
36    operation_kind  TEXT NOT NULL,
37    payload         BLOB NOT NULL,
38    created_at      TEXT NOT NULL DEFAULT (datetime('now'))
39);
40CREATE UNIQUE INDEX IF NOT EXISTS idx_journal_sequence
41    ON mutation_journal(home_device_id, store_id, sequence);
42";
43
44/// Append a journal entry within an active transaction.
45///
46/// The caller is responsible for opening the transaction and committing
47/// the semantic mutation together. This function only appends the journal
48/// row and returns the assigned journal_id.
49pub fn append_journal_entry(
50    conn: &Connection,
51    home_device_id: &str,
52    store_id: &str,
53    operation_kind: &str,
54    payload: &[u8],
55) -> Result<i64, MemoryError> {
56    let next_seq: i64 = conn
57        .query_row(
58            "SELECT COALESCE(MAX(sequence), 0) + 1
59             FROM mutation_journal
60             WHERE home_device_id = ?1 AND store_id = ?2",
61            rusqlite::params![home_device_id, store_id],
62            |row| row.get(0),
63        )
64        .map_err(|e| MemoryError::Database(e))?;
65
66    conn.execute(
67        "INSERT INTO mutation_journal (home_device_id, store_id, sequence, operation_kind, payload)
68         VALUES (?1, ?2, ?3, ?4, ?5)",
69        rusqlite::params![home_device_id, store_id, next_seq, operation_kind, payload],
70    )
71    .map_err(MemoryError::Database)?;
72
73    Ok(next_seq)
74}
75
76/// Execute a mutation closure within a transaction that also journals it.
77///
78/// The closure receives `&Connection` and must perform the semantic mutation.
79/// After the closure succeeds, a journal entry is appended. If any step fails,
80/// the entire transaction is rolled back.
81///
82/// Returns `(journal_id, sequence, closure_result)`.
83pub fn mutate_and_journal<F, T>(
84    conn: &Connection,
85    home_device_id: &str,
86    store_id: &str,
87    operation_kind: &str,
88    payload: &[u8],
89    f: F,
90) -> Result<(i64, i64, T), MemoryError>
91where
92    F: FnOnce(&Connection) -> Result<T, MemoryError>,
93{
94    let tx = conn
95        .unchecked_transaction()
96        .map_err(MemoryError::Database)?;
97
98    // SAFETY: closure runs inside the transaction; any error aborts the tx.
99    let result = f(conn)?;
100
101    let seq = append_journal_entry(conn, home_device_id, store_id, operation_kind, payload)?;
102
103    let journal_id: i64 = conn.last_insert_rowid();
104
105    tx.commit().map_err(MemoryError::Database)?;
106
107    Ok((journal_id, seq, result))
108}
109
110/// Export a contiguous range of journal entries.
111///
112/// Returns entries with sequence numbers in `[start_seq, start_seq + limit)`.
113/// If a gap is detected (missing sequence), returns only the entries before the
114/// gap and sets `has_more` to false with `next_seq` pointing to the gap.
115pub fn export_contiguous(
116    conn: &Connection,
117    home_device_id: &str,
118    store_id: &str,
119    start_seq: i64,
120    limit: usize,
121) -> Result<ExportedBatch, MemoryError> {
122    let mut stmt = conn
123        .prepare(
124            "SELECT journal_id, home_device_id, store_id, sequence, operation_kind, payload, created_at
125             FROM mutation_journal
126             WHERE home_device_id = ?1 AND store_id = ?2 AND sequence >= ?3
127             ORDER BY sequence ASC
128             LIMIT ?4",
129        )
130        .map_err(MemoryError::Database)?;
131
132    let rows = stmt
133        .query_map(
134            rusqlite::params![home_device_id, store_id, start_seq, limit as i64],
135            |row| {
136                Ok(JournalEntry {
137                    journal_id: row.get(0)?,
138                    home_device_id: row.get(1)?,
139                    store_id: row.get(2)?,
140                    sequence: row.get(3)?,
141                    operation_kind: row.get(4)?,
142                    payload: row.get(5)?,
143                    created_at: row.get(6)?,
144                })
145            },
146        )
147        .map_err(MemoryError::Database)?;
148
149    let mut entries: Vec<JournalEntry> = Vec::new();
150    let mut expected = start_seq;
151    let mut has_gap = false;
152    for row in rows {
153        let entry = row.map_err(MemoryError::Database)?;
154        if entry.sequence != expected {
155            has_gap = true;
156            break;
157        }
158        expected = entry.sequence + 1;
159        entries.push(entry);
160    }
161
162    let has_more = if has_gap {
163        false
164    } else {
165        entries.len() >= limit
166    };
167
168    Ok(ExportedBatch {
169        entries,
170        next_seq: expected,
171        has_more,
172    })
173}
174
175/// A batch of journal entries ready for replication.
176#[derive(Debug, Clone)]
177pub struct ExportedBatch {
178    pub entries: Vec<JournalEntry>,
179    pub next_seq: i64,
180    pub has_more: bool,
181}
182
183/// Apply a single journal entry on a replica store.
184///
185/// The `replay_fn` closure receives the operation_kind and payload and must
186/// perform the equivalent semantic mutation on the replica. This function
187/// first checks whether the sequence has already been applied (idempotent replay),
188/// atomically applies the mutation and records the sequence, or quits silently
189/// if the sequence was already seen.
190pub fn replay_journal_entry<F>(
191    conn: &Connection,
192    home_device_id: &str,
193    store_id: &str,
194    sequence: i64,
195    operation_kind: &str,
196    payload: &[u8],
197    replay_fn: F,
198) -> Result<ReplayOutcome, MemoryError>
199where
200    F: FnOnce(&Connection) -> Result<(), MemoryError>,
201{
202    // Check if already applied
203    let already: bool = conn
204        .query_row(
205            "SELECT COUNT(*) > 0 FROM mutation_journal
206             WHERE home_device_id = ?1 AND store_id = ?2 AND sequence = ?3",
207            rusqlite::params![home_device_id, store_id, sequence],
208            |row| row.get(0),
209        )
210        .map_err(MemoryError::Database)?;
211
212    if already {
213        return Ok(ReplayOutcome::AlreadyApplied { sequence });
214    }
215
216    let tx = conn
217        .unchecked_transaction()
218        .map_err(MemoryError::Database)?;
219
220    // SAFETY: closure runs inside transaction; any error aborts
221    replay_fn(conn)?;
222
223    conn.execute(
224        "INSERT INTO mutation_journal (home_device_id, store_id, sequence, operation_kind, payload)
225         VALUES (?1, ?2, ?3, ?4, ?5)",
226        rusqlite::params![home_device_id, store_id, sequence, operation_kind, payload],
227    )
228    .map_err(MemoryError::Database)?;
229
230    tx.commit().map_err(MemoryError::Database)?;
231
232    Ok(ReplayOutcome::Applied { sequence })
233}
234
235/// Outcome of replaying a journal entry.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum ReplayOutcome {
238    Applied { sequence: i64 },
239    AlreadyApplied { sequence: i64 },
240}
241
242/// Get the next expected sequence number for a device/store pair.
243pub fn next_expected_sequence(
244    conn: &Connection,
245    home_device_id: &str,
246    store_id: &str,
247) -> Result<i64, MemoryError> {
248    let seq: i64 = conn
249        .query_row(
250            "SELECT COALESCE(MAX(sequence), 0) + 1
251             FROM mutation_journal
252             WHERE home_device_id = ?1 AND store_id = ?2",
253            rusqlite::params![home_device_id, store_id],
254            |row| row.get(0),
255        )
256        .map_err(MemoryError::Database)?;
257    Ok(seq)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263    use rusqlite::Connection;
264
265    fn test_conn() -> Connection {
266        let conn = Connection::open_in_memory().unwrap();
267        conn.execute_batch(MIGRATION_V37).unwrap();
268        conn
269    }
270
271    #[test]
272    fn append_and_export() {
273        let conn = test_conn();
274        let seq =
275            append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"payload-1").unwrap();
276        assert_eq!(seq, 1);
277
278        let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
279        assert_eq!(batch.entries.len(), 1);
280        assert_eq!(batch.next_seq, 2);
281        assert!(!batch.has_more);
282    }
283
284    #[test]
285    fn contiguous_gap_detection() {
286        let conn = test_conn();
287        append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"p1").unwrap();
288        append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"p2").unwrap();
289        // Delete sequence 2 to create a gap
290        conn.execute("DELETE FROM mutation_journal WHERE sequence = 2", [])
291            .unwrap();
292
293        // Now export from seq 1: should only get seq 1, detecting gap at seq 2
294        let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
295        assert_eq!(batch.entries.len(), 1);
296        assert_eq!(batch.entries[0].sequence, 1);
297        assert_eq!(batch.next_seq, 2);
298        assert!(!batch.has_more, "gap must set has_more=false");
299    }
300
301    #[test]
302    fn replay_is_idempotent() {
303        let conn = test_conn();
304
305        use std::cell::Cell;
306        let call_count = Cell::new(0);
307        let replay = |c: &Connection| -> Result<(), MemoryError> {
308            call_count.set(call_count.get() + 1);
309            c.execute("CREATE TABLE IF NOT EXISTS replayed (id INTEGER)", [])
310                .map_err(MemoryError::Database)?;
311            Ok(())
312        };
313
314        let result =
315            replay_journal_entry(&conn, "device-1", "store-1", 1, "add_fact", b"p1", &replay)
316                .unwrap();
317        assert_eq!(result, ReplayOutcome::Applied { sequence: 1 });
318        assert_eq!(call_count.get(), 1);
319
320        // Replay same sequence — must be AlreadyApplied
321        let result =
322            replay_journal_entry(&conn, "device-1", "store-1", 1, "add_fact", b"p1", &replay)
323                .unwrap();
324        assert_eq!(result, ReplayOutcome::AlreadyApplied { sequence: 1 });
325        assert_eq!(call_count.get(), 1, "replay fn must not be called again");
326    }
327
328    #[test]
329    fn mutate_and_journal_is_atomic() {
330        let conn = test_conn();
331
332        // Test: mutation fails, journal must not be written
333        let result = mutate_and_journal(
334            &conn,
335            "device-1",
336            "store-1",
337            "add_fact",
338            b"payload",
339            |_c| -> Result<(), MemoryError> {
340                Err(MemoryError::Database(rusqlite::Error::InvalidQuery))
341            },
342        );
343        assert!(result.is_err());
344
345        // Journal must be empty
346        let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
347        assert!(batch.entries.is_empty());
348    }
349
350    #[test]
351    fn mutate_and_journal_success() {
352        let conn = test_conn();
353
354        let (jid, seq, val) = mutate_and_journal(
355            &conn,
356            "device-1",
357            "store-1",
358            "add_fact",
359            b"payload",
360            |c| -> Result<_, MemoryError> {
361                c.execute("CREATE TABLE IF NOT EXISTS test_table (x INTEGER)", [])
362                    .map_err(MemoryError::Database)?;
363                Ok(42)
364            },
365        )
366        .unwrap();
367
368        assert_eq!(seq, 1);
369        assert_eq!(val, 42);
370        assert!(jid > 0);
371
372        let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
373        assert_eq!(batch.entries.len(), 1);
374        assert_eq!(batch.entries[0].operation_kind, "add_fact");
375    }
376}