1use crate::error::MemoryError;
15use rusqlite::Connection;
16
17#[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
29pub 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
44pub 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
76pub 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 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
110pub 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#[derive(Debug, Clone)]
177pub struct ExportedBatch {
178 pub entries: Vec<JournalEntry>,
179 pub next_seq: i64,
180 pub has_more: bool,
181}
182
183pub 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum ReplayOutcome {
238 Applied { sequence: i64 },
239 AlreadyApplied { sequence: i64 },
240}
241
242pub 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 conn.execute("DELETE FROM mutation_journal WHERE sequence = 2", [])
291 .unwrap();
292
293 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 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 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 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}