coven/sync/
apply.rs

1/// Apply a changeset to a database with the production conflict handler.
2///
3/// Within a single changeset, SQLite defers FK checks -- parent and child
4/// rows in the same changeset are applied in recording order. Cross-changeset
5/// FK dependencies are handled by applying changesets in seq order (parents
6/// are always in earlier changesets than children).
7///
8/// If a FK violation remains after applying a changeset, the conflict handler
9/// reports it via `FOREIGN_KEY` type and the tracker notes it for the caller.
10///
11/// For `release_files` DATA conflicts where incoming wins, the local
12/// `encryption_nonce` value is restored after applying because that column
13/// is device-specific.
14use std::collections::HashMap;
15use std::ffi::{c_char, c_int, CStr, CString};
16use std::ptr;
17
18use libsqlite3_sys as ffi;
19
20use super::conflict::{lww_conflict_handler, ConflictTracker, TableSchema};
21use super::session::SyncError;
22use super::session_ext::{apply_changeset_with_context, Changeset};
23
24/// Result of applying a changeset.
25pub struct ApplyResult {
26    /// True if any FK constraint violations were reported. The caller may
27    /// want to retry this changeset after applying other changesets that
28    /// contain the missing parent rows.
29    pub had_fk_violations: bool,
30}
31
32/// Snapshot of device-specific columns for a single release_files row.
33struct DeviceLocalSnapshot {
34    /// Local `encryption_nonce` as raw bytes (None means NULL).
35    encryption_nonce: Option<Vec<u8>>,
36}
37
38/// Apply a changeset to the given database connection using LWW conflict
39/// resolution.
40///
41/// Builds schema info from the database to look up `_updated_at` column
42/// indices dynamically, so future migrations that add columns are safe.
43///
44/// # Safety
45/// `db` must be a valid, open sqlite3 connection pointer.
46pub unsafe fn apply_changeset_lww(
47    db: *mut ffi::sqlite3,
48    changeset: &Changeset,
49) -> Result<ApplyResult, SyncError> {
50    let synced = super::session::synced_tables();
51    let table_refs: Vec<&str> = synced.iter().map(String::as_str).collect();
52    let schema = TableSchema::from_db(db, &table_refs);
53    let mut tracker = ConflictTracker::new();
54
55    // Snapshot device-specific columns from all existing release_files rows.
56    // This is read before applying so we have the original local values
57    // regardless of what the changeset overwrites.
58    let snapshots = snapshot_device_local_columns(db);
59
60    apply_changeset_with_context(db, changeset, |ct, ctx| {
61        lww_conflict_handler(ct, ctx, &schema, &mut tracker)
62    })
63    .map_err(SyncError::ChangesetApply)?;
64
65    // Restore device-specific columns on release_files rows where incoming won.
66    for row_id in &tracker.release_file_restore_ids {
67        if let Some(snap) = snapshots.get(row_id.as_str()) {
68            restore_device_local_columns(db, row_id, snap);
69        }
70    }
71
72    Ok(ApplyResult {
73        had_fk_violations: tracker.had_constraint_conflict,
74    })
75}
76
77/// Read `encryption_nonce` for all existing release_files rows.
78unsafe fn snapshot_device_local_columns(
79    db: *mut ffi::sqlite3,
80) -> HashMap<String, DeviceLocalSnapshot> {
81    let mut map = HashMap::new();
82
83    let sql = "SELECT id, encryption_nonce FROM release_files";
84    let c_sql = CString::new(sql).unwrap();
85    let mut stmt: *mut ffi::sqlite3_stmt = ptr::null_mut();
86    let rc = ffi::sqlite3_prepare_v2(db, c_sql.as_ptr(), -1, &mut stmt, ptr::null_mut());
87
88    // Table might not exist yet (e.g. in a partial test schema).
89    // In that case, just return an empty map.
90    if rc != ffi::SQLITE_OK as c_int {
91        return map;
92    }
93
94    while ffi::sqlite3_step(stmt) == ffi::SQLITE_ROW as c_int {
95        // Column 0: id (TEXT)
96        let id_ptr = ffi::sqlite3_column_text(stmt, 0);
97        if id_ptr.is_null() {
98            continue;
99        }
100        let id = CStr::from_ptr(id_ptr as *const c_char)
101            .to_str()
102            .expect("SQLite text columns are always UTF-8")
103            .to_string();
104
105        // Column 1: encryption_nonce (BLOB, nullable)
106        let encryption_nonce = if ffi::sqlite3_column_type(stmt, 1) == ffi::SQLITE_NULL as c_int {
107            None
108        } else {
109            let blob_ptr = ffi::sqlite3_column_blob(stmt, 1);
110            let blob_len = ffi::sqlite3_column_bytes(stmt, 1) as usize;
111            if blob_ptr.is_null() || blob_len == 0 {
112                None
113            } else {
114                let slice = std::slice::from_raw_parts(blob_ptr as *const u8, blob_len);
115                Some(slice.to_vec())
116            }
117        };
118
119        map.insert(id, DeviceLocalSnapshot { encryption_nonce });
120    }
121
122    ffi::sqlite3_finalize(stmt);
123    map
124}
125
126/// Restore local `encryption_nonce` on a release_files row after an incoming
127/// changeset overwrote it.
128unsafe fn restore_device_local_columns(
129    db: *mut ffi::sqlite3,
130    row_id: &str,
131    snap: &DeviceLocalSnapshot,
132) {
133    let sql = "UPDATE release_files SET encryption_nonce = ?1 WHERE id = ?2";
134    let c_sql = CString::new(sql).unwrap();
135    let mut stmt: *mut ffi::sqlite3_stmt = ptr::null_mut();
136    let rc = ffi::sqlite3_prepare_v2(db, c_sql.as_ptr(), -1, &mut stmt, ptr::null_mut());
137    assert_eq!(
138        rc,
139        ffi::SQLITE_OK as c_int,
140        "prepare restore_device_local_columns failed"
141    );
142
143    // Bind encryption_nonce (param 1) as BLOB
144    match &snap.encryption_nonce {
145        Some(bytes) => {
146            ffi::sqlite3_bind_blob(
147                stmt,
148                1,
149                bytes.as_ptr() as *const _,
150                bytes.len() as c_int,
151                ffi::SQLITE_TRANSIENT(),
152            );
153        }
154        None => {
155            ffi::sqlite3_bind_null(stmt, 1);
156        }
157    }
158
159    // Bind row id (param 2)
160    let c_id = CString::new(row_id).unwrap();
161    ffi::sqlite3_bind_text(
162        stmt,
163        2,
164        c_id.as_ptr(),
165        row_id.len() as c_int,
166        ffi::SQLITE_TRANSIENT(),
167    );
168
169    let step = ffi::sqlite3_step(stmt);
170    assert_eq!(
171        step,
172        ffi::SQLITE_DONE as c_int,
173        "restore_device_local_columns step failed"
174    );
175
176    ffi::sqlite3_finalize(stmt);
177}