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                let index = append_in_transaction(&transaction, &payload, sequence)?;
98                transaction.commit().map_err(SqliteSyncError::database)?;
99                Ok(index)
100            })
101            .map_err(|error| match error {
102                SqliteSyncError::CorruptRecord("sequence conflict") => {
103                    SyncError::SequenceConflict(sequence)
104                }
105                other => other.sync(),
106            })
107    }
108
109    fn snapshot_records(&self) -> SyncResult<Vec<(u64, Vec<u8>)>> {
110        self.store
111            .with_connection(|connection| {
112                let mut statement = connection
113                    .prepare(
114                        "SELECT source_sequence, payload FROM appcore_replication_log
115                         ORDER BY log_index",
116                    )
117                    .map_err(SqliteSyncError::database)?;
118                let rows = statement
119                    .query_map([], |row| {
120                        Ok((row.get::<_, i64>(0)?, row.get::<_, Vec<u8>>(1)?))
121                    })
122                    .map_err(SqliteSyncError::database)?;
123                let mut records = Vec::new();
124                let mut bytes = 0usize;
125                for row in rows {
126                    let (sequence, payload) = row.map_err(SqliteSyncError::database)?;
127                    bytes = bytes
128                        .checked_add(payload.len())
129                        .ok_or(SqliteSyncError::CapacityExceeded("snapshot byte"))?;
130                    if bytes as u64 > self.store.config().max_database_bytes {
131                        return Err(SqliteSyncError::CapacityExceeded("snapshot byte"));
132                    }
133                    records.push((
134                        u64::try_from(sequence)
135                            .map_err(|_| SqliteSyncError::CorruptRecord("sequence"))?,
136                        payload,
137                    ));
138                }
139                Ok(records)
140            })
141            .map_err(SqliteSyncError::sync)
142    }
143}
144
145fn validate_page_bytes(
146    transaction: &Transaction<'_>,
147    start: i64,
148    limit: i64,
149    max_bytes: usize,
150) -> crate::SqliteSyncResult<usize> {
151    // Both passes share a read transaction: concurrent replacement cannot
152    // change the admitted lengths before payload materialization.
153    let mut statement = transaction
154        .prepare(
155            "SELECT length(payload) FROM appcore_replication_log
156         WHERE log_index > ?1 ORDER BY log_index LIMIT ?2",
157        )
158        .map_err(SqliteSyncError::database)?;
159    let rows = statement
160        .query_map(params![start, limit], |row| row.get::<_, i64>(0))
161        .map_err(SqliteSyncError::database)?;
162    let mut bytes = 0usize;
163    let mut count = 0usize;
164    for row in rows {
165        let length = usize::try_from(row.map_err(SqliteSyncError::database)?)
166            .map_err(|_| SqliteSyncError::CorruptRecord("payload length"))?;
167        bytes = bytes
168            .checked_add(length)
169            .ok_or(SqliteSyncError::CapacityExceeded("read byte"))?;
170        if bytes > max_bytes {
171            return Err(SqliteSyncError::CapacityExceeded("read byte"));
172        }
173        count += 1;
174    }
175    Ok(count)
176}
177
178impl ReplicationLog for SqliteReplicationLog {
179    fn append(&mut self, record: Vec<u8>) -> SyncResult<usize> {
180        self.append_record(record, 0)
181    }
182
183    fn append_with_sequence(&mut self, record: Vec<u8>, sequence: u64) -> SyncResult<usize> {
184        self.append_record(record, sequence)
185    }
186
187    fn event_at_sequence(&self, sequence: u64) -> SyncResult<Option<Vec<u8>>> {
188        if sequence == 0 {
189            return Ok(None);
190        }
191        self.store
192            .with_connection(|connection| {
193                connection
194                    .query_row(
195                        "SELECT payload FROM appcore_replication_log WHERE source_sequence = ?1",
196                        [sequence_to_i64(sequence)?],
197                        |row| row.get(0),
198                    )
199                    .optional()
200                    .map_err(SqliteSyncError::database)
201            })
202            .map_err(SqliteSyncError::sync)
203    }
204
205    fn events_since(&self, index: usize) -> SyncResult<Vec<Vec<u8>>> {
206        let length = self.len()?;
207        if length.saturating_sub(index) > self.store.config().max_read_records {
208            return Err(capacity_error("read record"));
209        }
210        self.events_page(index, self.store.config().max_read_records)
211    }
212
213    fn events_page(
214        &self,
215        index: usize,
216        max_records: usize,
217        max_bytes: usize,
218    ) -> SyncResult<Vec<Vec<u8>>> {
219        self.read_events_page(index, max_records, max_bytes)
220    }
221
222    fn last_index(&self) -> SyncResult<usize> {
223        self.len()
224    }
225
226    fn len(&self) -> SyncResult<usize> {
227        self.store
228            .with_connection(|connection| count_records(connection))
229            .map_err(SqliteSyncError::sync)
230    }
231
232    fn is_empty(&self) -> SyncResult<bool> {
233        self.len().map(|length| length == 0)
234    }
235
236    fn create_snapshot(&self) -> SyncResult<ReplicationSnapshot> {
237        ReplicationSnapshot::try_from_records(self.snapshot_records()?)
238    }
239
240    fn restore_snapshot(&mut self, snapshot: &ReplicationSnapshot) -> SyncResult<()> {
241        snapshot.validate()?;
242        validate_snapshot_bytes(snapshot, self.store.config().max_database_bytes)?;
243        self.store
244            .with_connection(|connection| {
245                let transaction = connection
246                    .transaction_with_behavior(TransactionBehavior::Immediate)
247                    .map_err(SqliteSyncError::database)?;
248                transaction
249                    .execute("DELETE FROM appcore_replication_log", [])
250                    .map_err(SqliteSyncError::database)?;
251                let mut previous_hash = String::new();
252                for (offset, record) in snapshot.records.iter().enumerate() {
253                    let hash = record_hash(&previous_hash, record.sequence, &record.payload);
254                    transaction
255                        .execute(
256                            "INSERT INTO appcore_replication_log
257                             (log_index, source_sequence, payload, previous_hash, record_hash)
258                             VALUES (?1, ?2, ?3, ?4, ?5)",
259                            params![
260                                i64::try_from(offset + 1).map_err(|_| {
261                                    SqliteSyncError::CapacityExceeded("log index")
262                                })?,
263                                sequence_to_i64(record.sequence)?,
264                                &record.payload,
265                                previous_hash,
266                                hash
267                            ],
268                        )
269                        .map_err(SqliteSyncError::database)?;
270                    previous_hash = hash;
271                }
272                transaction.commit().map_err(SqliteSyncError::database)
273            })
274            .map_err(SqliteSyncError::sync)
275    }
276}
277
278fn existing_sequence(
279    transaction: &Transaction<'_>,
280    sequence: u64,
281) -> Result<Option<(usize, Vec<u8>)>, SqliteSyncError> {
282    if sequence == 0 {
283        return Ok(None);
284    }
285    transaction
286        .query_row(
287            "SELECT log_index, CASE WHEN length(payload) <= 1048576 THEN payload ELSE NULL END FROM appcore_replication_log WHERE source_sequence = ?1",
288            [sequence_to_i64(sequence)?],
289            |row| Ok((row.get::<_, i64>(0)?, row.get(1)?)),
290        )
291        .optional()
292        .map_err(SqliteSyncError::database)?
293        .map(|(index, payload)| {
294            usize::try_from(index)
295                .map(|index| (index, payload))
296                .map_err(|_| SqliteSyncError::CorruptRecord("log index"))
297        })
298        .transpose()
299}
300
301fn count_records(connection: &rusqlite::Connection) -> Result<usize, SqliteSyncError> {
302    let count: i64 = connection
303        .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
304            row.get(0)
305        })
306        .map_err(SqliteSyncError::database)?;
307    usize::try_from(count).map_err(|_| SqliteSyncError::CorruptRecord("log count"))
308}
309
310fn validate_payload(payload: &[u8]) -> SyncResult<()> {
311    if payload.len() > MAX_REPLICATION_RECORD_BYTES {
312        return Err(capacity_error("replication record"));
313    }
314    Ok(())
315}
316
317fn validate_snapshot_bytes(snapshot: &ReplicationSnapshot, max_bytes: u64) -> SyncResult<()> {
318    let mut bytes = 0u64;
319    for record in &snapshot.records {
320        bytes = bytes
321            .checked_add(u64::try_from(record.payload.len()).unwrap_or(u64::MAX))
322            .ok_or_else(|| capacity_error("snapshot byte"))?;
323        if bytes > max_bytes {
324            return Err(capacity_error("snapshot byte"));
325        }
326    }
327    Ok(())
328}
329
330fn sequence_to_i64(sequence: u64) -> Result<i64, SqliteSyncError> {
331    i64::try_from(sequence).map_err(|_| SqliteSyncError::CapacityExceeded("sequence"))
332}
333
334fn capacity_error(resource: &'static str) -> SyncError {
335    SqliteSyncError::CapacityExceeded(resource).sync()
336}
337
338pub(crate) fn record_hash(previous_hash: &str, sequence: u64, payload: &[u8]) -> String {
339    let mut hasher = Sha256::new();
340    hasher.update(REPLICATION_LOG_FORMAT_V1.as_bytes());
341    hasher.update((previous_hash.len() as u64).to_be_bytes());
342    hasher.update(previous_hash.as_bytes());
343    hasher.update(sequence.to_be_bytes());
344    hasher.update((payload.len() as u64).to_be_bytes());
345    hasher.update(payload);
346    let digest = hasher.finalize();
347    let mut output = String::with_capacity(digest.len() * 2);
348    for byte in digest {
349        use std::fmt::Write as _;
350        let _ = write!(output, "{byte:02x}");
351    }
352    output
353}
354
355/// Appends on the caller-owned transaction without committing it.
356pub(crate) fn append_in_transaction(
357    transaction: &Transaction<'_>,
358    payload: &[u8],
359    sequence: u64,
360) -> crate::SqliteSyncResult<usize> {
361    validate_payload(payload).map_err(|_| SqliteSyncError::CapacityExceeded("log record"))?;
362    if let Some(existing) = existing_sequence(transaction, sequence)? {
363        return if existing.1 == payload {
364            Ok(existing.0)
365        } else {
366            Err(SqliteSyncError::CorruptRecord("sequence conflict"))
367        };
368    }
369    let previous_hash: String = transaction
370        .query_row(
371            "SELECT record_hash FROM appcore_replication_log
372                         ORDER BY log_index DESC LIMIT 1",
373            [],
374            |row| row.get(0),
375        )
376        .optional()
377        .map_err(SqliteSyncError::database)?
378        .unwrap_or_default();
379    let record_hash = record_hash(&previous_hash, sequence, payload);
380    transaction
381        .execute(
382            "INSERT INTO appcore_replication_log
383                         (source_sequence, payload, previous_hash, record_hash)
384                         VALUES (?1, ?2, ?3, ?4)",
385            params![
386                sequence_to_i64(sequence)?,
387                payload,
388                previous_hash,
389                record_hash
390            ],
391        )
392        .map_err(SqliteSyncError::database)?;
393    let index = usize::try_from(transaction.last_insert_rowid())
394        .map_err(|_| SqliteSyncError::CapacityExceeded("log index"))?;
395    Ok(index)
396}