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