Skip to main content

appcore_sync_sqlite/
store.rs

1// =============================================================================
2//        #######
3//     ###       ###     F: store.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 store contracts and behavior for this crate.
12
13use crate::integrity::validate_internal_records;
14use crate::schema;
15use crate::{
16    SqliteReplicationLog, SqliteSyncCheckpointStore, SqliteSyncConfig, SqliteSyncError,
17    SqliteSyncOutbox, SqliteSyncResult, SqliteSyncTombstoneStore,
18};
19use appcore_contracts::ProviderId;
20use appcore_storage::{
21    StorageCapabilityDescriptorV1, StorageCapabilityProviderV1, StorageCapabilityV1,
22};
23use parking_lot::{Condvar, Mutex};
24use rusqlite::limits::Limit;
25use rusqlite::{Connection, OpenFlags};
26use std::fmt;
27use std::fs;
28use std::path::Path;
29use std::sync::Arc;
30use std::time::{Duration, Instant};
31
32/// Result of a provider integrity inspection.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct SqliteSyncHealth {
35    /// Internal schema version observed by the provider.
36    pub schema_version: u32,
37    /// Database pages currently allocated.
38    pub page_count: u64,
39    /// Configured maximum page count.
40    pub max_page_count: u64,
41}
42
43struct SqliteSyncInner {
44    pool: Mutex<ConnectionPool>,
45    available: Condvar,
46    config: SqliteSyncConfig,
47}
48
49struct ConnectionPool {
50    idle: Vec<Connection>,
51    total: usize,
52}
53
54struct ConnectionGuard<'a> {
55    inner: &'a SqliteSyncInner,
56    connection: Option<Connection>,
57}
58
59/// Shared owner of one bounded `SQLite` sync database.
60#[derive(Clone)]
61pub struct SqliteSyncStore {
62    inner: Arc<SqliteSyncInner>,
63}
64
65impl SqliteSyncStore {
66    /// Opens, migrates and integrity-checks one provider database.
67    pub fn open(mut config: SqliteSyncConfig) -> SqliteSyncResult<Self> {
68        config.validate()?;
69        config.path = normalize_path(&config.path)?;
70        let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
71            | OpenFlags::SQLITE_OPEN_CREATE
72            | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
73        let mut connection =
74            Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
75        schema::validate_identity(&connection)?;
76        configure(&connection, &config)?;
77        schema::migrate(&mut connection)?;
78        integrity_check(&connection, &config)?;
79        Ok(Self {
80            inner: Arc::new(SqliteSyncInner {
81                pool: Mutex::new(ConnectionPool {
82                    idle: vec![connection],
83                    total: 1,
84                }),
85                available: Condvar::new(),
86                config,
87            }),
88        })
89    }
90
91    /// Runs an integrity check and reports bounded database usage.
92    pub fn health(&self) -> SqliteSyncResult<SqliteSyncHealth> {
93        self.with_connection(|connection| {
94            integrity_check(connection, &self.inner.config)?;
95            let schema_version = connection
96                .pragma_query_value(None, "user_version", |row| row.get(0))
97                .map_err(SqliteSyncError::database)?;
98            let page_count: i64 = connection
99                .pragma_query_value(None, "page_count", |row| row.get(0))
100                .map_err(SqliteSyncError::database)?;
101            let max_page_count: i64 = connection
102                .pragma_query_value(None, "max_page_count", |row| row.get(0))
103                .map_err(SqliteSyncError::database)?;
104            Ok(SqliteSyncHealth {
105                schema_version,
106                page_count: u64::try_from(page_count)
107                    .map_err(|_| SqliteSyncError::CorruptRecord("page count"))?,
108                max_page_count: u64::try_from(max_page_count)
109                    .map_err(|_| SqliteSyncError::CorruptRecord("page limit"))?,
110            })
111        })
112    }
113
114    /// Returns the provider's redacted configuration bounds.
115    pub fn config(&self) -> &SqliteSyncConfig {
116        &self.inner.config
117    }
118
119    /// Creates a replication-log handle backed by this database.
120    pub fn replication_log(&self) -> SqliteReplicationLog {
121        SqliteReplicationLog::new(self.clone())
122    }
123
124    /// Creates a checkpoint-store handle backed by this database.
125    pub fn checkpoint_store(&self) -> SqliteSyncCheckpointStore {
126        SqliteSyncCheckpointStore::new(self.clone())
127    }
128
129    /// Creates an outbox handle backed by this database.
130    pub fn outbox(&self) -> SqliteSyncOutbox {
131        SqliteSyncOutbox::new(self.clone())
132    }
133
134    /// Creates an opaque tombstone-store handle backed by this database.
135    pub fn tombstone_store(&self) -> SqliteSyncTombstoneStore {
136        SqliteSyncTombstoneStore::new(self.clone())
137    }
138
139    pub(crate) fn with_connection<T>(
140        &self,
141        action: impl FnOnce(&mut Connection) -> SqliteSyncResult<T>,
142    ) -> SqliteSyncResult<T> {
143        let mut connection = self.acquire_connection()?;
144        action(connection.connection_mut()?)
145    }
146
147    fn acquire_connection(&self) -> SqliteSyncResult<ConnectionGuard<'_>> {
148        let deadline = Instant::now() + Duration::from_millis(self.inner.config.busy_timeout_ms);
149        let mut pool = self.inner.pool.lock();
150        loop {
151            if let Some(connection) = pool.idle.pop() {
152                return Ok(ConnectionGuard {
153                    inner: &self.inner,
154                    connection: Some(connection),
155                });
156            }
157            if pool.total < self.inner.config.max_connections {
158                pool.total += 1;
159                drop(pool);
160                return match open_connection(&self.inner.config) {
161                    Ok(connection) => Ok(ConnectionGuard {
162                        inner: &self.inner,
163                        connection: Some(connection),
164                    }),
165                    Err(error) => {
166                        let mut pool = self.inner.pool.lock();
167                        pool.total = pool.total.saturating_sub(1);
168                        self.inner.available.notify_one();
169                        Err(error)
170                    }
171                };
172            }
173            let now = Instant::now();
174            if now >= deadline {
175                return Err(SqliteSyncError::CapacityExceeded("connection"));
176            }
177            self.inner.available.wait_for(&mut pool, deadline - now);
178        }
179    }
180}
181
182impl ConnectionGuard<'_> {
183    fn connection_mut(&mut self) -> SqliteSyncResult<&mut Connection> {
184        self.connection
185            .as_mut()
186            .ok_or(SqliteSyncError::DatabaseOperation)
187    }
188}
189
190impl Drop for ConnectionGuard<'_> {
191    fn drop(&mut self) {
192        if let Some(connection) = self.connection.take() {
193            self.inner.pool.lock().idle.push(connection);
194            self.inner.available.notify_one();
195        }
196    }
197}
198
199impl fmt::Debug for SqliteSyncStore {
200    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
201        formatter
202            .debug_struct("SqliteSyncStore")
203            .field("config", &self.inner.config)
204            .finish_non_exhaustive()
205    }
206}
207
208impl StorageCapabilityProviderV1 for SqliteSyncStore {
209    fn storage_capabilities_v1(
210        &self,
211    ) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
212        sqlite_sync_capability_descriptor_v1()
213    }
214}
215
216/// Returns the conservative provider-independent guarantees for `SQLite` sync.
217pub fn sqlite_sync_capability_descriptor_v1(
218) -> Result<StorageCapabilityDescriptorV1, appcore_storage::StorageCapabilityError> {
219    let provider_id = ProviderId::new("sqlite-sync")
220        .map_err(|_| appcore_storage::StorageCapabilityError::InvalidDescriptor)?;
221    Ok(StorageCapabilityDescriptorV1::new(
222        provider_id,
223        [
224            StorageCapabilityV1::Transactions,
225            StorageCapabilityV1::Locking,
226            StorageCapabilityV1::Snapshot,
227            StorageCapabilityV1::OnlineBackup,
228            StorageCapabilityV1::MultiProcess,
229        ],
230    ))
231}
232
233fn configure(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
234    configure_memory(connection)?;
235    connection
236        .busy_timeout(Duration::from_millis(config.busy_timeout_ms))
237        .map_err(SqliteSyncError::database)?;
238    connection
239        .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get::<_, String>(0))
240        .map_err(SqliteSyncError::database)
241        .and_then(|mode| {
242            if mode.eq_ignore_ascii_case("wal") {
243                Ok(())
244            } else {
245                Err(SqliteSyncError::DatabaseOperation)
246            }
247        })?;
248    connection
249        .pragma_update(None, "synchronous", "FULL")
250        .and_then(|_| connection.pragma_update(None, "foreign_keys", true))
251        .and_then(|_| connection.pragma_update(None, "trusted_schema", false))
252        .and_then(|_| connection.pragma_update(None, "wal_autocheckpoint", 1_000))
253        .map_err(SqliteSyncError::database)?;
254    let max_length = i32::try_from(config.max_outbox_record_bytes)
255        .map_err(|_| SqliteSyncError::InvalidConfiguration("outbox record bound overflow"))?;
256    connection
257        .set_limit(Limit::SQLITE_LIMIT_LENGTH, max_length)
258        .and_then(|_| connection.set_limit(Limit::SQLITE_LIMIT_ATTACHED, 0))
259        .and_then(|_| connection.set_limit(Limit::SQLITE_LIMIT_WORKER_THREADS, 0))
260        .map_err(SqliteSyncError::database)?;
261    let page_bytes: i64 = connection
262        .pragma_query_value(None, "page_size", |row| row.get(0))
263        .map_err(SqliteSyncError::database)?;
264    let page_bytes =
265        u64::try_from(page_bytes).map_err(|_| SqliteSyncError::CorruptRecord("page size"))?;
266    if page_bytes == 0 {
267        return Err(SqliteSyncError::CorruptRecord("page size"));
268    }
269    let max_pages = i64::try_from(config.max_database_bytes / page_bytes)
270        .map_err(|_| SqliteSyncError::InvalidConfiguration("database page bound overflow"))?;
271    connection
272        .pragma_update(None, "max_page_count", max_pages)
273        .map_err(SqliteSyncError::database)
274}
275
276fn open_connection(config: &SqliteSyncConfig) -> SqliteSyncResult<Connection> {
277    let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
278        | OpenFlags::SQLITE_OPEN_CREATE
279        | OpenFlags::SQLITE_OPEN_FULL_MUTEX;
280    let connection =
281        Connection::open_with_flags(&config.path, flags).map_err(SqliteSyncError::database)?;
282    configure(&connection, config)?;
283    Ok(connection)
284}
285
286/// Applies connection-local cache policy without a process-global heap limit.
287pub(crate) fn configure_memory(connection: &Connection) -> SqliteSyncResult<()> {
288    // The build may force memory storage even when the PRAGMA reports FILE.
289    // Select FILE where supported without changing process-global temp paths.
290    // Negative cache_size is a KiB target, not a hard SQLite heap ceiling.
291    // Disable file mappings so VFS defaults cannot silently bypass this policy.
292    for (pragma, expected) in [
293        ("cache_size", -2_048_i64),
294        ("mmap_size", 0),
295        ("temp_store", 1),
296    ] {
297        connection
298            .pragma_update(None, pragma, expected)
299            .map_err(SqliteSyncError::database)?;
300        let actual: i64 = connection
301            .pragma_query_value(None, pragma, |row| row.get(0))
302            .map_err(SqliteSyncError::database)?;
303        if actual != expected {
304            return Err(SqliteSyncError::DatabaseOperation);
305        }
306    }
307    Ok(())
308}
309
310fn integrity_check(connection: &Connection, config: &SqliteSyncConfig) -> SqliteSyncResult<()> {
311    let result: String = connection
312        .query_row("PRAGMA quick_check(1)", [], |row| row.get(0))
313        .map_err(SqliteSyncError::database)?;
314    if result != "ok" {
315        return Err(SqliteSyncError::IntegrityFailed);
316    }
317    validate_internal_records(connection, config)
318}
319
320pub(crate) fn normalize_path(path: &Path) -> SqliteSyncResult<std::path::PathBuf> {
321    let file_name = path.file_name().ok_or(SqliteSyncError::UnsafePath)?;
322    match fs::symlink_metadata(path) {
323        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
324            return Err(SqliteSyncError::UnsafePath);
325        }
326        Ok(_) => {}
327        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
328        Err(_) => return Err(SqliteSyncError::UnsafePath),
329    }
330    let parent = path.parent().unwrap_or_else(|| Path::new("."));
331    fs::create_dir_all(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
332    let canonical_parent = fs::canonicalize(parent).map_err(|_| SqliteSyncError::UnsafePath)?;
333    if !canonical_parent.is_dir() {
334        return Err(SqliteSyncError::UnsafePath);
335    }
336    Ok(canonical_parent.join(file_name))
337}
338
339#[cfg(test)]
340mod memory_tests {
341    use super::*;
342
343    #[test]
344    fn every_pooled_connection_and_reopen_apply_memory_policy() {
345        let directory = tempfile::tempdir().unwrap();
346        let config =
347            SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
348        for _ in 0..2 {
349            let store = SqliteSyncStore::open(config.clone()).unwrap();
350            let mut first = store.acquire_connection().unwrap();
351            let mut second = store.acquire_connection().unwrap();
352            for guard in [&mut first, &mut second] {
353                let connection = guard.connection_mut().unwrap();
354                let cache: i64 = connection
355                    .pragma_query_value(None, "cache_size", |row| row.get(0))
356                    .unwrap();
357                let mmap: i64 = connection
358                    .pragma_query_value(None, "mmap_size", |row| row.get(0))
359                    .unwrap();
360                assert_eq!(cache, -2_048);
361                assert_eq!(mmap, 0);
362                let temp: i64 = connection
363                    .pragma_query_value(None, "temp_store", |row| row.get(0))
364                    .unwrap();
365                assert_eq!(temp, 1);
366            }
367        }
368    }
369
370    #[test]
371    fn held_reader_allows_wal_growth_beyond_autocheckpoint_until_release() {
372        use appcore_sync::ReplicationLog;
373        let directory = tempfile::tempdir().unwrap();
374        let config =
375            SqliteSyncConfig::new(directory.path().join("state.db")).with_max_connections(2);
376        let store = SqliteSyncStore::open(config.clone()).unwrap();
377        let mut log = store.replication_log();
378        log.append(vec![0]).unwrap();
379        let mut held = store.acquire_connection().unwrap();
380        let reader = held.connection_mut().unwrap();
381        reader.execute_batch("BEGIN DEFERRED").unwrap();
382        let count: i64 = reader
383            .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
384                row.get(0)
385            })
386            .unwrap();
387        assert_eq!(count, 1);
388        for sequence in 1..=8 {
389            log.append_with_sequence(vec![sequence as u8; 1024 * 1024], sequence)
390                .unwrap();
391        }
392        let (frames, checkpointed) = store
393            .with_connection(|writer| {
394                let threshold: i64 = writer
395                    .pragma_query_value(None, "wal_autocheckpoint", |row| row.get(0))
396                    .map_err(SqliteSyncError::database)?;
397                assert_eq!(threshold, 1_000);
398                writer
399                    .query_row("PRAGMA wal_checkpoint(PASSIVE)", [], |row| {
400                        Ok((row.get::<_, i64>(1)?, row.get::<_, i64>(2)?))
401                    })
402                    .map_err(SqliteSyncError::database)
403            })
404            .unwrap();
405        assert!(frames > 1_000);
406        assert!(checkpointed < frames);
407        let count: i64 = reader
408            .query_row("SELECT COUNT(*) FROM appcore_replication_log", [], |row| {
409                row.get(0)
410            })
411            .unwrap();
412        assert_eq!(count, 1);
413        reader.execute_batch("ROLLBACK").unwrap();
414        drop(held);
415        store
416            .with_connection(|connection| {
417                let result: (i64, i64, i64) = connection
418                    .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |row| {
419                        Ok((row.get(0)?, row.get(1)?, row.get(2)?))
420                    })
421                    .map_err(SqliteSyncError::database)?;
422                assert_eq!(result, (0, 0, 0));
423                Ok(())
424            })
425            .unwrap();
426        assert_eq!(log.len().unwrap(), 9);
427        drop(log);
428        drop(store);
429        let reopened = SqliteSyncStore::open(config).unwrap();
430        assert_eq!(reopened.replication_log().len().unwrap(), 9);
431    }
432}