Skip to main content

coven_database/store/store_session/merge_materialization_transaction/
changeset_application.rs

1//! Apply a changeset to the connection, resolving conflicts as each row lands.
2//!
3//! Prepare column merges in the changeset before applying any live rows. When
4//! an incoming UPDATE loses row arbitration, preserve columns it changed from a
5//! base the local row still holds, together with the local winning values and
6//! timestamp. SQLite applies the combined changeset as one operation, including
7//! constraint retries. Other collisions use `arbitrate_row_conflict`.
8//!
9//! Captured row effects include their synced trigger and foreign-key effects.
10//! Native constraint retries must not run those actions again: an UPDATE cycle
11//! may temporarily delete and reinsert a parent while its children survive.
12//! The production materializer validates foreign keys after its whole atomic
13//! replay step; the test wrapper also reports unresolved foreign keys so isolated
14//! changeset tests can roll back. The canonical scheduler handles dependencies
15//! between changesets. A non-FK
16//! constraint conflict marks the whole changeset rejected; the caller rolls its
17//! transaction back instead of committing the rows that happened not to conflict.
18
19use std::borrow::Cow;
20use std::collections::HashSet;
21use std::sync::{Arc, Mutex};
22
23use fallible_streaming_iterator::FallibleStreamingIterator;
24use rusqlite::hooks::Action;
25use rusqlite::session::{ChangesetItem, ChangesetIter, ConflictAction, ConflictType};
26use rusqlite::types::{Value, ValueRef};
27use rusqlite::{Connection, OptionalExtension};
28use tracing::warn;
29
30use super::conflict::{
31    arbitrate_row_conflict, compare_lww_stamps, IncomingTimestampPolicy, LwwComparison, TableSchema,
32};
33use crate::changeset::{value_ref_to_string, UpdateValue};
34use crate::changeset_identity::validate_changeset_row_identities;
35use crate::gate::Changegroup;
36use crate::store::store_session::replay_sql::ReplaySql;
37use crate::{quote_ident, ChangesetIdentityError, DbError};
38use coven_protocol::hlc::Timestamp;
39#[cfg(any(test, feature = "test-utils"))]
40use coven_protocol::synced_schema::SyncedTable;
41
42use super::MergeMaterializationTransaction;
43
44#[path = "recorded_changeset.rs"]
45mod recorded_changeset;
46#[cfg(test)]
47#[path = "recorded_changeset_tests.rs"]
48mod recorded_changeset_tests;
49#[cfg(test)]
50#[path = "three_way_tests.rs"]
51mod three_way_tests;
52
53/// Result of applying a changeset.
54#[cfg_attr(
55    not(any(test, feature = "test-utils")),
56    allow(unreachable_pub),
57    doc = "Public when the `test-utils` feature exposes changeset application."
58)]
59pub struct ApplyResult {
60    /// True if the resulting database has unresolved foreign keys. The caller
61    /// may retry after applying changesets that contain the missing parent
62    /// rows.
63    #[cfg(any(test, feature = "test-utils"))]
64    pub had_fk_violations: bool,
65    /// Tables that hit non-retryable SQLite constraint conflicts. The caller must
66    /// roll back the transaction when this is non-empty.
67    pub constraint_conflict_tables: Vec<String>,
68    /// Incoming rows whose exact row value won arbitration. A missing stamp
69    /// identifies a winning deletion.
70    #[cfg(any(test, feature = "test-utils"))]
71    pub winning_rows: Vec<WinningRow>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq)]
75pub struct WinningRow {
76    pub table: String,
77    pub row_id: String,
78    pub row_stamp: Option<String>,
79}
80
81#[derive(Clone, Debug)]
82struct IncomingRow {
83    table: String,
84    row_id: String,
85    row_stamp: Option<String>,
86}
87
88/// Changeset bytes paired with the exact synced schema that validated their row
89/// identities. Construction is the one identity-validation boundary; apply only
90/// accepts this type, so callers cannot parse once for classification and then
91/// parse the same bytes again during mutation.
92pub struct ValidatedChangeset<B> {
93    bytes: B,
94    schema: Arc<TableSchema>,
95}
96
97impl<B: AsRef<[u8]>> ValidatedChangeset<B> {
98    pub fn new(bytes: B, schema: Arc<TableSchema>) -> Result<Self, ChangesetIdentityError> {
99        validate_changeset_row_identities(bytes.as_ref(), schema.synced_tables())?;
100        Ok(Self { bytes, schema })
101    }
102
103    pub fn bytes(&self) -> &[u8] {
104        self.bytes.as_ref()
105    }
106
107    pub fn schema(&self) -> &TableSchema {
108        &self.schema
109    }
110
111    pub fn validate_subset<C: AsRef<[u8]>>(
112        &self,
113        bytes: C,
114    ) -> Result<ValidatedChangeset<C>, ChangesetIdentityError> {
115        ValidatedChangeset::new(bytes, self.schema.clone())
116    }
117}
118
119/// Apply `bytes` to `conn`, resolving column and row conflicts,
120/// building the [`TableSchema`] from `tables` once. A convenience wrapper over
121/// `resolve_and_apply_changeset_with_schema` for callers that apply a single
122/// changeset and don't already hold a schema (tests, snapshot round-trips).
123///
124/// `receiver_wall_ms` is the receiver's current wall-clock millis, against which
125/// a grossly-future incoming `_updated_at` is refused (see `arbitrate_row_conflict`).
126#[cfg(any(test, feature = "test-utils"))]
127pub fn resolve_and_apply_changeset(
128    conn: &Connection,
129    store_dir: &coven_foundation::store_dir::StoreDir,
130    bytes: &[u8],
131    tables: &[SyncedTable],
132    receiver_wall_ms: u64,
133) -> Result<ApplyResult, DbError> {
134    let schema = Arc::new(TableSchema::from_db(conn, tables)?);
135    resolve_and_apply_changeset_with_schema(conn, store_dir, bytes, schema, receiver_wall_ms)
136}
137
138/// Apply `bytes` to `conn`, resolving conflicts against a pre-built
139/// [`TableSchema`]: prepare losing UPDATE column merges in the changeset, then
140/// apply once with a conflict closure for remaining row collisions.
141///
142/// The schema's per-table `_updated_at` column index map is derived once (from
143/// the live schema, so future migrations that add columns are safe) and reused
144/// across every changeset in a pull, rather than re-querying `PRAGMA table_info`
145/// per changeset. The conflict closure resolves each conflicting row's table from
146/// its operation and decides REPLACE/OMIT by comparing `_updated_at`;
147/// Non-FK constraint conflicts are collected so the caller can surface the
148/// rejected changeset; unresolved foreign keys are checked after application.
149///
150/// `schema` is an `Arc` so the same map moves into the `'static` conflict closure
151/// without re-deriving it per call. `receiver_wall_ms` is the receiver's current
152/// wall-clock millis, read once by the caller and moved into the closure to bound
153/// a grossly-future incoming `_updated_at` (see `arbitrate_row_conflict`).
154#[cfg(any(test, feature = "test-utils"))]
155pub(crate) fn resolve_and_apply_changeset_with_schema(
156    conn: &Connection,
157    store_dir: &coven_foundation::store_dir::StoreDir,
158    bytes: &[u8],
159    schema: Arc<TableSchema>,
160    receiver_wall_ms: u64,
161) -> Result<ApplyResult, DbError> {
162    let changeset = ValidatedChangeset::new(bytes, schema).map_err(DbError::from)?;
163    let tx = conn.unchecked_transaction().map_err(DbError::from)?;
164    let result = MergeMaterializationTransaction::from_store(
165        crate::store::store_session::StoreTransaction::new(&tx, store_dir),
166    )
167    .apply_changeset(
168        changeset,
169        IncomingTimestampPolicy::Received { receiver_wall_ms },
170    )?;
171    if result.had_fk_violations || !result.constraint_conflict_tables.is_empty() {
172        tx.rollback().map_err(DbError::from)?;
173    } else {
174        tx.commit().map_err(DbError::from)?;
175    }
176    Ok(result)
177}
178
179impl MergeMaterializationTransaction<'_, '_> {
180    pub(crate) fn apply_changeset<B: AsRef<[u8]>>(
181        &self,
182        changeset: ValidatedChangeset<B>,
183        timestamp_policy: IncomingTimestampPolicy,
184    ) -> Result<ApplyResult, DbError> {
185        let conn = self.store.transaction;
186        let ValidatedChangeset { bytes, schema } = changeset;
187        let bytes = bytes.as_ref();
188        #[cfg(any(test, feature = "test-utils"))]
189        let incoming_rows = incoming_rows(bytes, &schema)?;
190
191        let constraint_conflict_tables = Arc::new(Mutex::new(Vec::new()));
192        let (prepared_bytes, merged_updates) =
193            prepare_column_merges(conn, bytes, &schema, timestamp_policy)?;
194
195        let closure_constraint_conflict_tables = constraint_conflict_tables.clone();
196        let closure_schema = schema.clone();
197        ReplaySql::begin(conn)?.run(|| {
198            conn.apply_strm(
199                &mut prepared_bytes.as_ref(),
200                None::<fn(&str) -> bool>,
201                move |conflict_type, item| {
202                    // A FOREIGN_KEY conflict's iterator supports ONLY `fk_conflicts()`;
203                    // calling `op()`/`new_value()`/`conflict()` on it is undefined (it
204                    // crashes the process). Resolve it first, without touching the row.
205                    if conflict_type == ConflictType::SQLITE_CHANGESET_FOREIGN_KEY {
206                        return ConflictAction::SQLITE_CHANGESET_OMIT;
207                    }
208                    // Every other conflict type exposes the operation, so the table name
209                    // (needed to find the `_updated_at` column) is readable.
210                    let (table, op_code) = match item.op() {
211                        Ok(op) => (op.table_name().to_string(), op.code()),
212                        Err(error) => {
213                            warn!(error = %error, "failed to read changeset conflict operation; aborting apply");
214                            return ConflictAction::SQLITE_CHANGESET_ABORT;
215                        }
216                    };
217                    if conflict_type == ConflictType::SQLITE_CHANGESET_CONSTRAINT {
218                        warn!(
219                            table = %table,
220                            "changeset hit a non-retryable SQLite constraint conflict; rejecting changeset"
221                        );
222                        match closure_constraint_conflict_tables.lock() {
223                            Ok(mut tables) => tables.push(table),
224                            Err(error) => {
225                                warn!(error = %error, "failed to record changeset constraint conflict; aborting apply");
226                                return ConflictAction::SQLITE_CHANGESET_ABORT;
227                            }
228                        }
229                        return ConflictAction::SQLITE_CHANGESET_OMIT;
230                    }
231                    if conflict_type == ConflictType::SQLITE_CHANGESET_DATA
232                        && op_code == Action::SQLITE_UPDATE
233                    {
234                        match update_pk_key(&item, &table).map(|pk| {
235                            merged_updates.contains(&RowKey {
236                                table: table.clone(),
237                                pk,
238                            })
239                        }) {
240                            Ok(true) => return ConflictAction::SQLITE_CHANGESET_REPLACE,
241                            Ok(false) => {}
242                            Err(error) => {
243                                warn!(table, error = %error, "failed to read merged UPDATE primary key; aborting apply");
244                                return ConflictAction::SQLITE_CHANGESET_ABORT;
245                            }
246                        }
247                    }
248                    arbitrate_row_conflict(
249                        conflict_type,
250                        item,
251                        &table,
252                        &closure_schema,
253                        timestamp_policy,
254                    )
255                },
256            )
257            .map_err(DbError::from)
258        })?;
259        #[cfg(any(test, feature = "test-utils"))]
260        let had_fk_violations = self.has_foreign_key_violations()?;
261        let constraint_conflict_tables = constraint_conflict_tables
262            .lock()
263            .map_err(|_| {
264                DbError::Message("constraint conflict table collection is poisoned".to_string())
265            })?
266            .clone();
267        #[cfg(any(test, feature = "test-utils"))]
268        let winning_rows = resolve_winning_rows(conn, &schema, incoming_rows)?;
269
270        Ok(ApplyResult {
271            #[cfg(any(test, feature = "test-utils"))]
272            had_fk_violations,
273            constraint_conflict_tables,
274            #[cfg(any(test, feature = "test-utils"))]
275            winning_rows,
276        })
277    }
278
279    pub(crate) fn current_winning_rows<B: AsRef<[u8]>>(
280        &self,
281        schema: &TableSchema,
282        changeset: B,
283    ) -> Result<Vec<WinningRow>, DbError> {
284        resolve_winning_rows(
285            self.store.transaction,
286            schema,
287            incoming_rows(changeset.as_ref(), schema)?,
288        )
289    }
290
291    pub(crate) fn apply_changeset_strict<B: AsRef<[u8]>>(
292        &self,
293        changeset: ValidatedChangeset<B>,
294        blob_decls: &crate::BlobDecls,
295    ) -> Result<(), DbError> {
296        let bytes = changeset.bytes();
297        let old_changes = crate::walk_old_changeset(bytes).map_err(DbError::Changeset)?;
298        let new_changes = crate::walk_changeset(bytes).map_err(DbError::Changeset)?;
299        let old_exact_bindings = super::exact_blob_bindings_on(self.store.transaction)?;
300        let obsolete = crate::local_blob_cleanup_intents::intents_from_changes(
301            blob_decls,
302            &old_changes,
303            &new_changes,
304        )?;
305        self.store
306            .transaction
307            .apply_strm(
308                &mut &bytes[..],
309                None::<fn(&str) -> bool>,
310                |_conflict_type, _item| ConflictAction::SQLITE_CHANGESET_ABORT,
311            )
312            .map_err(DbError::from)?;
313        for intent in obsolete {
314            super::record_obsolete_copy_intents_from_bindings_on(
315                self.store.transaction,
316                blob_decls,
317                &intent,
318                &old_exact_bindings,
319            )?;
320        }
321        Ok(())
322    }
323}
324
325fn incoming_rows(bytes: &[u8], schema: &TableSchema) -> Result<Vec<IncomingRow>, DbError> {
326    if bytes.is_empty() {
327        return Ok(Vec::new());
328    }
329    let input: &mut dyn std::io::Read = &mut &bytes[..];
330    let mut iter = ChangesetIter::start_strm(&input).map_err(DbError::from)?;
331    let mut rows = Vec::new();
332    while let Some(item) = iter.next().map_err(DbError::from)? {
333        let op = item.op().map_err(DbError::from)?;
334        let table = op.table_name();
335        let updated_at = schema.updated_at(table).ok_or_else(|| {
336            DbError::Message(format!("changeset contains undeclared table {table:?}"))
337        })?;
338        let (id_side, stamp_side) = match op.code() {
339            Action::SQLITE_INSERT => (UpdateValue::New, Some(UpdateValue::New)),
340            Action::SQLITE_UPDATE => (UpdateValue::Old, Some(UpdateValue::New)),
341            Action::SQLITE_DELETE => (UpdateValue::Old, None),
342            code => {
343                return Err(DbError::Message(format!(
344                    "changeset for {table:?} contains unsupported operation {code:?}"
345                )));
346            }
347        };
348        let row_id = required_text_changeset_value(item, table, 0, id_side, "row id")?;
349        let row_stamp = stamp_side
350            .map(|side| required_text_changeset_value(item, table, updated_at, side, "row stamp"))
351            .transpose()?;
352        rows.push(IncomingRow {
353            table: table.to_string(),
354            row_id,
355            row_stamp,
356        });
357    }
358    Ok(rows)
359}
360
361fn required_text_changeset_value(
362    item: &ChangesetItem,
363    table: &str,
364    column: usize,
365    side: UpdateValue,
366    field: &str,
367) -> Result<String, DbError> {
368    let value = changeset_value(item, column, side)?.ok_or_else(|| {
369        DbError::Message(format!("changeset for {table:?} has no {side:?} {field}"))
370    })?;
371    let Value::Text(value) = value else {
372        return Err(DbError::Message(format!(
373            "changeset for {table:?} has non-TEXT {side:?} {field}"
374        )));
375    };
376    Ok(value)
377}
378
379fn resolve_winning_rows(
380    conn: &Connection,
381    schema: &TableSchema,
382    incoming: Vec<IncomingRow>,
383) -> Result<Vec<WinningRow>, DbError> {
384    let mut winners = Vec::new();
385    for row in incoming {
386        let columns = schema.columns(&row.table).ok_or_else(|| {
387            DbError::Message(format!("synced table {:?} has no column map", row.table))
388        })?;
389        let updated_at = schema.updated_at(&row.table).ok_or_else(|| {
390            DbError::Message(format!(
391                "synced table {:?} has no _updated_at column index",
392                row.table
393            ))
394        })?;
395        let sql = format!(
396            "SELECT {} FROM {} WHERE {} = ?1",
397            quote_ident(&columns[updated_at]),
398            quote_ident(&row.table),
399            quote_ident(&columns[0])
400        );
401        let live_stamp = conn
402            .query_row(&sql, [&row.row_id], |result| result.get::<_, String>(0))
403            .optional()
404            .map_err(DbError::from)?;
405        let incoming_won = match (&row.row_stamp, &live_stamp) {
406            (None, None) => true,
407            (Some(expected), Some(actual)) => expected == actual,
408            _ => false,
409        };
410        if incoming_won {
411            winners.push(WinningRow {
412                table: row.table,
413                row_id: row.row_id,
414                row_stamp: row.row_stamp,
415            });
416        }
417    }
418    Ok(winners)
419}
420
421#[derive(Clone, Debug, Eq, PartialEq, Hash)]
422struct RowKey {
423    table: String,
424    pk: String,
425}
426
427struct UpdateColumn {
428    index: usize,
429    base: Value,
430    incoming: Value,
431}
432
433struct IncomingUpdate {
434    table: String,
435    pk: String,
436    columns: Vec<UpdateColumn>,
437    incoming_updated_at: Timestamp,
438    incoming_updated_at_value: Value,
439}
440
441fn prepare_column_merges<'bytes>(
442    conn: &Connection,
443    bytes: &'bytes [u8],
444    schema: &TableSchema,
445    timestamp_policy: IncomingTimestampPolicy,
446) -> Result<(Cow<'bytes, [u8]>, HashSet<RowKey>), DbError> {
447    if bytes.is_empty() {
448        return Ok((Cow::Borrowed(bytes), HashSet::new()));
449    }
450    let input: &mut dyn std::io::Read = &mut &bytes[..];
451    let mut iter = ChangesetIter::start_strm(&input)?;
452    let mut handled = HashSet::new();
453    let mut encoder = None;
454    while let Some(item) = iter.next()? {
455        let Some(update) = incoming_update(item, schema)? else {
456            continue;
457        };
458        if prepare_losing_update(
459            conn,
460            schema,
461            &update,
462            timestamp_policy,
463            item.op()?.indirect(),
464            &mut encoder,
465            bytes,
466        )? {
467            handled.insert(RowKey {
468                table: update.table,
469                pk: update.pk,
470            });
471        }
472    }
473    let prepared = match encoder {
474        Some(encoder) => Cow::Owned(encoder.output()?),
475        None => Cow::Borrowed(bytes),
476    };
477    Ok((prepared, handled))
478}
479
480fn incoming_update(
481    item: &ChangesetItem,
482    schema: &TableSchema,
483) -> Result<Option<IncomingUpdate>, DbError> {
484    let op = item.op().map_err(DbError::from)?;
485    if op.code() != Action::SQLITE_UPDATE {
486        return Ok(None);
487    }
488
489    let table = op.table_name();
490    let Some(updated_at) = schema.updated_at(table) else {
491        warn!(
492            table,
493            "UPDATE changeset table is not in the local synced schema"
494        );
495        return Ok(None);
496    };
497
498    let Some(incoming_updated_at_value) = changeset_value(item, updated_at, UpdateValue::New)?
499    else {
500        warn!(table, "UPDATE changeset has no incoming _updated_at value");
501        return Ok(None);
502    };
503    let Some(incoming_updated_at) = timestamp_from_value(&incoming_updated_at_value) else {
504        warn!(
505            table,
506            "UPDATE changeset has an incoming _updated_at value that does not parse"
507        );
508        return Ok(None);
509    };
510
511    let pk = update_pk_key(item, table)?;
512
513    let columns = update_columns(item, updated_at)?;
514    if let Some(blob) = schema.blob_columns(table) {
515        let edits_blob = columns.iter().any(|column| {
516            blob.iter().any(|index| index == column.index) && column.base != column.incoming
517        });
518        if edits_blob {
519            for index in blob.iter().filter(|index| *index != 0) {
520                if !columns.iter().any(|column| column.index == index) {
521                    return Err(DbError::Message(format!(
522                        "blob UPDATE for {table} omits content column {index}"
523                    )));
524                }
525            }
526        }
527    }
528
529    Ok(Some(IncomingUpdate {
530        table: table.to_string(),
531        pk,
532        columns,
533        incoming_updated_at,
534        incoming_updated_at_value,
535    }))
536}
537
538fn update_columns(item: &ChangesetItem, updated_at: usize) -> Result<Vec<UpdateColumn>, DbError> {
539    let op = item.op()?;
540    let table = op.table_name();
541    let mut columns = Vec::new();
542    for index in 0..op.number_of_columns() as usize {
543        if index == 0 || index == updated_at {
544            continue;
545        }
546        let base = changeset_value(item, index, UpdateValue::Old)?;
547        let incoming = changeset_value(item, index, UpdateValue::New)?;
548        match (base, incoming) {
549            (Some(base), Some(incoming)) => columns.push(UpdateColumn {
550                index,
551                base,
552                incoming,
553            }),
554            (None, None) => {}
555            _ => {
556                return Err(DbError::Message(format!(
557                    "UPDATE changeset for {table} has only one side for column {index}"
558                )));
559            }
560        }
561    }
562
563    Ok(columns)
564}
565
566fn prepare_losing_update(
567    conn: &Connection,
568    schema: &TableSchema,
569    update: &IncomingUpdate,
570    timestamp_policy: IncomingTimestampPolicy,
571    indirect: bool,
572    encoder: &mut Option<Changegroup>,
573    bytes: &[u8],
574) -> Result<bool, DbError> {
575    let columns = schema.columns(&update.table).ok_or_else(|| {
576        DbError::Message(format!("synced table {} has no column map", update.table))
577    })?;
578    let updated_at = schema.updated_at(&update.table).ok_or_else(|| {
579        DbError::Message(format!(
580            "synced table {} has no _updated_at column",
581            update.table
582        ))
583    })?;
584    if update.columns.iter().any(|c| c.index >= columns.len()) || updated_at >= columns.len() {
585        return Err(DbError::Message(format!(
586            "UPDATE changeset for {} names a column outside the local schema",
587            update.table
588        )));
589    }
590    let sql = format!(
591        "SELECT {} FROM {} WHERE {} = ?1",
592        columns
593            .iter()
594            .map(|column| quote_ident(column))
595            .collect::<Vec<_>>()
596            .join(", "),
597        quote_ident(&update.table),
598        quote_ident(&columns[0]),
599    );
600    let local = conn
601        .query_row(&sql, [&update.pk], |row| {
602            (0..columns.len())
603                .map(|index| row.get::<_, Value>(index))
604                .collect::<rusqlite::Result<Vec<_>>>()
605        })
606        .optional()?;
607    let Some(local) = local else { return Ok(false) };
608    let local_stamp = timestamp_from_value(&local[updated_at]).ok_or_else(|| {
609        DbError::Message(format!(
610            "local row in {} has no parseable _updated_at",
611            update.table
612        ))
613    })?;
614    match compare_lww_stamps(
615        &update.table,
616        update.incoming_updated_at.clone(),
617        local_stamp,
618        timestamp_policy,
619    ) {
620        LwwComparison::IncomingWins | LwwComparison::IncomingGrossFuture => return Ok(false),
621        LwwComparison::LocalWins => {}
622    }
623    // Encode incoming -> merged as a correction to the original changeset. The
624    // live rows stay untouched until SQLite applies the combined changeset, so
625    // its constraint retry sees every update and deletion in the same operation.
626    let mut incoming = local.clone();
627    let mut merged = local.clone();
628    incoming[updated_at] = update.incoming_updated_at_value.clone();
629    let blob = schema.blob_columns(&update.table);
630    // Content fields describe one value: an older edit can replace them only
631    // while the whole value still matches its captured base.
632    let merge_blob = blob.is_none_or(|blob| {
633        update
634            .columns
635            .iter()
636            .filter(|column| blob.iter().any(|index| index == column.index))
637            .all(|column| local[column.index] == column.base)
638    });
639    for column in &update.columns {
640        incoming[column.index] = column.incoming.clone();
641        let belongs_to_blob =
642            blob.is_some_and(|blob| blob.iter().any(|index| index == column.index));
643        if (belongs_to_blob && merge_blob)
644            || (!belongs_to_blob && local[column.index] == column.base)
645        {
646            merged[column.index] = column.incoming.clone();
647        }
648    }
649    if merged == local {
650        return Ok(false);
651    }
652    let encoder = match encoder {
653        Some(encoder) => encoder,
654        slot @ None => {
655            let group = Changegroup::new()?;
656            // The live connection outlives this group and supplies only its
657            // column/primary-key layout; encoding does not mutate its rows.
658            unsafe { group.set_schema(conn.handle()) }?;
659            group.add_changeset(bytes)?;
660            slot.insert(group)
661        }
662    };
663    let (old, new): (Vec<_>, Vec<_>) = incoming
664        .into_iter()
665        .zip(merged)
666        .enumerate()
667        .map(|(index, (incoming, merged))| {
668            if index == 0 {
669                (Some(incoming), None)
670            } else if incoming != merged {
671                (Some(incoming), Some(merged))
672            } else {
673                (None, None)
674            }
675        })
676        .unzip();
677    encoder.add_update(&update.table, &old, &new, indirect)?;
678    Ok(true)
679}
680
681fn changeset_value(
682    item: &ChangesetItem,
683    column: usize,
684    side: UpdateValue,
685) -> Result<Option<Value>, DbError> {
686    let value = match side {
687        UpdateValue::Old => item.old_value(column),
688        UpdateValue::New => item.new_value(column),
689    };
690    match value {
691        Ok(value) => Value::try_from(value).map(Some).map_err(|error| {
692            DbError::context(
693                format!("changeset {side:?} value conversion failed for column {column}"),
694                error,
695            )
696        }),
697        Err(rusqlite::Error::InvalidColumnIndex(_)) => Ok(None),
698        Err(error) => Err(DbError::context(
699            format!("changeset {side:?} value read failed for column {column}"),
700            error,
701        )),
702    }
703}
704
705fn update_pk_key(item: &ChangesetItem, table: &str) -> Result<String, DbError> {
706    match item.old_value(0) {
707        Ok(value) => text_id_from_value_ref(table, value),
708        Err(rusqlite::Error::InvalidColumnIndex(_)) => Err(DbError::Message(format!(
709            "UPDATE changeset for {table} has no old-side primary key"
710        ))),
711        Err(error) => Err(DbError::context(
712            format!("UPDATE changeset for {table} primary key read failed"),
713            error,
714        )),
715    }
716}
717
718fn text_id_from_value_ref(table: &str, value: ValueRef<'_>) -> Result<String, DbError> {
719    let ValueRef::Text(bytes) = value else {
720        return Err(DbError::Message(format!(
721            "UPDATE changeset for {table} primary key is not TEXT"
722        )));
723    };
724    std::str::from_utf8(bytes)
725        .map(str::to_owned)
726        .map_err(|error| {
727            DbError::context(
728                format!("UPDATE changeset for {table} primary key is not UTF-8"),
729                error,
730            )
731        })
732}
733
734fn timestamp_from_value(value: &Value) -> Option<Timestamp> {
735    value_ref_to_string(ValueRef::from(value)).and_then(|s| Timestamp::parse(&s))
736}