use crate::error::MemoryError;
use rusqlite::Connection;
#[derive(Debug, Clone)]
pub struct JournalEntry {
pub journal_id: i64,
pub home_device_id: String,
pub store_id: String,
pub sequence: i64,
pub operation_kind: String,
pub payload: Vec<u8>,
pub created_at: String,
}
pub const MIGRATION_V37: &str = "\
CREATE TABLE IF NOT EXISTS mutation_journal (
journal_id INTEGER PRIMARY KEY AUTOINCREMENT,
home_device_id TEXT NOT NULL,
store_id TEXT NOT NULL,
sequence INTEGER NOT NULL,
operation_kind TEXT NOT NULL,
payload BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_journal_sequence
ON mutation_journal(home_device_id, store_id, sequence);
";
pub fn append_journal_entry(
conn: &Connection,
home_device_id: &str,
store_id: &str,
operation_kind: &str,
payload: &[u8],
) -> Result<i64, MemoryError> {
let next_seq: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sequence), 0) + 1
FROM mutation_journal
WHERE home_device_id = ?1 AND store_id = ?2",
rusqlite::params![home_device_id, store_id],
|row| row.get(0),
)
.map_err(|e| MemoryError::Database(e))?;
conn.execute(
"INSERT INTO mutation_journal (home_device_id, store_id, sequence, operation_kind, payload)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![home_device_id, store_id, next_seq, operation_kind, payload],
)
.map_err(MemoryError::Database)?;
Ok(next_seq)
}
pub fn mutate_and_journal<F, T>(
conn: &Connection,
home_device_id: &str,
store_id: &str,
operation_kind: &str,
payload: &[u8],
f: F,
) -> Result<(i64, i64, T), MemoryError>
where
F: FnOnce(&Connection) -> Result<T, MemoryError>,
{
let tx = conn
.unchecked_transaction()
.map_err(MemoryError::Database)?;
let result = f(conn)?;
let seq = append_journal_entry(conn, home_device_id, store_id, operation_kind, payload)?;
let journal_id: i64 = conn.last_insert_rowid();
tx.commit().map_err(MemoryError::Database)?;
Ok((journal_id, seq, result))
}
pub fn export_contiguous(
conn: &Connection,
home_device_id: &str,
store_id: &str,
start_seq: i64,
limit: usize,
) -> Result<ExportedBatch, MemoryError> {
let mut stmt = conn
.prepare(
"SELECT journal_id, home_device_id, store_id, sequence, operation_kind, payload, created_at
FROM mutation_journal
WHERE home_device_id = ?1 AND store_id = ?2 AND sequence >= ?3
ORDER BY sequence ASC
LIMIT ?4",
)
.map_err(MemoryError::Database)?;
let rows = stmt
.query_map(
rusqlite::params![home_device_id, store_id, start_seq, limit as i64],
|row| {
Ok(JournalEntry {
journal_id: row.get(0)?,
home_device_id: row.get(1)?,
store_id: row.get(2)?,
sequence: row.get(3)?,
operation_kind: row.get(4)?,
payload: row.get(5)?,
created_at: row.get(6)?,
})
},
)
.map_err(MemoryError::Database)?;
let mut entries: Vec<JournalEntry> = Vec::new();
let mut expected = start_seq;
let mut has_gap = false;
for row in rows {
let entry = row.map_err(MemoryError::Database)?;
if entry.sequence != expected {
has_gap = true;
break;
}
expected = entry.sequence + 1;
entries.push(entry);
}
let has_more = if has_gap {
false
} else {
entries.len() >= limit
};
Ok(ExportedBatch {
entries,
next_seq: expected,
has_more,
})
}
#[derive(Debug, Clone)]
pub struct ExportedBatch {
pub entries: Vec<JournalEntry>,
pub next_seq: i64,
pub has_more: bool,
}
pub fn replay_journal_entry<F>(
conn: &Connection,
home_device_id: &str,
store_id: &str,
sequence: i64,
operation_kind: &str,
payload: &[u8],
replay_fn: F,
) -> Result<ReplayOutcome, MemoryError>
where
F: FnOnce(&Connection) -> Result<(), MemoryError>,
{
let already: bool = conn
.query_row(
"SELECT COUNT(*) > 0 FROM mutation_journal
WHERE home_device_id = ?1 AND store_id = ?2 AND sequence = ?3",
rusqlite::params![home_device_id, store_id, sequence],
|row| row.get(0),
)
.map_err(MemoryError::Database)?;
if already {
return Ok(ReplayOutcome::AlreadyApplied { sequence });
}
let tx = conn
.unchecked_transaction()
.map_err(MemoryError::Database)?;
replay_fn(conn)?;
conn.execute(
"INSERT INTO mutation_journal (home_device_id, store_id, sequence, operation_kind, payload)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![home_device_id, store_id, sequence, operation_kind, payload],
)
.map_err(MemoryError::Database)?;
tx.commit().map_err(MemoryError::Database)?;
Ok(ReplayOutcome::Applied { sequence })
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReplayOutcome {
Applied { sequence: i64 },
AlreadyApplied { sequence: i64 },
}
pub fn next_expected_sequence(
conn: &Connection,
home_device_id: &str,
store_id: &str,
) -> Result<i64, MemoryError> {
let seq: i64 = conn
.query_row(
"SELECT COALESCE(MAX(sequence), 0) + 1
FROM mutation_journal
WHERE home_device_id = ?1 AND store_id = ?2",
rusqlite::params![home_device_id, store_id],
|row| row.get(0),
)
.map_err(MemoryError::Database)?;
Ok(seq)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn test_conn() -> Connection {
let conn = Connection::open_in_memory().unwrap();
conn.execute_batch(MIGRATION_V37).unwrap();
conn
}
#[test]
fn append_and_export() {
let conn = test_conn();
let seq =
append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"payload-1").unwrap();
assert_eq!(seq, 1);
let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
assert_eq!(batch.entries.len(), 1);
assert_eq!(batch.next_seq, 2);
assert!(!batch.has_more);
}
#[test]
fn contiguous_gap_detection() {
let conn = test_conn();
append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"p1").unwrap();
append_journal_entry(&conn, "device-1", "store-1", "add_fact", b"p2").unwrap();
conn.execute("DELETE FROM mutation_journal WHERE sequence = 2", [])
.unwrap();
let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
assert_eq!(batch.entries.len(), 1);
assert_eq!(batch.entries[0].sequence, 1);
assert_eq!(batch.next_seq, 2);
assert!(!batch.has_more, "gap must set has_more=false");
}
#[test]
fn replay_is_idempotent() {
let conn = test_conn();
use std::cell::Cell;
let call_count = Cell::new(0);
let replay = |c: &Connection| -> Result<(), MemoryError> {
call_count.set(call_count.get() + 1);
c.execute("CREATE TABLE IF NOT EXISTS replayed (id INTEGER)", [])
.map_err(MemoryError::Database)?;
Ok(())
};
let result =
replay_journal_entry(&conn, "device-1", "store-1", 1, "add_fact", b"p1", &replay)
.unwrap();
assert_eq!(result, ReplayOutcome::Applied { sequence: 1 });
assert_eq!(call_count.get(), 1);
let result =
replay_journal_entry(&conn, "device-1", "store-1", 1, "add_fact", b"p1", &replay)
.unwrap();
assert_eq!(result, ReplayOutcome::AlreadyApplied { sequence: 1 });
assert_eq!(call_count.get(), 1, "replay fn must not be called again");
}
#[test]
fn mutate_and_journal_is_atomic() {
let conn = test_conn();
let result = mutate_and_journal(
&conn,
"device-1",
"store-1",
"add_fact",
b"payload",
|_c| -> Result<(), MemoryError> {
Err(MemoryError::Database(rusqlite::Error::InvalidQuery))
},
);
assert!(result.is_err());
let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
assert!(batch.entries.is_empty());
}
#[test]
fn mutate_and_journal_success() {
let conn = test_conn();
let (jid, seq, val) = mutate_and_journal(
&conn,
"device-1",
"store-1",
"add_fact",
b"payload",
|c| -> Result<_, MemoryError> {
c.execute("CREATE TABLE IF NOT EXISTS test_table (x INTEGER)", [])
.map_err(MemoryError::Database)?;
Ok(42)
},
)
.unwrap();
assert_eq!(seq, 1);
assert_eq!(val, 42);
assert!(jid > 0);
let batch = export_contiguous(&conn, "device-1", "store-1", 1, 10).unwrap();
assert_eq!(batch.entries.len(), 1);
assert_eq!(batch.entries[0].operation_kind, "add_fact");
}
}