Skip to main content

appcore_sync_sqlite/
log.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: log.rs
4//    ##   ## ##   ##    P: AppCore-Runtime
5//         ## ##
6//                       C: 2026/08/26 00:00:00 by dnettoRaw
7//    ##   ## ##   ##    U: 2026/08/26 00:00:00 by dnettoRaw
8//      ###########      S: 2.0.0
9// =============================================================================
10
11//! Defines bounded log contracts and behavior for this crate.
12
13use crate::{SqliteSyncError, SqliteSyncStore};
14use appcore_sync::{
15    ReplicationLog, ReplicationSnapshot, SyncError, SyncResult, MAX_REPLICATION_PAGE_BYTES,
16    MAX_REPLICATION_PAGE_RECORDS, REPLICATION_LOG_FORMAT_V1,
17};
18use rusqlite::{params, OptionalExtension, Transaction, TransactionBehavior};
19use sha2::{Digest, Sha256};
20
21pub(crate) const MAX_REPLICATION_RECORD_BYTES: usize = 1024 * 1024;
22
23/// SQLite-backed implementation of the `AppCore` replication-log contract.
24#[derive(Debug, Clone)]
25pub struct SqliteReplicationLog {
26    store: SqliteSyncStore,
27}
28
29impl SqliteReplicationLog {
30    pub(crate) fn new(store: SqliteSyncStore) -> Self {
31        Self { store }
32    }
33
34    /// Reads one bounded page after the supplied zero-based log offset.
35    pub fn events_page(&self, index: usize, max_records: usize) -> SyncResult<Vec<Vec<u8>>> {
36        self.read_events_page(index, max_records, self.store.config().max_read_bytes)
37    }
38
39    fn read_events_page(
40        &self,
41        index: usize,
42        max_records: usize,
43        max_bytes: usize,
44    ) -> SyncResult<Vec<Vec<u8>>> {
45        if max_records == 0
46            || max_records > self.store.config().max_read_records
47            || max_records > MAX_REPLICATION_PAGE_RECORDS
48        {
49            return Err(capacity_error("read record"));
50        }
51        if max_bytes == 0
52            || max_bytes > self.store.config().max_read_bytes
53            || max_bytes > MAX_REPLICATION_PAGE_BYTES
54        {
55            return Err(capacity_error("read byte"));
56        }
57        self.store
58            .with_connection(|connection| {
59                let transaction = connection
60                    .transaction_with_behavior(TransactionBehavior::Deferred)
61                    .map_err(SqliteSyncError::database)?;
62                let length = count_records(&transaction)?;
63                if index > length {
64                    return Err(SqliteSyncError::CorruptRecord("log index"));
65                }
66                let start = i64::try_from(index)
67                    .map_err(|_| SqliteSyncError::CapacityExceeded("log index"))?;
68                let limit = i64::try_from(max_records)
69                    .map_err(|_| SqliteSyncError::CapacityExceeded("read record"))?;
70                let admitted = validate_page_bytes(&transaction, start, limit, max_bytes)?;
71                let mut statement = transaction
72                    .prepare(
73                        "SELECT payload FROM appcore_replication_log
74                         WHERE log_index > ?1 ORDER BY log_index LIMIT ?2",
75                    )
76                    .map_err(SqliteSyncError::database)?;
77                let rows = statement
78                    .query_map(params![start, limit], |row| row.get::<_, Vec<u8>>(0))
79                    .map_err(SqliteSyncError::database)?;
80                let mut payloads = Vec::with_capacity(admitted);
81                for row in rows {
82                    let payload = row.map_err(SqliteSyncError::database)?;
83                    payloads.push(payload);
84                }
85                Ok(payloads)
86            })
87            .map_err(SqliteSyncError::sync)
88    }
89
90    fn append_record(&self, payload: Vec<u8>, sequence: u64) -> SyncResult<usize> {
91        validate_payload(&payload)?;
92        self.store
93            .with_connection(|connection| {
94                let transaction = connection
95                    .transaction_with_behavior(TransactionBehavior::Immediate)
96                    .map_err(SqliteSyncError::database)?;
97                if let Some(existing) = existing_sequence(&transaction, sequence)? {
98                    return if existing.1 == payload {
99                        Ok(existing.0)
100                    } else {
101                        Err(SqliteSyncError::CorruptRecord("sequence conflict"))
102                    };
103                }
104                let previous_hash: String = transaction
105                    .query_row(
106                        "SELECT record_hash FROM appcore_replication_log
107                         ORDER BY log_index DESC LIMIT 1",
108                        [],
109                        |row| row.get(0),
110                    )
111                    .optional()
112                    .map_err(SqliteSyncError::database)?
113                    .unwrap_or_default();
114                let record_hash = record_hash(&previous_hash, sequence, &payload);
115                transaction
116                    .execute(
117                        "INSERT INTO appcore_replication_log
118                         (source_sequence, payload, previous_hash, record_hash)
119                         VALUES (?1, ?2, ?3, ?4)",
120                        params![
121                            sequence_to_i64(sequence)?,
122                            payload,
123                            previous_hash,
124                            record_hash
125                        ],
126                    )
127                    .map_err(SqliteSyncError::database)?;
128                let index = usize::try_from(transaction.last_insert_rowid())
129                    .map_err(|_| SqliteSyncError::CapacityExceeded("log index"))?;
130                transaction.commit().map_err(SqliteSyncError::database)?;
131                Ok(index)
132            })
133            .map_err(|error| match error {
134                SqliteSyncError::CorruptRecord("sequence conflict") => {
135                    SyncError::SequenceConflict(sequence)
136                }
137                other => other.sync(),
138            })
139    }
140
141    fn snapshot_records(&self) -> SyncResult<Vec<(u64, Vec<u8>)>> {
142        self.store
143            .with_connection(|connection| {
144                let mut statement = connection
145                    .prepare(
146                        "SELECT source_sequence, payload FROM appcore_replication_log
147                         ORDER BY log_index",
148                    )
149                    .map_err(SqliteSyncError::database)?;
150                let rows = statement
151                    .query_map([], |row| {
152                        Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
153                    })
154                    .map_err(SqliteSyncError::database)?;
155                let mut records = Vec::new();
156                let mut bytes = 0usize;
157                for row in rows {
158                    let (sequence, payload) = row.map_err(SqliteSyncError::database)?;
159                    bytes = bytes
160                        .checked_add(payload.len())
161                        .ok_or(SqliteSyncError::CapacityExceeded("snapshot byte"))?;
162                    if bytes as u64 > self.store.config().max_database_bytes {
163                        return Err(SqliteSyncError::CapacityExceeded("snapshot byte"));
164                    }
165                    records.push((
166                        u64::try_from(sequence)
167                            .map_err(|_| SqliteSyncError::CorruptRecord("sequence"))?,
168                        payload,
169                    ));
170                }
171                Ok(records)
172            })
173            .map_err(SqliteSyncError::sync)
174    }
175}
176
177fn validate_page_bytes(
178    transaction: &Transaction<'_>,
179    start: i64,
180    limit: i64,
181    max_bytes: usize,
182) -> crate::SqliteSyncResult<usize> {
183    // Both passes share a read transaction: concurrent replacement cannot
184    // change the admitted lengths before payload materialization.
185    let mut statement = transaction
186        .prepare(
187            "SELECT length(payload) FROM appcore_replication_log
188         WHERE log_index > ?1 ORDER BY log_index LIMIT ?2",
189        )
190        .map_err(SqliteSyncError::database)?;
191    let rows = statement
192        .query_map(params![start, limit], |row| row.get::<_, i64>(0))
193        .map_err(SqliteSyncError::database)?;
194    let mut bytes = 0usize;
195    let mut count = 0usize;
196    for row in rows {
197        let length = usize::try_from(row.map_err(SqliteSyncError::database)?)
198            .map_err(|_| SqliteSyncError::CorruptRecord("payload length"))?;
199        bytes = bytes
200            .checked_add(length)
201            .ok_or(SqliteSyncError::CapacityExceeded("read byte"))?;
202        if bytes > max_bytes {
203            return Err(SqliteSyncError::CapacityExceeded("read byte"));
204        }
205        count += 1;
206    }
207    Ok(count)
208}
209
210impl ReplicationLog for SqliteReplicationLog {
211    fn append(&mut self, record: Vec<u8>) -> SyncResult<usize> {
212        self.append_record(record, 0)
213    }
214
215    fn append_with_sequence(&mut self, record: Vec<u8>, sequence: u64) -> SyncResult<usize> {
216        self.append_record(record, sequence)
217    }
218
219    fn event_at_sequence(&self, sequence: u64) -> SyncResult<Option<Vec<u8>>> {
220        if sequence == 0 {
221            return Ok(None);
222        }
223        self.store
224            .with_connection(|connection| {
225                connection
226                    .query_row(
227                        "SELECT payload FROM appcore_replication_log WHERE source_sequence = ?1",
228                        [sequence_to_i64(sequence)?],
229                        |row| row.get(0),
230                    )
231                    .optional()
232                    .map_err(SqliteSyncError::database)
233            })
234            .map_err(SqliteSyncError::sync)
235    }
236
237    fn events_since(&self, index: usize) -> SyncResult<Vec<Vec<u8>>> {
238        let length = self.len()?;
239        if length.saturating_sub(index) > self.store.config().max_read_records {
240            return Err(capacity_error("read record"));
241        }
242        self.events_page(index, self.store.config().max_read_records)
243    }
244
245    fn events_page(
246        &self,
247        index: usize,
248        max_records: usize,
249        max_bytes: usize,
250    ) -> SyncResult<Vec<Vec<u8>>> {
251        self.read_events_page(index, max_records, max_bytes)
252    }
253
254    fn last_index(&self) -> SyncResult<usize> {
255        self.len()
256    }
257
258    fn len(&self) -> SyncResult<usize> {
259        self.store
260            .with_connection(|connection| count_records(connection))
261            .map_err(SqliteSyncError::sync)
262    }
263
264    fn is_empty(&self) -> SyncResult<bool> {
265        self.len().map(|length| length == 0)
266    }
267
268    fn create_snapshot(&self) -> SyncResult<ReplicationSnapshot> {
269        ReplicationSnapshot::try_from_records(self.snapshot_records()?)
270    }
271
272    fn restore_snapshot(&mut self, snapshot: &ReplicationSnapshot) -> SyncResult<()> {
273        snapshot.validate()?;
274        validate_snapshot_bytes(snapshot, self.store.config().max_database_bytes)?;
275        self.store
276            .with_connection(|connection| {
277                let transaction = connection
278                    .transaction_with_behavior(TransactionBehavior::Immediate)
279                    .map_err(SqliteSyncError::database)?;
280                transaction
281                    .execute("DELETE FROM appcore_replication_log", [])
282                    .map_err(SqliteSyncError::database)?;
283                let mut previous_hash = String::new();
284                for (offset, record) in snapshot.records.iter().enumerate() {
285                    let hash = record_hash(&previous_hash, record.sequence, &record.payload);
286                    transaction
287                        .execute(
288                            "INSERT INTO appcore_replication_log
289                             (log_index, source_sequence, payload, previous_hash, record_hash)
290                             VALUES (?1, ?2, ?3, ?4, ?5)",
291                            params![
292                                i64::try_from(offset + 1).map_err(|_| {
293                                    SqliteSyncError::CapacityExceeded("log index")
294                                })?,
295                                sequence_to_i64(record.sequence)?,
296                                &record.payload,
297                                previous_hash,
298                                hash
299                            ],
300                        )
301                        .map_err(SqliteSyncError::database)?;
302                    previous_hash = hash;
303                }
304                transaction.commit().map_err(SqliteSyncError::database)
305            })
306            .map_err(SqliteSyncError::sync)
307    }
308}
309
310fn existing_sequence(
311    transaction: &Transaction<'_>,
312    sequence: u64,
313) -> Result<Option<(usize, Vec<u8>)>, SqliteSyncError> {
314    if sequence == 0 {
315        return Ok(None);
316    }
317    transaction
318        .query_row(
319            "SELECT log_index, payload FROM appcore_replication_log WHERE source_sequence = ?1",
320            [sequence_to_i64(sequence)?],
321            |row| Ok((row.get::<_, i64>(0)?, row.get(1)?)),
322        )
323        .optional()
324        .map_err(SqliteSyncError::database)?
325        .map(|(index, payload)| {
326            usize::try_from(index)
327                .map(|index| (index, payload))
328                .map_err(|_| SqliteSyncError::CorruptRecord("log index"))
329        })
330        .transpose()
331}
332
333fn count_records(connection: &rusqlite::Connection) -> Result<usize, SqliteSyncError> {
334    let count: i64 = connection
335        .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
336            row.get(0)
337        })
338        .map_err(SqliteSyncError::database)?;
339    usize::try_from(count).map_err(|_| SqliteSyncError::CorruptRecord("log count"))
340}
341
342fn validate_payload(payload: &[u8]) -> SyncResult<()> {
343    if payload.len() > MAX_REPLICATION_RECORD_BYTES {
344        return Err(capacity_error("replication record"));
345    }
346    Ok(())
347}
348
349fn validate_snapshot_bytes(snapshot: &ReplicationSnapshot, max_bytes: u64) -> SyncResult<()> {
350    let mut bytes = 0u64;
351    for record in &snapshot.records {
352        bytes = bytes
353            .checked_add(u64::try_from(record.payload.len()).unwrap_or(u64::MAX))
354            .ok_or_else(|| capacity_error("snapshot byte"))?;
355        if bytes > max_bytes {
356            return Err(capacity_error("snapshot byte"));
357        }
358    }
359    Ok(())
360}
361
362fn sequence_to_i64(sequence: u64) -> Result<i64, SqliteSyncError> {
363    i64::try_from(sequence).map_err(|_| SqliteSyncError::CapacityExceeded("sequence"))
364}
365
366fn capacity_error(resource: &'static str) -> SyncError {
367    SqliteSyncError::CapacityExceeded(resource).sync()
368}
369
370pub(crate) fn record_hash(previous_hash: &str, sequence: u64, payload: &[u8]) -> String {
371    let mut hasher = Sha256::new();
372    hasher.update(REPLICATION_LOG_FORMAT_V1.as_bytes());
373    hasher.update((previous_hash.len() as u64).to_be_bytes());
374    hasher.update(previous_hash.as_bytes());
375    hasher.update(sequence.to_be_bytes());
376    hasher.update((payload.len() as u64).to_be_bytes());
377    hasher.update(payload);
378    let digest = hasher.finalize();
379    let mut output = String::with_capacity(digest.len() * 2);
380    for byte in digest {
381        use std::fmt::Write as _;
382        let _ = write!(output, "{byte:02x}");
383    }
384    output
385}