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