1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12use std::borrow::Cow;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17use std::sync::Arc;
18
19use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
20use rusqlite::{Connection, OpenFlags, OptionalExtension};
21use serde::{Deserialize, Serialize};
22use serde_json::{json, Map, Value};
23use sha2::{Digest, Sha256};
24use ssp2::decode::WIRE_VERSION;
25use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
26use ssp2::primitives::RawJson;
27use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
28use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
29
30use crate::api::{
31 AppliedServerCommit, AppliedServerCommitSource, ClientChangeBatch, ClientDiagnosticsHost,
32 ClientDiagnosticsLease, ClientDiagnosticsReplica, ClientDiagnosticsRequest,
33 ClientDiagnosticsSchema, ClientDiagnosticsSnapshot, ClientDiagnosticsStorage, ClientError,
34 ClientLimits, CommandEffects, CommitDelivery, CommitOperation, CommitOperationOutcome,
35 CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution, CommitOutcomeStatus,
36 ConflictRecord, CoverageSnapshot, CurrentBaseProof, CurrentBaseProofReason,
37 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
38 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
39 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
40 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
41 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
42 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
43 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
44 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
45 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
46 MAX_DIAGNOSTIC_QUERY_FAILURES,
47};
48use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
49#[cfg(feature = "bench-internals")]
50use crate::bench::{Phase, Recorder};
51use crate::previous_version::{
52 capture_previous_version_from_replica, first_incompatibility,
53 reconcile_previous_version_at_boot, set_local_schema_descriptor,
54 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
55 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
56};
57use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
58use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
59use crate::values::{
60 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
61 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
62 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
63 sort_scope_map,
64};
65
66const DEFAULT_ACCEPT: u8 = 0b0111;
70const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
71const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
72const ACCEPT_SQLITE: u8 = 1 << 2;
73const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
74const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
75
76fn recommended_action_of(details: &Option<Value>) -> Option<String> {
78 details
79 .as_ref()?
80 .get("recommendedAction")?
81 .as_str()
82 .map(str::to_owned)
83}
84
85fn coalesced_delete_keys(
93 frames: &[Frame],
94 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
95) -> HashSet<(i64, String, String)> {
96 let mut sections = Vec::new();
97 let mut open: Option<(usize, String, SubStatus)> = None;
98 for (index, frame) in frames.iter().enumerate() {
99 match frame {
100 Frame::SubStart { id, status, .. } => {
101 open = Some((index, id.clone(), *status));
102 }
103 Frame::SubEnd { next_cursor, .. } => {
104 if let Some((start, id, status)) = open.take() {
105 sections.push((start, index, id, status, Some(*next_cursor)));
106 }
107 }
108 _ => {}
109 }
110 }
111 type Key = (i64, String, String);
112 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
113 let mut both_in_frame: HashSet<Key> = HashSet::new();
114 let mut frame_ordinal = 0usize;
115 for (start, end, id, status, next_cursor) in sections {
116 if !applies(&id, status, next_cursor) {
117 continue;
118 }
119 for frame in &frames[start + 1..end] {
120 let Frame::Commit {
121 commit_seq,
122 tables,
123 changes,
124 ..
125 } = frame
126 else {
127 continue;
128 };
129 frame_ordinal += 1;
130 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
131 for change in changes {
132 let Some(table) = tables.get(change.table_index as usize) else {
133 continue;
134 };
135 let key = (*commit_seq, table.clone(), change.row_id.clone());
136 let entry = ops.entry(key).or_default();
137 let local_entry = local
138 .entry((table.clone(), change.row_id.clone()))
139 .or_default();
140 match change.op {
141 Op::Upsert => {
142 entry.0.insert(frame_ordinal);
143 local_entry.0 = true;
144 }
145 Op::Delete => {
146 entry.1.insert(frame_ordinal);
147 local_entry.1 = true;
148 }
149 }
150 }
151 for ((table, row_id), (has_upsert, has_delete)) in local {
152 if has_upsert && has_delete {
153 both_in_frame.insert((*commit_seq, table, row_id));
154 }
155 }
156 }
157 }
158 ops.into_iter()
159 .filter_map(|(key, (upserts, deletes))| {
160 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
161 .then_some(key)
162 })
163 .collect()
164}
165
166pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
168const LOCAL_REVISION_KEY: &str = "localRevision";
169const CLIENT_ID_KEY: &str = "clientId";
170const LEASE_STATE_KEY: &str = "leaseState";
171const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
172pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
173const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
178const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
179const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
180const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
183const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
186const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
187const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
189 "the persisted commit carries values the current codec refuses";
190const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
192
193#[derive(Debug, Serialize, Deserialize)]
194#[serde(rename_all = "camelCase", deny_unknown_fields)]
195struct PersistedLocalDataRebootstrapReceipt {
196 version: u8,
197 retained_commits: u64,
198 reset_subscriptions: u64,
199}
200
201fn invalid_local_rebootstrap_receipt() -> String {
202 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
203}
204
205fn encode_local_rebootstrap_receipt(
206 retained_commits: usize,
207 reset_subscriptions: usize,
208) -> Result<String, String> {
209 let retained_commits =
210 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
211 let reset_subscriptions =
212 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
213 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
214 return Err(invalid_local_rebootstrap_receipt());
215 }
216 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
217 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
218 retained_commits,
219 reset_subscriptions,
220 })
221 .map_err(|_| invalid_local_rebootstrap_receipt())
222}
223
224fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
225 if value == "v1" {
227 return Ok((0, 0));
228 }
229 let receipt: PersistedLocalDataRebootstrapReceipt =
230 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
231 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
232 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
233 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
234 {
235 return Err(invalid_local_rebootstrap_receipt());
236 }
237 Ok((
238 usize::try_from(receipt.retained_commits)
239 .map_err(|_| invalid_local_rebootstrap_receipt())?,
240 usize::try_from(receipt.reset_subscriptions)
241 .map_err(|_| invalid_local_rebootstrap_receipt())?,
242 ))
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq)]
246enum SubState {
247 Active,
248 Revoked,
249 Failed,
250}
251
252#[cfg(test)]
253mod observation_tests;
254
255impl SubState {
256 fn name(self) -> &'static str {
257 match self {
258 SubState::Active => "active",
259 SubState::Revoked => "revoked",
260 SubState::Failed => "failed",
261 }
262 }
263
264 fn parse(value: &str) -> Self {
265 match value {
266 "revoked" => Self::Revoked,
267 "failed" => Self::Failed,
268 _ => Self::Active,
269 }
270 }
271}
272
273#[derive(Debug, Clone)]
274struct Subscription {
275 id: String,
276 table: String,
277 requested: Vec<(String, Vec<String>)>,
278 params: Option<String>,
279 cursor: i64,
280 bootstrap_state: Option<String>,
282 state: SubState,
283 reason_code: Option<String>,
284 effective: Option<Vec<(String, Vec<String>)>>,
287 synced_once: bool,
288}
289
290impl Subscription {
291 fn takes_delta(&self) -> bool {
295 self.state == SubState::Active
296 && self.cursor >= 0
297 && self.bootstrap_state.is_none()
298 && self.synced_once
299 }
300}
301
302#[doc(hidden)]
304pub struct CurrentBasePreparation {
305 client_commit_id: String,
306 sequence: i64,
307 subscriptions: Vec<Subscription>,
308 transport_epoch: u64,
309 subscription_epoch: u64,
310 security_epoch: u64,
311}
312
313#[cfg(test)]
314mod row_ownership_tests;
315
316#[cfg(test)]
317mod subscription_rearm_tests;
318
319#[derive(Debug, Clone)]
320struct OutboxOp {
321 upsert: bool,
322 table: String,
323 row_id: String,
324 base_version: Option<i64>,
325 values: Option<Map<String, Value>>,
329 insert: bool,
333}
334
335impl OutboxOp {
336 fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
341 let values = self.values.as_ref()?;
342 if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
343 return Some(Cow::Borrowed(values));
344 }
345 full_row_values(table, values.clone()).ok().map(Cow::Owned)
346 }
347}
348
349impl From<&OutboxOp> for CommitOperation {
350 fn from(operation: &OutboxOp) -> Self {
351 Self {
352 table: operation.table.clone(),
353 row_id: operation.row_id.clone(),
354 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
355 base_version: operation.base_version,
356 values: operation.values.clone(),
357 insert: operation.insert,
358 }
359 }
360}
361
362#[derive(Debug, Clone)]
363struct OutboxCommit {
364 client_commit_id: String,
365 ops: Vec<OutboxOp>,
366}
367
368fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
372 commit
373 .ops
374 .iter()
375 .filter(|operation| operation.upsert)
376 .map(|operation| {
377 (
378 operation.table.as_str(),
379 operation
380 .values
381 .as_ref()
382 .map(|values| values.keys().map(String::as_str).collect())
383 .unwrap_or_default(),
384 operation.insert,
385 )
386 })
387 .collect()
388}
389
390type FailedCommit = (OutboxCommit, Map<String, Value>);
391
392#[derive(Debug, Clone)]
393struct CompiledLocalDataPurgeTarget {
394 table: String,
395 selectors: Vec<(String, Vec<String>)>,
396}
397
398struct StoredCommitOutcomeRow {
399 sequence: i64,
400 client_commit_id: String,
401 status: String,
402 recorded_at_ms: i64,
403 results_json: String,
404 operations_json: Option<String>,
405 resolution: String,
406 resolved_at_ms: Option<i64>,
407 replacement_client_commit_id: Option<String>,
408}
409
410impl StoredCommitOutcomeRow {
411 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
412 Ok(Self {
413 sequence: row.get(0)?,
414 client_commit_id: row.get(1)?,
415 status: row.get(2)?,
416 recorded_at_ms: row.get(3)?,
417 results_json: row.get(4)?,
418 operations_json: row.get(5)?,
419 resolution: row.get(6)?,
420 resolved_at_ms: row.get(7)?,
421 replacement_client_commit_id: row.get(8)?,
422 })
423 }
424}
425
426enum SectionError {
429 FailClosed,
430 Abort(String, String),
431 Transport(TransportError),
432}
433
434#[derive(Debug)]
435pub(crate) struct RequestMeta {
436 pushed_ids: Vec<String>,
437 fresh: Vec<(String, bool)>,
440 pub(crate) accept: u8,
441 deferred_commits: usize,
445}
446
447#[derive(Default)]
448struct ChangeAccumulator {
449 tables: BTreeMap<String, Option<BTreeSet<String>>>,
451 windows: BTreeMap<(String, String), BTreeSet<String>>,
452 status: bool,
453 conflicts: bool,
454 rejections: bool,
455 outcomes: bool,
456}
457
458impl ChangeAccumulator {
459 fn table(&mut self, table: &str) {
460 self.tables.insert(table.to_owned(), None);
461 }
462
463 fn scope(&mut self, table: &str, key: String) {
464 match self.tables.get_mut(table) {
465 Some(None) => {}
466 Some(Some(keys)) => {
467 keys.insert(key);
468 }
469 None => {
470 self.tables
471 .insert(table.to_owned(), Some(BTreeSet::from([key])));
472 }
473 }
474 }
475
476 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
477 self.windows
478 .entry((base_key.to_owned(), table.to_owned()))
479 .or_default()
480 .insert(unit.to_owned());
481 }
482
483 fn touched(&self) -> bool {
484 !self.tables.is_empty()
485 || !self.windows.is_empty()
486 || self.status
487 || self.conflicts
488 || self.rejections
489 || self.outcomes
490 }
491}
492
493const PUSH_OPS_PER_REQUEST: usize = 500;
499const MAX_LOCAL_PURGE_TARGETS: usize = 64;
500const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
501const MAX_LOCAL_PURGE_VALUES: usize = 128;
502const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
503pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
504
505struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
507
508impl OverlayDirty {
509 fn clean() -> Self {
510 Self(RefCell::new(Some(BTreeSet::new())))
511 }
512 fn get(&self) -> bool {
513 self.0
514 .borrow()
515 .as_ref()
516 .is_none_or(|tables| !tables.is_empty())
517 }
518 fn set(&self, dirty: bool) {
519 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
520 }
521 fn table(&self, table: &str) {
522 if let Some(tables) = self.0.borrow_mut().as_mut() {
523 tables.insert(table.to_owned());
524 }
525 }
526 fn snapshot(&self) -> Option<BTreeSet<String>> {
527 self.0.borrow().clone()
528 }
529 fn restore(&self, tables: Option<BTreeSet<String>>) {
530 *self.0.borrow_mut() = tables;
531 }
532}
533
534#[derive(Debug, Clone, Default)]
537struct Shared<T>(Arc<T>);
538
539impl<T> std::ops::Deref for Shared<T> {
540 type Target = T;
541 fn deref(&self) -> &T {
542 &self.0
543 }
544}
545
546impl<T: Clone> std::ops::DerefMut for Shared<T> {
547 fn deref_mut(&mut self) -> &mut T {
548 Arc::make_mut(&mut self.0)
549 }
550}
551
552impl<T> From<T> for Shared<T> {
553 fn from(value: T) -> Self {
554 Self(Arc::new(value))
555 }
556}
557
558#[derive(Debug, Clone)]
562struct Rollback {
563 subs: Shared<Vec<Subscription>>,
564 outbox: Shared<Vec<Arc<OutboxCommit>>>,
565 failed_commits: Shared<Vec<FailedCommit>>,
566 conflicts: Shared<Vec<ConflictRecord>>,
567 rejections: Shared<Vec<RejectionRecord>>,
568 schema_floor: Option<SchemaFloor>,
569 lease_state: Option<LeaseState>,
570 stopped: bool,
571 upgrading: bool,
572 sync_needed: bool,
573 active_round: Option<uuid::Uuid>,
574 overlay_dirty: Option<BTreeSet<String>>,
575 change_queue: usize,
576 sync_intent_queue: usize,
577 last_change: Option<DiagnosticLastChange>,
578}
579
580pub struct SyncClient {
581 #[cfg(feature = "bench-internals")]
582 benchmark_phases: Recorder,
583 conn: Connection,
584 schema: ClientSchema,
585 client_id: String,
586 limits: ClientLimits,
587 subs: Shared<Vec<Subscription>>,
588 outbox: Shared<Vec<Arc<OutboxCommit>>>,
589 failed_commits: Shared<Vec<FailedCommit>>,
590 retain_failed_commits: bool,
591 conflicts: Shared<Vec<ConflictRecord>>,
592 rejections: Shared<Vec<RejectionRecord>>,
593 schema_floor: Option<SchemaFloor>,
594 lease_state: Option<LeaseState>,
596 stopped: bool,
598 upgrading: bool,
600 sync_needed: bool,
602 realtime_policy: RealtimePolicy,
605 realtime_state: RealtimeState,
606 realtime_reason_code: Option<String>,
607 realtime_retry_delay_ms: Option<u64>,
608 presence: HashMap<String, HashMap<String, PresencePeer>>,
610 now_ms: Option<i64>,
613 encryption: crate::values::EncryptionConfig,
617 security_preflight: bool,
620 security_epoch: u64,
621 transport_enabled: bool,
622 transport_epoch: u64,
623 subscription_epoch: u64,
624 previous_version: Option<PreviousVersionContextConfig>,
628 insert_sql: RefCell<HashMap<String, String>>,
634 overlay_dirty: OverlayDirty,
637 storage_failure: RefCell<Option<QueryReadFailure>>,
639 #[cfg(test)]
642 overlay_rebuild_count: Cell<usize>,
643 #[cfg(test)]
644 acknowledged_replay_count: Cell<usize>,
645 #[cfg(test)]
648 outcome_prune_count: Cell<usize>,
649 progress: ProgressObserver,
651 change_queue: VecDeque<ClientChangeBatch>,
652 sync_intent_queue: VecDeque<SyncIntent>,
653 retry_delay_ms: u64,
655 round_retry_delay_ms: Option<u64>,
657 round_failure: Option<String>,
662 active_round: Option<uuid::Uuid>,
663 last_round: Option<DiagnosticLastRound>,
664 last_change: Option<DiagnosticLastChange>,
665 query_failures: Vec<DiagnosticQueryFailure>,
667 coalesced: Option<CoalescedApply>,
669 coalesced_failure: Option<String>,
672}
673
674struct CoalescedApply {
678 rows: usize,
679 rollback: Rollback,
680}
681
682const COALESCED_APPLY_ROWS: usize = 1024;
683
684pub(crate) fn quote_ident(name: &str) -> String {
685 format!("\"{}\"", name.replace('"', "\"\""))
686}
687pub(crate) fn meta_get(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
691 conn.query_row(
692 "SELECT value FROM _syncular_meta WHERE key = ?1",
693 rusqlite::params![key],
694 |row| row.get::<_, String>(0),
695 )
696 .optional()
697}
698
699pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
700 conn.execute(
701 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
702 rusqlite::params![key, value],
703 )
704 .map(|_| ())
705}
706
707pub(crate) fn meta_delete(conn: &Connection, key: &str) -> rusqlite::Result<()> {
708 conn.execute(
709 "DELETE FROM _syncular_meta WHERE key = ?1",
710 rusqlite::params![key],
711 )
712 .map(|_| ())
713}
714
715fn row_id_predicate(table: &TableSchema) -> String {
717 let key = quote_ident(&table.primary_key);
718 let text_match = format!("CAST({key} AS TEXT) = ?1");
719 match table.columns[table.pk_index].ty {
720 ColumnType::String | ColumnType::Json => {
721 format!("{key} = ?1 AND {text_match}")
722 }
723 ColumnType::Integer | ColumnType::Boolean => {
727 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
728 }
729 _ => text_match,
732 }
733}
734
735fn local_scope_predicate(
743 table: &TableSchema,
744 effective: &[(String, Vec<String>)],
745 owners: &[Vec<(String, Vec<String>)>],
746) -> Result<Option<(String, Vec<SqlValue>)>, ()> {
747 if effective.is_empty() || effective.iter().any(|(_, values)| values.is_empty()) {
748 return Ok(None);
749 }
750 let mut clauses = Vec::new();
751 let mut params = Vec::new();
752 for (variable, values) in effective {
753 let name = table.scope_column(variable).ok_or(())?;
754 let column = table
755 .columns
756 .iter()
757 .find(|column| column.name == name)
758 .ok_or(())?;
759 let key = quote_ident(name);
760 let holes = vec!["?"; values.len()].join(", ");
761 clauses.push(if column.ty == ColumnType::Integer {
762 params.extend(values.iter().cloned().map(SqlValue::Text));
763 let seek = vec!["+CAST(? AS INTEGER)"; values.len()].join(", ");
764 format!("{key} IN ({seek}) AND CAST({key} AS TEXT) IN ({holes})")
765 } else {
766 format!("{key} IN ({holes})")
767 });
768 params.extend(values.iter().cloned().map(SqlValue::Text));
769 }
770 for owner in owners {
771 if let Some((sql, owner_params)) = local_scope_predicate(table, owner, &[])? {
772 clauses.push(format!("NOT ({sql})"));
773 params.extend(owner_params);
774 }
775 }
776 Ok(Some((clauses.join(" AND "), params)))
777}
778
779fn owns_local_row(
781 table: &TableSchema,
782 effective: &[(String, Vec<String>)],
783 owners: &[Vec<(String, Vec<String>)>],
784 values: &Map<String, Value>,
785) -> bool {
786 let matches = |scopes: &[(String, Vec<String>)]| {
787 !scopes.is_empty()
788 && scopes.iter().all(|(variable, allowed)| {
789 table
790 .scope_column(variable)
791 .and_then(|column| values.get(column))
792 .filter(|value| !value.is_null())
793 .is_some_and(|value| {
794 allowed.contains(
795 &value
796 .as_str()
797 .map_or_else(|| value.to_string(), str::to_owned),
798 )
799 })
800 })
801 };
802 matches(effective) && !owners.iter().any(|owner| matches(owner))
803}
804
805fn is_local_operation_code_like(value: &str) -> bool {
806 let bytes = value.as_bytes();
807 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
808 && bytes
809 .iter()
810 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
811}
812
813fn base_table(name: &str) -> String {
814 quote_ident(&format!("_syncular_base_{name}"))
815}
816
817type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
822
823fn window_base_key(base: &WindowBase) -> String {
824 format!(
825 "{}\0{}\0{}",
826 base.table,
827 base.variable,
828 canonical_scope_json(&base.fixed_scopes)
829 )
830}
831
832fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
834 let mut scopes = base.fixed_scopes.clone();
835 scopes.retain(|(k, _)| k != &base.variable);
836 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
837 scopes
838}
839
840fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
844 let canonical = canonical_scope_json(&unit_scopes(base, unit));
845 let digest = Sha256::digest(canonical.as_bytes());
846 let hex = bytes_to_hex(&digest);
847 format!("w:{}:{}", base.table, &hex[..16])
848}
849
850fn visible_table(name: &str) -> String {
851 quote_ident(name)
852}
853
854const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
855
856fn is_synced_table_name(name: &str) -> bool {
859 if name.starts_with("sqlite_") {
860 return false;
861 }
862 if name.starts_with("_syncular_base_") {
863 return true; }
865 !name.starts_with("_syncular_")
867}
868
869fn blob_id_for(bytes: &[u8]) -> String {
871 let digest = Sha256::digest(bytes);
872 format!("sha256:{}", bytes_to_hex(&digest))
873}
874
875enum RowParam<'a> {
879 Cell(&'a Option<ColumnValue>),
880 Version(i64),
881}
882
883impl rusqlite::ToSql for RowParam<'_> {
884 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
885 Ok(match self {
886 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
887 RowParam::Cell(cell) => match cell {
888 None => ToSqlOutput::Owned(SqlValue::Null),
889 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
890 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
891 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
892 Some(ColumnValue::Boolean(b)) => {
893 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
894 }
895 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
896 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
897 }
898 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
900 ToSqlOutput::Borrowed(ValueRef::Blob(b))
901 }
902 },
903 })
904 }
905}
906
907fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
909 match param.to_sql().map_err(|error| error.to_string())? {
910 ToSqlOutput::Owned(value) => Ok(value),
911 ToSqlOutput::Borrowed(value) => {
912 SqlValue::try_from(value).map_err(|error| error.to_string())
913 }
914 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
915 }
916}
917
918fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
926 use ssp2::segment::ColumnType;
927 let mismatch = || {
928 Err(format!(
929 "image column {:?} holds a value of the wrong type",
930 column.name
931 ))
932 };
933 match value {
934 ValueRef::Null => {
935 if !column.nullable {
936 return Err(format!(
937 "image column {:?} is NULL but not nullable",
938 column.name
939 ));
940 }
941 Ok(ToSqlOutput::Owned(SqlValue::Null))
942 }
943 ValueRef::Integer(i) => match column.ty {
944 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
945 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
946 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
947 _ => mismatch(),
948 },
949 ValueRef::Real(_) => match column.ty {
950 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
951 _ => mismatch(),
952 },
953 ValueRef::Text(t) => {
954 std::str::from_utf8(t)
955 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
956 match column.ty {
957 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
958 Ok(ToSqlOutput::Borrowed(value))
959 }
960 _ => mismatch(),
961 }
962 }
963 ValueRef::Blob(_) => match column.ty {
964 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
966 _ => mismatch(),
967 },
968 }
969}
970
971pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
972 use rusqlite::types::ValueRef;
973 match value {
974 ValueRef::Null => Value::Null,
975 ValueRef::Integer(i) => match column.ty {
976 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
977 ssp2::segment::ColumnType::Float => {
978 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
979 }
980 _ => Value::from(i),
981 },
982 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
983 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
984 ValueRef::Blob(b) => {
985 let mut map = Map::new();
986 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
987 Value::Object(map)
988 }
989 }
990}
991
992fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
996 Ok(match value {
997 Value::Null => SqlValue::Null,
998 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
999 Value::Number(n) => {
1000 if let Some(i) = n.as_i64() {
1001 SqlValue::Integer(i)
1002 } else if let Some(f) = n.as_f64() {
1003 SqlValue::Real(f)
1004 } else {
1005 return Err(format!("query param number {n} is out of range"));
1006 }
1007 }
1008 Value::String(s) => SqlValue::Text(s.clone()),
1009 Value::Object(_) => {
1010 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
1011 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
1012 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
1013 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
1014 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
1015 })?)
1016 } else {
1017 return Err(
1018 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
1019 .to_owned(),
1020 );
1021 }
1022 }
1023 Value::Array(_) => return Err("query array params are not supported".to_owned()),
1024 })
1025}
1026
1027fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
1032 use rusqlite::types::ValueRef;
1033 match value {
1034 ValueRef::Null => Value::Null,
1035 ValueRef::Integer(i) => {
1036 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
1040 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
1041 Value::from(i)
1042 } else {
1043 let mut map = Map::new();
1044 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
1045 Value::Object(map)
1046 }
1047 }
1048 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
1049 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
1050 ValueRef::Blob(b) => {
1051 let mut map = Map::new();
1052 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
1053 Value::Object(map)
1054 }
1055 }
1056}
1057
1058fn query_connection(
1060 conn: &Connection,
1061 sql: &str,
1062 params: &[QueryValue],
1063) -> Result<Vec<QueryRow>, QueryReadFailure> {
1064 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
1065 let mut stmt = crate::query_guard::prepare_read_only(conn, &lowered_sql)?;
1066 let bound: Vec<SqlValue> = params
1067 .iter()
1068 .map(json_param_to_sql)
1069 .collect::<Result<_, _>>()?;
1070 let columns: Vec<(usize, String)> = stmt
1074 .column_names()
1075 .enumerate()
1076 .filter(|(_, name)| *name != "_syncular_version")
1077 .map(|(i, name)| (i, name.to_owned()))
1078 .collect();
1079 let bound_refs: Vec<&dyn rusqlite::ToSql> =
1080 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1081 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
1082 let mut out = Vec::new();
1083 while let Some(row) = sql_rows.next()? {
1084 let mut record = Map::new();
1085 for (i, name) in &columns {
1086 let value = row.get_ref(*i)?;
1087 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1088 }
1089 out.push(record);
1090 }
1091 Ok(out)
1092}
1093
1094fn persisted_window_state(
1095 conn: &Connection,
1096 base: &WindowBase,
1097) -> Result<WindowState, QueryReadFailure> {
1098 let mut stmt = conn.prepare(
1099 "SELECT windows.unit, subscriptions.state_json
1100 FROM _syncular_windows AS windows
1101 JOIN _syncular_subscriptions AS subscriptions
1102 ON subscriptions.id = windows.sub_id
1103 WHERE windows.base = ?1
1104 ORDER BY windows.unit ASC",
1105 )?;
1106 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
1107 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1108 })?;
1109 let mut units = Vec::new();
1110 let mut pending = Vec::new();
1111 for row in rows {
1112 let (unit, raw) = row?;
1113 let state: Value = serde_json::from_str(&raw)
1114 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
1115 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
1116 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
1117 || state
1118 .get("bootstrapState")
1119 .is_some_and(|value| !value.is_null());
1120 if is_pending {
1121 pending.push(unit.clone());
1122 }
1123 units.push(unit);
1124 }
1125 Ok(WindowState { units, pending })
1126}
1127
1128#[derive(Debug, Clone, Copy)]
1130pub struct SnapshotStatement<'a> {
1131 pub sql: &'a str,
1132 pub params: &'a [Value],
1133}
1134
1135#[derive(Debug, Clone, Default)]
1139pub struct SnapshotReadRequest<'a> {
1140 pub statements: Vec<SnapshotStatement<'a>>,
1141 pub coverage: &'a [WindowCoverage],
1142 pub subscriptions: Vec<String>,
1143 pub commit_ids: Vec<String>,
1144}
1145
1146#[cfg(test)]
1147thread_local! {
1148 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
1152 std::cell::RefCell::new(None);
1153}
1154
1155#[cfg(test)]
1156fn run_snapshot_read_hook() {
1157 SNAPSHOT_READ_HOOK.with(|slot| {
1158 if let Some(hook) = slot.borrow_mut().take() {
1159 hook();
1160 }
1161 });
1162}
1163
1164fn snapshot_read_connection(
1169 conn: &Connection,
1170 request: &SnapshotReadRequest<'_>,
1171) -> Result<SnapshotRead, QueryReadFailure> {
1172 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
1173 let corrupt = |message: &str| QueryReadFailure {
1174 code: Some("sync.local_corrupt"),
1175 sqlite_code: None,
1176 sqlite_message: None,
1177 rollback_failure: None,
1178 message: message.to_owned(),
1179 };
1180 let stored_failure = |error: rusqlite::Error, message: &str| match error {
1181 rusqlite::Error::Utf8Error(..)
1182 | rusqlite::Error::InvalidColumnType(..)
1183 | rusqlite::Error::FromSqlConversionFailure(..)
1184 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
1185 _ => QueryReadFailure::from(error),
1186 };
1187 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
1188 let revision: Option<String> = conn
1191 .query_row(
1192 "SELECT value FROM _syncular_meta WHERE key = ?1",
1193 rusqlite::params![LOCAL_REVISION_KEY],
1194 |row| row.get(0),
1195 )
1196 .optional()
1197 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
1198 let revision = match revision {
1199 None => "0".to_owned(),
1200 Some(value)
1201 if value == "0"
1202 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1203 && value.bytes().all(|b| b.is_ascii_digit())
1204 && value.parse::<u64>().is_ok()) =>
1205 {
1206 value
1207 }
1208 Some(_) => return Err(corrupt("persisted local revision is invalid")),
1209 };
1210 let mut queries = Vec::with_capacity(request.statements.len());
1211 for statement in &request.statements {
1212 queries.push(query_connection(conn, statement.sql, statement.params)?);
1213 #[cfg(test)]
1214 run_snapshot_read_hook();
1215 }
1216 let mut pending = Vec::new();
1217 let mut missing = Vec::new();
1218 for requested in request.coverage {
1219 let base_key = window_base_key(&requested.base);
1220 let state = persisted_window_state(conn, &requested.base)?;
1221 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
1222 let reference = WindowUnitRef {
1223 base_key: base_key.clone(),
1224 unit: unit.clone(),
1225 };
1226 if !state.units.iter().any(|held| held == &unit) {
1227 missing.push(reference);
1228 } else if state.pending.iter().any(|held| held == &unit) {
1229 pending.push(reference);
1230 }
1231 }
1232 }
1233 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
1234 for id in &request.subscriptions {
1235 let row: Option<(String, String)> = conn
1236 .query_row(
1237 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
1238 rusqlite::params![id],
1239 |row| Ok((row.get(0)?, row.get(1)?)),
1240 )
1241 .optional()
1242 .map_err(|error| {
1243 stored_failure(error, "persisted subscription state is invalid")
1244 })?;
1245 let Some((table, raw)) = row else {
1246 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
1247 continue;
1248 };
1249 let state: Value = serde_json::from_str(&raw)
1252 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
1253 let status = match state.get("status").and_then(Value::as_str) {
1254 Some(value @ ("active" | "revoked" | "failed")) => value,
1255 _ => return Err(corrupt("persisted subscription state is invalid")),
1256 };
1257 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
1258 return Err(corrupt("persisted subscription state is invalid"));
1259 };
1260 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
1261 return Err(corrupt("persisted subscription state is invalid"));
1262 }
1263 let effective_scopes = state
1264 .get("effectiveScopes")
1265 .filter(|value| !value.is_null());
1266 if let Some(scopes) = effective_scopes {
1267 json_to_scope_map(scopes)
1268 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
1269 }
1270 let has_resume_token = match state.get("bootstrapState") {
1271 None | Some(Value::Null) => false,
1272 Some(Value::String(_)) => true,
1273 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
1274 };
1275 let reason_code = match state.get("reasonCode") {
1278 None | Some(Value::Null) => None,
1279 Some(Value::String(value)) => Some(value.clone()),
1280 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
1281 };
1282 let active = status == "active";
1283 subscriptions.push(SubscriptionCatchup::Known {
1284 id: id.clone(),
1285 table,
1286 status: status.to_owned(),
1287 cursor,
1288 has_resume_token,
1289 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
1290 known_pending_pages: active && (has_resume_token || cursor < 0),
1291 effective_scopes: effective_scopes.cloned(),
1292 reason_code,
1293 });
1294 }
1295 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
1296 for id in &request.commit_ids {
1297 let pending: bool = conn.query_row(
1298 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
1299 rusqlite::params![id],
1300 |row| row.get(0),
1301 )?;
1302 if pending {
1303 deliveries.push(CommitDelivery::Pending {
1304 client_commit_id: id.clone(),
1305 });
1306 continue;
1307 }
1308 let row = conn
1309 .query_row(
1310 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
1311 resolution, resolved_at_ms, replacement_client_commit_id
1312 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
1313 rusqlite::params![id],
1314 StoredCommitOutcomeRow::from_row,
1315 )
1316 .optional()
1317 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
1318 deliveries.push(match row {
1319 Some(row) => CommitDelivery::Known {
1320 client_commit_id: id.clone(),
1321 outcome: SyncClient::outcome_from_row(row)
1322 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
1323 },
1324 None => CommitDelivery::Unknown {
1325 client_commit_id: id.clone(),
1326 },
1327 });
1328 }
1329 Ok(SnapshotRead {
1330 revision,
1331 queries,
1332 coverage: CoverageSnapshot {
1333 complete: pending.is_empty() && missing.is_empty(),
1334 pending,
1335 missing,
1336 },
1337 subscriptions,
1338 deliveries,
1339 })
1340 })();
1341 match result {
1342 Ok(read) => {
1343 conn.execute_batch("RELEASE syncular_snapshot_read")?;
1344 Ok(read)
1345 }
1346 Err(mut error) => {
1347 if let Err(rollback) = conn
1348 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
1349 {
1350 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
1351 }
1352 Err(error)
1353 }
1354 }
1355}
1356
1357fn snapshot_connection(
1358 conn: &Connection,
1359 sql: &str,
1360 params: &[Value],
1361 coverage: &[WindowCoverage],
1362) -> Result<QuerySnapshot, QueryReadFailure> {
1363 let read = snapshot_read_connection(
1364 conn,
1365 &SnapshotReadRequest {
1366 statements: vec![SnapshotStatement { sql, params }],
1367 coverage,
1368 subscriptions: Vec::new(),
1369 commit_ids: Vec::new(),
1370 },
1371 )?;
1372 Ok(QuerySnapshot {
1373 revision: read.revision,
1374 rows: read
1375 .queries
1376 .into_iter()
1377 .next()
1378 .expect("single-query snapshot returns one result"),
1379 coverage: read.coverage,
1380 })
1381}
1382
1383pub struct FileQuerySnapshotReader {
1388 path: String,
1389 conn: Option<Connection>,
1390}
1391
1392impl FileQuerySnapshotReader {
1393 #[must_use]
1394 pub fn new(path: impl Into<String>) -> Self {
1395 Self {
1396 path: path.into(),
1397 conn: None,
1398 }
1399 }
1400
1401 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
1402 if self.conn.is_none() {
1403 let conn = Connection::open_with_flags(
1404 &self.path,
1405 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
1406 )
1407 .map_err(QueryReadFailure::from)?;
1408 conn.busy_timeout(std::time::Duration::from_millis(250))
1409 .map_err(QueryReadFailure::from)?;
1410 self.conn = Some(conn);
1411 }
1412 self.conn
1413 .as_ref()
1414 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
1415 }
1416
1417 pub fn snapshot_read(
1420 &mut self,
1421 request: &SnapshotReadRequest<'_>,
1422 ) -> Result<SnapshotRead, QueryReadFailure> {
1423 snapshot_read_connection(self.connection()?, request)
1424 }
1425
1426 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
1429 Ok(self
1430 .snapshot_read(&SnapshotReadRequest::default())?
1431 .revision)
1432 }
1433
1434 pub fn subscription_catchup(
1436 &mut self,
1437 id: &str,
1438 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
1439 let read = self.snapshot_read(&SnapshotReadRequest {
1440 subscriptions: vec![id.to_owned()],
1441 ..Default::default()
1442 })?;
1443 Ok(read
1444 .subscriptions
1445 .into_iter()
1446 .next()
1447 .expect("one requested catch-up"))
1448 }
1449
1450 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
1453 let read = self.snapshot_read(&SnapshotReadRequest {
1454 commit_ids: vec![id.to_owned()],
1455 ..Default::default()
1456 })?;
1457 Ok(read
1458 .deliveries
1459 .into_iter()
1460 .next()
1461 .expect("one requested delivery"))
1462 }
1463
1464 pub fn query_snapshot(
1467 &mut self,
1468 sql: &str,
1469 params: &[Value],
1470 coverage: &[WindowCoverage],
1471 ) -> Result<QuerySnapshot, QueryReadFailure> {
1472 snapshot_connection(self.connection()?, sql, params, coverage)
1473 }
1474}
1475
1476fn validate_authority_reads(
1477 schema: &ClientSchema,
1478 declarations: &[crate::api::AuthorityReadDeclaration],
1479) -> Result<(), String> {
1480 let mut seen = BTreeSet::new();
1481 for read in declarations {
1482 let table = schema
1483 .table(&read.table)
1484 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
1485 let plain = |name: &str| {
1486 table.columns.iter().enumerate().any(|(i, c)| {
1487 c.name == name
1488 && name != "authority_version"
1489 && !name.starts_with("_sync")
1490 && !table.encrypted_columns.iter().any(|e| e.index == i)
1491 && matches!(
1492 c.ty,
1493 ssp2::segment::ColumnType::String
1494 | ssp2::segment::ColumnType::Integer
1495 | ssp2::segment::ColumnType::Float
1496 | ssp2::segment::ColumnType::Boolean
1497 | ssp2::segment::ColumnType::Json
1498 )
1499 })
1500 };
1501 if !seen.insert(&read.table)
1502 || read.columns.is_empty()
1503 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
1504 || !read.columns.contains(&table.primary_key)
1505 || !read.columns.iter().all(|c| plain(c))
1506 || read.scopes.is_empty()
1507 || !read.scopes.iter().all(|(v, values)| {
1508 table
1509 .scope_column(v)
1510 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
1511 && !values.is_empty()
1512 && values.iter().all(|v| !v.is_empty() && v != "*")
1513 })
1514 {
1515 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
1516 }
1517 }
1518 Ok(())
1519}
1520
1521fn schema_marker_read_failure(error: rusqlite::Error) -> String {
1526 let failure = QueryReadFailure::from(error);
1527 if failure.code.is_some() {
1528 failure.to_string()
1529 } else {
1530 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
1531 }
1532}
1533
1534fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
1538 if limits.outcome_retention_max_entries == Some(0) {
1539 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
1540 }
1541 if [
1542 limits.max_push_commits_per_request,
1543 limits.max_push_operations_per_request,
1544 limits.max_push_request_bytes,
1545 ]
1546 .into_iter()
1547 .flatten()
1548 .any(|value| value == 0 || value > u32::MAX as usize)
1549 {
1550 return Err(
1551 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
1552 );
1553 }
1554 Ok(())
1555}
1556
1557fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
1558 let corrupt =
1559 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
1560 let table_type: Option<String> = conn
1561 .query_row(
1562 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
1563 [],
1564 |row| row.get(0),
1565 )
1566 .optional()
1567 .map_err(schema_marker_read_failure)?;
1568 let Some(table_type) = table_type else {
1569 return Ok(None);
1570 };
1571 if table_type != "table" {
1572 return Err(corrupt());
1573 }
1574 let mut statement = conn
1578 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
1579 .map_err(schema_marker_read_failure)?;
1580 let mut rows = statement
1581 .query([LOCAL_SCHEMA_VERSION_KEY])
1582 .map_err(schema_marker_read_failure)?;
1583 let value: Option<String> = rows
1584 .next()
1585 .map_err(schema_marker_read_failure)?
1586 .map(|row| row.get(0))
1587 .transpose()
1588 .map_err(schema_marker_read_failure)?;
1589 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
1590 return Err(corrupt());
1591 }
1592 let Some(value) = value else {
1593 let has_descriptor = conn
1594 .query_row(
1595 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
1596 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
1597 |_| Ok(()),
1598 )
1599 .optional()
1600 .map_err(schema_marker_read_failure)?
1601 .is_some();
1602 if has_descriptor {
1603 return Err(corrupt());
1604 }
1605 return Ok(None);
1606 };
1607 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
1608 || !value.bytes().all(|b| b.is_ascii_digit())
1609 {
1610 return Err(corrupt());
1611 }
1612 let version = value.parse::<i32>().map_err(|_| corrupt())?;
1613 if version > requested {
1614 return Err(
1615 "client.schema_downgrade: persisted local schema is newer than the requested schema"
1616 .to_owned(),
1617 );
1618 }
1619 Ok(Some(version))
1620}
1621
1622#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
1626fn prepare_wasm_replica(path: &str) -> Result<(), String> {
1627 if path.is_empty() || path == ":memory:" {
1628 return Ok(());
1629 }
1630 crate::wasm_vfs::disable_foreign_lock_check()
1631}
1632
1633#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
1634fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
1635 Ok(())
1636}
1637
1638impl SyncClient {
1639 pub fn new_with_identity(
1640 client_id: Option<String>,
1641 schema_json: &Value,
1642 limits: ClientLimits,
1643 ) -> Result<Self, String> {
1644 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1645 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1646 Self::with_connection(resolved, schema_json, limits, conn)
1647 }
1648
1649 pub fn new(
1650 client_id: String,
1651 schema_json: &Value,
1652 limits: ClientLimits,
1653 ) -> Result<Self, String> {
1654 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
1655 Self::with_connection(client_id, schema_json, limits, conn)
1656 }
1657
1658 pub fn open_path(
1664 client_id: String,
1665 schema_json: &Value,
1666 limits: ClientLimits,
1667 path: &str,
1668 ) -> Result<Self, String> {
1669 validate_client_limits(&limits)?;
1670 parse_schema_json(schema_json)?;
1671 prepare_wasm_replica(path)?;
1672 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1673 Self::with_connection(client_id, schema_json, limits, conn)
1674 }
1675
1676 pub fn open_path_with_identity(
1680 client_id: Option<String>,
1681 schema_json: &Value,
1682 limits: ClientLimits,
1683 path: &str,
1684 ) -> Result<Self, String> {
1685 Self::open_path_with_identity_journal(
1686 client_id,
1687 schema_json,
1688 limits,
1689 path,
1690 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
1691 )
1692 }
1693
1694 fn open_path_with_identity_journal(
1695 client_id: Option<String>,
1696 schema_json: &Value,
1697 limits: ClientLimits,
1698 path: &str,
1699 require_wal: bool,
1700 ) -> Result<Self, String> {
1701 validate_client_limits(&limits)?;
1702 parse_schema_json(schema_json)?;
1705 prepare_wasm_replica(path)?;
1706 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1707 conn.busy_timeout(std::time::Duration::from_millis(250))
1715 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
1716 conn.pragma_update(None, "synchronous", "FULL")
1721 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
1722 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
1723 if require_wal {
1727 let journal_mode: String = client
1728 .conn
1729 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
1730 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1731 if journal_mode != "wal" {
1732 return Err(
1733 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
1734 );
1735 }
1736 }
1737 Ok(client)
1738 }
1739
1740 pub fn reset_replica(path: &str) -> Result<(), String> {
1754 if path.is_empty() || path == ":memory:" {
1755 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
1756 }
1757 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
1761 if vfs.is_null() {
1762 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
1763 }
1764 let delete = unsafe { (*vfs).xDelete }
1765 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
1766 for suffix in ["-wal", "-shm", "-journal", ""] {
1767 let file_name = format!("{path}{suffix}");
1768 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
1769 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
1770 })?;
1771 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
1774 if code != rusqlite::ffi::SQLITE_OK && code != rusqlite::ffi::SQLITE_IOERR_DELETE_NOENT
1777 {
1778 return Err(format!(
1779 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
1780 ));
1781 }
1782 }
1783 Ok(())
1784 }
1785
1786 #[must_use]
1787 pub fn client_id(&self) -> &str {
1788 &self.client_id
1789 }
1790
1791 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> Result<String, String> {
1798 self.disconnect_realtime(transport);
1799 let client_id = uuid::Uuid::new_v4().to_string();
1800 self.set_meta(CLIENT_ID_KEY, &client_id)?;
1801 self.client_id.clone_from(&client_id);
1802 self.round_failure = None;
1803 Ok(client_id)
1804 }
1805
1806 pub fn with_connection(
1813 client_id: String,
1814 schema_json: &Value,
1815 limits: ClientLimits,
1816 conn: Connection,
1817 ) -> Result<Self, String> {
1818 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
1819 }
1820
1821 fn with_connection_identity(
1824 client_id: Option<String>,
1825 schema_json: &Value,
1826 limits: ClientLimits,
1827 conn: Connection,
1828 ) -> Result<Self, String> {
1829 validate_client_limits(&limits)?;
1830 let schema = parse_schema_json(schema_json)?;
1831 validate_authority_reads(&schema, &limits.authority_reads)?;
1832 let previous_version = match limits.previous_version_context {
1835 Some(config) => {
1836 config.validate()?;
1837 Some(config)
1838 }
1839 None => None,
1840 };
1841 let mut client = SyncClient {
1842 #[cfg(feature = "bench-internals")]
1843 benchmark_phases: Recorder::default(),
1844 conn,
1845 schema,
1846 client_id: client_id
1847 .clone()
1848 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
1849 limits,
1850 subs: Shared::default(),
1851 outbox: Shared::default(),
1852 failed_commits: Shared::default(),
1853 retain_failed_commits: false,
1854 conflicts: Shared::default(),
1855 rejections: Shared::default(),
1856 schema_floor: None,
1857 lease_state: None,
1858 stopped: false,
1859 upgrading: false,
1860 sync_needed: false,
1861 realtime_policy: RealtimePolicy::Optional,
1862 realtime_state: RealtimeState::Disconnected,
1863 realtime_reason_code: None,
1864 realtime_retry_delay_ms: None,
1865 presence: HashMap::new(),
1866 now_ms: None,
1867 encryption: crate::values::EncryptionConfig::default(),
1868 security_preflight: false,
1869 security_epoch: 0,
1870 transport_enabled: true,
1871 transport_epoch: 0,
1872 subscription_epoch: 0,
1873 previous_version,
1874 insert_sql: RefCell::new(HashMap::new()),
1875 overlay_dirty: OverlayDirty::clean(),
1876 storage_failure: RefCell::new(None),
1877 #[cfg(test)]
1878 overlay_rebuild_count: Cell::new(0),
1879 #[cfg(test)]
1880 acknowledged_replay_count: Cell::new(0),
1881 #[cfg(test)]
1882 outcome_prune_count: Cell::new(0),
1883 progress: ProgressObserver::default(),
1884 change_queue: VecDeque::new(),
1885 sync_intent_queue: VecDeque::new(),
1886 retry_delay_ms: 250,
1887 round_retry_delay_ms: None,
1888 round_failure: None,
1889 active_round: None,
1890 last_round: None,
1891 last_change: None,
1892 query_failures: Vec::new(),
1893 coalesced: None,
1894 coalesced_failure: None,
1895 };
1896 client
1900 .conn
1901 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
1902 client
1908 .conn
1909 .execute_batch("BEGIN IMMEDIATE")
1910 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1911 let startup = (|| -> Result<(), String> {
1912 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
1915 client.create_bookkeeping_tables()?;
1918 match client.get_meta(CLIENT_ID_KEY)? {
1919 Some(existing) => match client_id {
1920 Some(requested) if existing != requested => {
1921 return Err(format!(
1922 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
1923 ))
1924 }
1925 _ => client.client_id = existing,
1926 },
1927 None => client.set_meta(CLIENT_ID_KEY, &client.client_id)?,
1928 }
1929 client.restore_persisted_state()?;
1930 client.failed_commits = client.load_failed_commits()?.into();
1931 match marker {
1932 None => {
1933 client.create_synced_tables()?;
1934 client.persist_schema_version()?;
1935 client.save_subscription_scope_schema()?;
1936 }
1937 Some(version) if version == client.schema.version => {
1938 client.create_synced_tables()?;
1939 set_local_schema_descriptor(&client.conn, &client.schema)
1943 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
1944 client.save_subscription_scope_schema()?;
1945 }
1946 Some(_) => client.run_schema_reset()?,
1947 }
1948 client.reconcile_previous_version_at_boot()?;
1954 client.clear_satisfied_persisted_schema_floor()?;
1955 client.prune_unknown_subscriptions(false)?;
1956 client.drop_unencodable_outbox(false, None)?;
1965 if marker == Some(client.schema.version)
1966 && (!client.outbox.is_empty()
1967 || !client.failed_commits.is_empty()
1968 || client.has_acknowledged_rows()?)
1969 {
1970 client.overlay_dirty.set(true);
1974 client.rebuild_overlay()?;
1975 }
1976 client.enqueue_startup_sync_if_needed()?;
1983 Ok(())
1984 })();
1985 match startup {
1986 Ok(()) => match client.conn.execute_batch("COMMIT") {
1987 Ok(()) => Ok(client),
1988 Err(error) => {
1989 let failure = Self::sqlite_failure(&client.storage_failure, error);
1990 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
1991 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
1993 }
1994 Err(failure)
1995 }
1996 },
1997 Err(error) => {
1998 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
1999 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
2001 }
2002 Err(error)
2003 }
2004 }
2005 }
2006
2007 pub fn set_now_ms(&mut self, now_ms: i64) {
2010 self.now_ms = Some(now_ms);
2011 }
2012
2013 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
2017 self.encryption = encryption;
2018 }
2019
2020 #[must_use]
2021 pub fn security_lifecycle(&self) -> &'static str {
2022 if self.security_preflight {
2023 "preflight"
2024 } else {
2025 "active"
2026 }
2027 }
2028
2029 #[must_use]
2030 pub fn security_preflight(&self) -> bool {
2031 self.security_preflight
2032 }
2033
2034 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
2036 if self.transport_enabled != enabled {
2037 self.transport_enabled = enabled;
2038 self.transport_epoch = self.transport_epoch.wrapping_add(1);
2039 self.sync_intent_queue.clear();
2040 self.sync_intent_queue
2041 .push_back(if enabled && !self.security_preflight {
2042 SyncIntent::Interactive
2043 } else {
2044 SyncIntent::None
2045 });
2046 }
2047 if !enabled && self.active_round.is_none() {
2049 self.disconnect_realtime(transport);
2050 }
2051 }
2052
2053 pub fn transport_enabled(&self) -> bool {
2054 self.transport_enabled
2055 }
2056
2057 pub fn begin_security_preflight(&mut self) -> Result<(), String> {
2064 self.seal_security_on_teardown();
2065 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1")
2068 }
2069
2070 pub fn seal_security_on_teardown(&mut self) {
2080 self.cancel_sync_round();
2081 self.security_preflight = true;
2082 self.security_epoch = self.security_epoch.wrapping_add(1);
2083 self.encryption = crate::values::EncryptionConfig::default();
2084 self.sync_intent_queue.clear();
2085 }
2086
2087 pub fn activate_security(
2089 &mut self,
2090 encryption: crate::values::EncryptionConfig,
2091 ) -> Result<(), String> {
2092 if !self.security_preflight {
2093 return Err(
2094 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
2095 );
2096 }
2097 self.enqueue_startup_sync_if_needed()?;
2098 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY)?;
2099 self.encryption = encryption;
2100 self.security_preflight = false;
2101 Ok(())
2102 }
2103
2104 fn clock_now_ms(&self) -> i64 {
2105 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
2106 }
2107
2108 fn create_bookkeeping_tables(&self) -> Result<(), String> {
2109 self.conn
2111 .execute_batch(
2112 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
2113 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2114 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
2115 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
2116 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
2117 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
2118 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
2119 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
2120 PRIMARY KEY(commit_id, idx));
2121 CREATE INDEX IF NOT EXISTS _syncular_acked_rows_row
2122 ON _syncular_acked_rows(tbl, id);
2123 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
2124 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
2125 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
2126 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
2127 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2128 client_commit_id TEXT NOT NULL UNIQUE,
2129 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
2130 recorded_at_ms INTEGER NOT NULL,
2131 results_json TEXT NOT NULL,
2132 operations_json TEXT,
2133 resolution TEXT NOT NULL DEFAULT 'active'
2134 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
2135 resolved_at_ms INTEGER,
2136 replacement_client_commit_id TEXT);
2137 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
2138 ON _syncular_commit_outcomes(resolution, seq);
2139 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
2140 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
2141 CREATE TABLE IF NOT EXISTS _syncular_meta (
2142 key TEXT PRIMARY KEY, value TEXT NOT NULL);
2143 CREATE TABLE IF NOT EXISTS _syncular_windows (
2144 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
2145 PRIMARY KEY (base, unit));
2146 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
2147 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
2148 effective_scopes TEXT NOT NULL);
2149 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
2150 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
2151 PRIMARY KEY(commit_id, blob_id));
2152 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
2153 ON _syncular_blob_commit_refs(blob_id);",
2154 )
2155 .map_err(|e| e.to_string())?;
2156 let _ = self
2159 .conn
2160 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
2161 if self.get_meta(LOCAL_REVISION_KEY)?.is_none() {
2162 self.set_meta(LOCAL_REVISION_KEY, "0")?;
2163 }
2164 if self.schema_has_blobs() {
2167 let exists = self
2168 .conn
2169 .query_row(
2170 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
2171 [],
2172 |row| row.get::<_, bool>(0),
2173 )
2174 .map_err(|e| e.to_string())?;
2175 if exists {
2176 let mut statement = self
2177 .conn
2178 .prepare("PRAGMA table_info(_syncular_blobs)")
2179 .map_err(|e| e.to_string())?;
2180 let mut columns = statement
2181 .query_map([], |row| row.get::<_, String>(1))
2182 .map_err(|e| e.to_string())?
2183 .collect::<Result<Vec<_>, _>>()
2184 .map_err(|e| e.to_string())?;
2185 columns.sort();
2186 let mut expected = vec![
2187 "blob_id",
2188 "byte_length",
2189 "bytes",
2190 "created_at_ms",
2191 "media_type",
2192 ];
2193 expected.sort_unstable();
2194 if columns != expected {
2195 return Err(
2196 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
2197 );
2198 }
2199 } else {
2200 self.conn
2201 .execute_batch(
2202 "CREATE TABLE _syncular_blobs(
2203 blob_id TEXT PRIMARY KEY,
2204 bytes BLOB NOT NULL,
2205 byte_length INTEGER NOT NULL,
2206 media_type TEXT,
2207 created_at_ms INTEGER NOT NULL);",
2208 )
2209 .map_err(|e| e.to_string())?;
2210 }
2211 self.conn
2212 .execute_batch(
2213 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
2214 blob_id TEXT PRIMARY KEY,
2215 media_type TEXT,
2216 created_at_ms INTEGER NOT NULL);",
2217 )
2218 .map_err(|e| e.to_string())?;
2219 }
2220 Ok(())
2221 }
2222
2223 fn schema_has_blobs(&self) -> bool {
2225 self.schema
2226 .tables
2227 .iter()
2228 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
2229 }
2230
2231 fn get_meta(&self, key: &str) -> Result<Option<String>, String> {
2234 meta_get(&self.conn, key)
2235 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2236 }
2237
2238 fn persist_schema_version(&self) -> Result<(), String> {
2241 self.conn
2242 .execute(
2243 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
2244 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
2245 )
2246 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
2247 set_local_schema_descriptor(&self.conn, &self.schema)
2248 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2249 }
2250
2251 pub(crate) fn set_meta(&self, key: &str, value: &str) -> Result<(), String> {
2252 meta_set(&self.conn, key, value)
2253 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2254 }
2255
2256 fn delete_meta(&self, key: &str) -> Result<(), String> {
2257 meta_delete(&self.conn, key)
2258 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2259 }
2260
2261 fn load_outbox(&self) -> Result<Vec<Arc<OutboxCommit>>, String> {
2263 let mut stmt = self
2264 .conn
2265 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
2266 .map_err(|error| error.to_string())?;
2267 let rows = stmt
2268 .query_map([], |row| {
2269 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2270 })
2271 .map_err(|error| error.to_string())?;
2272 let mut commits = Vec::new();
2273 for row in rows {
2274 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
2275 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
2276 format!("invalid persisted outbox {client_commit_id:?}: {error}")
2277 })?;
2278 let mut ops = Vec::with_capacity(entries.len());
2279 for entry in entries {
2280 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
2281 ops.push(OutboxOp {
2282 upsert: op == "upsert",
2283 table: entry
2284 .get("table")
2285 .and_then(Value::as_str)
2286 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
2287 .to_owned(),
2288 row_id: entry
2289 .get("rowId")
2290 .and_then(Value::as_str)
2291 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
2292 .to_owned(),
2293 base_version: entry.get("baseVersion").and_then(Value::as_i64),
2294 values: entry.get("values").and_then(Value::as_object).cloned(),
2297 insert: entry.get("insert") == Some(&Value::Bool(true)),
2298 });
2299 }
2300 commits.push(Arc::new(OutboxCommit {
2301 client_commit_id,
2302 ops,
2303 }));
2304 }
2305 Ok(commits)
2306 }
2307
2308 fn restore_persisted_state(&mut self) -> Result<(), String> {
2309 self.subs = {
2310 let mut stmt = self
2311 .conn
2312 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
2313 .map_err(|error| error.to_string())?;
2314 let rows = stmt
2315 .query_map([], |row| {
2316 Ok((
2317 row.get::<_, String>(0)?,
2318 row.get::<_, String>(1)?,
2319 row.get::<_, String>(2)?,
2320 ))
2321 })
2322 .map_err(|error| error.to_string())?;
2323 let mut subscriptions = Vec::new();
2324 for row in rows {
2325 let (id, table, raw) = row.map_err(|error| error.to_string())?;
2326 let state: Value = serde_json::from_str(&raw)
2327 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
2328 let requested = json_to_scope_map(
2329 state.get("requested").unwrap_or(&Value::Object(Map::new())),
2330 )?;
2331 let effective = state
2332 .get("effectiveScopes")
2333 .filter(|value| !value.is_null())
2334 .map(json_to_scope_map)
2335 .transpose()?;
2336 subscriptions.push(Subscription {
2337 id,
2338 table,
2339 requested,
2340 params: state
2341 .get("params")
2342 .and_then(Value::as_str)
2343 .map(str::to_owned),
2344 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
2345 bootstrap_state: state
2346 .get("bootstrapState")
2347 .and_then(Value::as_str)
2348 .map(str::to_owned),
2349 state: SubState::parse(
2350 state
2351 .get("status")
2352 .and_then(Value::as_str)
2353 .unwrap_or("active"),
2354 ),
2355 reason_code: state
2356 .get("reasonCode")
2357 .and_then(Value::as_str)
2358 .map(str::to_owned),
2359 effective,
2360 synced_once: state
2361 .get("syncedOnce")
2362 .and_then(Value::as_bool)
2363 .unwrap_or(false),
2364 });
2365 }
2366 subscriptions.into()
2367 };
2368
2369 self.outbox = self.load_outbox()?.into();
2370
2371 self.prune_commit_outcomes()?;
2372 let active = self.commit_outcomes(CommitOutcomeQuery {
2373 active_only: true,
2374 ..CommitOutcomeQuery::default()
2375 })?;
2376 self.conflicts = active
2377 .iter()
2378 .flat_map(|outcome| outcome.results.iter())
2379 .filter_map(|result| match result {
2380 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
2381 _ => None,
2382 })
2383 .collect::<Vec<_>>()
2384 .into();
2385 self.rejections = active
2386 .iter()
2387 .flat_map(|outcome| outcome.results.iter())
2388 .filter_map(|result| match result {
2389 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
2390 _ => None,
2391 })
2392 .collect::<Vec<_>>()
2393 .into();
2394
2395 self.lease_state = self
2396 .get_meta(LEASE_STATE_KEY)?
2397 .map(|raw| serde_json::from_str(&raw))
2398 .transpose()
2399 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
2400 self.schema_floor = self
2401 .get_meta(SCHEMA_FLOOR_KEY)?
2402 .map(|raw| serde_json::from_str(&raw))
2403 .transpose()
2404 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
2405 self.stopped = self.schema_floor.is_some();
2406 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY)?.as_deref() == Some("1") {
2409 self.security_preflight = true;
2410 }
2411 Ok(())
2412 }
2413
2414 fn save_subscription_scope_schema(&self) -> Result<(), String> {
2415 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
2416 .schema
2417 .tables
2418 .iter()
2419 .map(|table| {
2420 (
2421 table.name.clone(),
2422 table
2423 .scope_variables
2424 .iter()
2425 .map(|scope| {
2426 (
2427 scope.variable.clone(),
2428 [scope.prefix.clone(), scope.column.clone()],
2429 )
2430 })
2431 .collect(),
2432 )
2433 })
2434 .collect();
2435 self.set_meta(
2436 "subscriptionScopeSchema",
2437 &serde_json::to_string(&declarations).expect("scope schema serialization"),
2438 )
2439 }
2440
2441 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
2443 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
2444 self.get_meta("subscriptionScopeSchema")?
2445 .map(|raw| serde_json::from_str(&raw))
2446 .transpose()
2447 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
2448 } else {
2449 None
2450 };
2451 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
2452 let Some(table) = self.schema.table(table_name) else {
2453 return false;
2454 };
2455 if requested
2456 .iter()
2457 .any(|(variable, _)| table.scope_column(variable).is_none())
2458 {
2459 return false;
2460 }
2461 if !bump {
2462 return true;
2463 }
2464 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
2465 return false;
2466 };
2467 let variables: BTreeSet<_> = if requested.is_empty() {
2468 old.keys()
2469 .cloned()
2470 .chain(
2471 table
2472 .scope_variables
2473 .iter()
2474 .map(|scope| scope.variable.clone()),
2475 )
2476 .collect()
2477 } else {
2478 requested
2479 .iter()
2480 .map(|(variable, _)| variable.clone())
2481 .collect()
2482 };
2483 variables.iter().all(|variable| {
2484 let current = table
2485 .scope_variables
2486 .iter()
2487 .find(|scope| &scope.variable == variable);
2488 match (old.get(variable), current) {
2489 (Some(old), Some(current)) => {
2490 old == &[current.prefix.clone(), current.column.clone()]
2491 }
2492 _ => false,
2493 }
2494 })
2495 };
2496 let mut stale_ids: BTreeSet<_> = self
2497 .subs
2498 .iter()
2499 .filter(|sub| !compatible(&sub.table, &sub.requested))
2500 .map(|sub| sub.id.clone())
2501 .collect();
2502 for (id, table, effective) in self.load_pending_evictions()? {
2503 if !compatible(&table, &effective) {
2504 stale_ids.insert(id);
2505 }
2506 }
2507 for id in &stale_ids {
2508 for sql in [
2509 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
2510 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2511 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
2512 ] {
2513 self.conn
2514 .execute(sql, rusqlite::params![id])
2515 .map_err(|error| error.to_string())?;
2516 }
2517 }
2518 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
2519 Ok(())
2520 }
2521
2522 pub fn local_revision(&self) -> Result<u64, String> {
2523 Ok(self
2524 .get_meta(LOCAL_REVISION_KEY)?
2525 .and_then(|value| value.parse().ok())
2526 .unwrap_or(0))
2527 }
2528
2529 pub fn snapshot_read(
2535 &mut self,
2536 request: &SnapshotReadRequest<'_>,
2537 owner: Option<QueryOwner<'_>>,
2538 ) -> Result<SnapshotRead, QueryReadFailure> {
2539 let result = snapshot_read_connection(&self.conn, request);
2540 if let Some(owner) = owner {
2541 self.record_query_read(owner, result.as_ref().err());
2542 }
2543 result
2544 }
2545
2546 #[must_use]
2547 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
2548 SyncStatusSnapshot {
2549 current_schema_version: self.schema.version,
2550 outbox: self.outbox.len(),
2551 upgrading: self.upgrading,
2552 lease_state: self.lease_state.clone(),
2553 schema_floor: self.schema_floor.clone(),
2554 sync_needed: self.sync_needed,
2555 previous_version_context: self.previous_version_status(),
2556 }
2557 }
2558
2559 fn previous_version_status(&self) -> PreviousVersionStatus {
2562 let Some(replica_path) = self.previous_version_replica_path() else {
2563 return PreviousVersionStatus {
2564 present: false,
2565 created_at_ms: None,
2566 };
2567 };
2568 match crate::previous_version::read_previous_version_record(&replica_path) {
2569 Ok(Some(record)) => PreviousVersionStatus {
2570 present: true,
2571 created_at_ms: Some(record.created_at_ms),
2572 },
2573 _ => PreviousVersionStatus {
2574 present: std::path::Path::new(
2575 &crate::previous_version::previous_version_container_path(&replica_path),
2576 )
2577 .exists(),
2578 created_at_ms: None,
2579 },
2580 }
2581 }
2582
2583 pub fn previous_version_snapshot(
2589 &self,
2590 spec: &PreviousVersionReadSpec,
2591 ) -> Result<PreviousVersionSnapshot, String> {
2592 let now_ms = self.clock_now_ms();
2593 crate::previous_version::previous_version_snapshot(
2594 &self.conn,
2595 self.previous_version_replica_path().as_deref(),
2596 self.previous_version.as_ref(),
2597 PreviousVersionLifecycle {
2598 lease_inactive: self.previous_version_lease_inactive(now_ms),
2599 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
2600 coverage_complete: self.previous_version_coverage_complete(),
2601 },
2602 now_ms,
2603 self.schema.version,
2604 spec,
2605 )
2606 }
2607
2608 pub fn previous_version_audit(
2610 &self,
2611 ) -> Result<Option<crate::previous_version::PreviousVersionAudit>, String> {
2612 crate::previous_version::stored_previous_version_audit(&self.conn)
2613 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
2614 }
2615
2616 pub fn previous_version_discard(
2620 &self,
2621 ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2622 self.drop_previous_version()
2623 }
2624
2625 fn drop_previous_version(
2626 &self,
2627 ) -> Result<crate::previous_version::PreviousVersionDiscardOutcome, String> {
2628 let Some(replica_path) = self.previous_version_replica_path() else {
2629 return Ok(crate::previous_version::PreviousVersionDiscardOutcome {
2630 present: false,
2631 discarded: false,
2632 });
2633 };
2634 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
2635 }
2636
2637 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
2639 let Some(lease) = &self.lease_state else {
2640 return false;
2641 };
2642 if lease.error_code.is_some() {
2643 return true;
2644 }
2645 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
2646 }
2647
2648 fn previous_version_coverage_complete(&self) -> bool {
2652 let active = self
2653 .subs
2654 .iter()
2655 .filter(|sub| sub.state == SubState::Active)
2656 .collect::<Vec<_>>();
2657 if active.is_empty() {
2658 return false;
2659 }
2660 active
2661 .iter()
2662 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
2663 }
2664
2665 fn previous_version_lifetime_check(&self) {
2669 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
2670 return;
2671 };
2672 let now_ms = self.clock_now_ms();
2673 if self.previous_version_lease_inactive(now_ms) {
2674 let _ = self.drop_previous_version();
2675 return;
2676 }
2677 let Some(replica_path) = self.previous_version_replica_path() else {
2678 return;
2679 };
2680 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
2681 else {
2682 return;
2683 };
2684 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
2685 let _ = self.drop_previous_version();
2686 return;
2687 }
2688 if now_ms - record.created_at_ms > config.max_age_ms {
2689 let _ = self.drop_previous_version();
2690 return;
2691 }
2692 if self.previous_version_coverage_complete() {
2693 let _ = self.drop_previous_version();
2694 }
2695 }
2696
2697 pub fn diagnostics_snapshot(
2698 &self,
2699 request: &ClientDiagnosticsRequest,
2700 ) -> Result<ClientDiagnosticsSnapshot, String> {
2701 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
2702 return Err(format!(
2703 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
2704 ));
2705 }
2706 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
2707 for sub in self.subs.iter() {
2708 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
2709 let complete =
2710 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
2711 let state = match sub.state {
2712 SubState::Revoked => "revoked",
2713 SubState::Failed => "failed",
2714 SubState::Active if reset => "reset",
2715 SubState::Active if complete => "complete",
2716 SubState::Active => "bootstrapping",
2717 };
2718 subscriptions.insert(
2719 sub.id.clone(),
2720 DiagnosticSubscription {
2721 id: sub.id.clone(),
2722 table: sub.table.clone(),
2723 state: state.to_owned(),
2724 complete,
2725 cursor: Some(sub.cursor),
2726 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
2727 },
2728 );
2729 }
2730 for expected in &request.expected_subscriptions {
2731 if expected.id.is_empty() || expected.table.is_empty() {
2732 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
2733 }
2734 if subscriptions
2735 .get(&expected.id)
2736 .is_some_and(|registered| registered.table != expected.table)
2737 {
2738 subscriptions.insert(
2739 expected.id.clone(),
2740 DiagnosticSubscription {
2741 id: expected.id.clone(),
2742 table: expected.table.clone(),
2743 state: "failed".to_owned(),
2744 complete: false,
2745 cursor: None,
2746 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
2747 },
2748 );
2749 } else {
2750 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
2751 DiagnosticSubscription {
2752 id: expected.id.clone(),
2753 table: expected.table.clone(),
2754 state: "unregistered".to_owned(),
2755 complete: false,
2756 cursor: None,
2757 reason_code: None,
2758 }
2759 });
2760 }
2761 }
2762 let mut ordered_subscriptions = Vec::new();
2763 let mut included = BTreeSet::new();
2764 for expected in &request.expected_subscriptions {
2765 if included.insert(expected.id.clone()) {
2766 if let Some(subscription) = subscriptions.get(&expected.id) {
2767 ordered_subscriptions.push(subscription.clone());
2768 }
2769 }
2770 }
2771 for (id, subscription) in &subscriptions {
2772 if included.insert(id.clone()) {
2773 ordered_subscriptions.push(subscription.clone());
2774 }
2775 }
2776 let subscriptions_truncated =
2777 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
2778 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
2779 let captured_at_ms = self.clock_now_ms();
2780 let lease = if let Some(error_code) = self
2781 .lease_state
2782 .as_ref()
2783 .and_then(|state| state.error_code.clone())
2784 {
2785 ClientDiagnosticsLease {
2786 state: "stopped".to_owned(),
2787 expires_at_ms: self
2788 .lease_state
2789 .as_ref()
2790 .and_then(|state| state.expires_at_ms),
2791 error_code: Some(Self::diagnostic_code(&error_code)),
2792 }
2793 } else if let Some(expires_at_ms) = self
2794 .lease_state
2795 .as_ref()
2796 .and_then(|state| state.expires_at_ms)
2797 {
2798 ClientDiagnosticsLease {
2799 state: if expires_at_ms <= captured_at_ms {
2800 "expired".to_owned()
2801 } else {
2802 "active".to_owned()
2803 },
2804 expires_at_ms: Some(expires_at_ms),
2805 error_code: None,
2806 }
2807 } else {
2808 ClientDiagnosticsLease {
2809 state: "none".to_owned(),
2810 expires_at_ms: None,
2811 error_code: None,
2812 }
2813 };
2814 let connectivity = match self.last_round.as_ref() {
2815 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
2816 Some(round) if round.status == "succeeded" => "online",
2817 Some(round)
2818 if round.status == "failed"
2819 && matches!(
2820 round.cause_kind,
2821 Some(
2822 crate::TransportCauseKind::Network | crate::TransportCauseKind::Connect
2823 )
2824 ) =>
2825 {
2826 "offline"
2827 }
2828 Some(round) if round.status == "failed" && round.http_status.is_some() => "online",
2829 _ => "unknown",
2830 };
2831 Ok(ClientDiagnosticsSnapshot {
2832 version: CLIENT_DIAGNOSTICS_VERSION,
2833 captured_at_ms,
2834 host: ClientDiagnosticsHost {
2835 kind: "direct".to_owned(),
2836 role: "single".to_owned(),
2837 connectivity: connectivity.to_owned(),
2838 realtime: self.realtime_state,
2839 realtime_policy: self.realtime_policy,
2840 realtime_reason_code: self.realtime_reason_code.clone(),
2841 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
2842 },
2843 security_lifecycle: self.security_lifecycle().to_owned(),
2844 schema: ClientDiagnosticsSchema {
2845 current_version: self.schema.version,
2846 upgrading: self.upgrading,
2847 required_version: self
2848 .schema_floor
2849 .as_ref()
2850 .and_then(|floor| floor.required_schema_version),
2851 latest_version: self
2852 .schema_floor
2853 .as_ref()
2854 .and_then(|floor| floor.latest_schema_version),
2855 },
2856 replica: ClientDiagnosticsReplica {
2857 local_revision: self.local_revision()?.to_string(),
2858 sync_needed: self.sync_needed,
2859 pending_outbox: self.outbox.len(),
2860 },
2861 lease,
2862 subscriptions: ordered_subscriptions,
2863 subscriptions_truncated,
2864 last_round: self.last_round.clone(),
2865 last_change: self.last_change.clone(),
2866 storage: self.diagnostics_storage(),
2867 query_failures: self.query_failures.clone(),
2868 })
2869 }
2870
2871 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
2872 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
2873 let page_count: i64 = self
2874 .conn
2875 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
2876 let page_size: i64 = self
2877 .conn
2878 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
2879 let outbox_bytes: i64 = self
2881 .conn
2882 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
2883 .query_row([], |row| row.get(0))?;
2884 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
2885 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
2886 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
2887 let blob_bytes = if self.schema_has_blobs() {
2888 self.conn
2889 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
2890 .query_row([], |row| row.get(0))?
2891 } else {
2892 0
2893 };
2894 let pressure = self
2895 .limits
2896 .blob_cache_max_bytes
2897 .is_some_and(|limit| blob_bytes > limit);
2898 Ok(ClientDiagnosticsStorage {
2899 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
2900 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
2901 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
2902 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
2903 retained_outcome_entries: Some(outcome_entries.max(0)),
2904 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
2905 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
2906 })
2907 };
2908 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
2909 status: "unreadable".to_owned(),
2910 database_bytes_approx: None,
2911 pending_outbox_bytes_approx: None,
2912 retained_outcome_bytes_approx: None,
2913 retained_outcome_entries: None,
2914 blob_cache_bytes_approx: None,
2915 pressure_reason_code: None,
2916 })
2917 }
2918
2919 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
2920 self.change_queue.drain(..).collect()
2921 }
2922
2923 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
2924 let intents = self.sync_intent_queue.drain(..).collect();
2925 if self.transport_enabled {
2926 intents
2927 } else {
2928 vec![SyncIntent::None]
2929 }
2930 }
2931
2932 fn schedule_background_retry(&mut self) -> u64 {
2933 let delay_ms = self.retry_delay_ms;
2934 self.sync_intent_queue
2935 .push_back(SyncIntent::Background { delay_ms });
2936 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
2937 self.round_retry_delay_ms = Some(delay_ms);
2938 delay_ms
2939 }
2940
2941 fn reset_background_retry(&mut self) {
2942 self.retry_delay_ms = 250;
2943 }
2944
2945 fn retryable_transport_code(code: &str) -> bool {
2946 code == "transport.failed"
2947 || code == "transport.timeout"
2948 || code == "transport.unavailable"
2949 || code == "sync.transport_failed"
2950 }
2951
2952 fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
2957 matches!(
2958 details
2959 .as_ref()
2960 .and_then(|details| details.get("causeKind"))
2961 .and_then(serde_json::Value::as_str),
2962 Some("connect" | "network")
2963 )
2964 }
2965
2966 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
2971 if ssp2::model::valid_round_failure_code(&error.code) {
2972 self.round_failure = Some(error.code.clone());
2973 }
2974 SectionError::Transport(error)
2975 }
2976
2977 fn retryable_failure_code(code: &str) -> bool {
2982 Self::retryable_transport_code(code)
2983 || matches!(
2984 code,
2985 "sync.auth_required"
2986 | "sync.auth_lease_required"
2987 | "sync.auth_lease_revoked"
2988 | "sync.internal_error"
2989 | "sync.idempotency_cache_miss"
2990 | "sync.schema_not_ready"
2991 | "sync.segment_expired"
2992 | "sync.rate_limited"
2993 | "sync.websocket_connection_limit"
2994 )
2995 }
2996
2997 fn diagnostic_code(code: &str) -> String {
2998 let valid = !code.is_empty()
2999 && code.len() <= 96
3000 && code.contains('.')
3001 && code
3002 .bytes()
3003 .next()
3004 .is_some_and(|byte| byte.is_ascii_lowercase())
3005 && code.bytes().all(|byte| {
3006 byte.is_ascii_lowercase()
3007 || byte.is_ascii_digit()
3008 || matches!(byte, b'.' | b'_' | b'-')
3009 });
3010 if valid {
3011 code.to_owned()
3012 } else {
3013 "client.unknown_failure".to_owned()
3014 }
3015 }
3016
3017 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
3018 if self.sync_needed != value {
3019 if self.begin_observation("syncular_status").is_ok() {
3020 self.sync_needed = value;
3021 let batch = ChangeAccumulator {
3022 status: true,
3023 ..ChangeAccumulator::default()
3024 };
3025 if self.finish_observation("syncular_status", batch).is_err() {
3026 self.rollback_observation("syncular_status");
3027 }
3028 } else {
3029 self.sync_needed = value;
3030 }
3031 }
3032 if value && interactive {
3033 self.sync_intent_queue.push_back(SyncIntent::Interactive);
3034 }
3035 }
3036
3037 fn sqlite_failure(
3038 storage_failure: &RefCell<Option<QueryReadFailure>>,
3039 error: rusqlite::Error,
3040 ) -> String {
3041 let message = error.to_string();
3042 let failure = QueryReadFailure::from(error);
3043 let code = failure.code;
3044 if code.is_some() && storage_failure.borrow().is_none() {
3045 *storage_failure.borrow_mut() = Some(failure);
3046 }
3047 match code {
3048 Some(code) => format!("{code}: {message}"),
3049 None => message,
3050 }
3051 }
3052
3053 fn begin_observation(&self, name: &str) -> Result<(), String> {
3054 if let Some(failure) = &self.coalesced_failure {
3055 return Err(failure.clone());
3056 }
3057 if self.coalesced.is_some() && self.conn.is_autocommit() {
3060 return Err("the coalesced apply transaction was rolled back".to_owned());
3061 }
3062 self.conn
3063 .execute_batch(&format!("SAVEPOINT {name}"))
3064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3065 }
3066
3067 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
3070 if self.coalesced.is_some() || !self.conn.is_autocommit() {
3071 return Ok(());
3072 }
3073 self.conn
3074 .execute_batch("BEGIN")
3075 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3076 self.coalesced = Some(CoalescedApply {
3077 rows: 0,
3078 rollback: self.capture(),
3079 });
3080 Ok(())
3081 }
3082
3083 fn capture(&self) -> Rollback {
3084 Rollback {
3085 subs: self.subs.clone(),
3086 outbox: self.outbox.clone(),
3087 failed_commits: self.failed_commits.clone(),
3088 conflicts: self.conflicts.clone(),
3089 rejections: self.rejections.clone(),
3090 schema_floor: self.schema_floor.clone(),
3091 lease_state: self.lease_state.clone(),
3092 stopped: self.stopped,
3093 upgrading: self.upgrading,
3094 sync_needed: self.sync_needed,
3095 active_round: self.active_round,
3096 overlay_dirty: self.overlay_dirty.snapshot(),
3097 change_queue: self.change_queue.len(),
3098 sync_intent_queue: self.sync_intent_queue.len(),
3099 last_change: self.last_change.clone(),
3100 }
3101 }
3102
3103 fn restore(&mut self, rollback: Rollback) {
3104 self.subs = rollback.subs;
3105 self.outbox = rollback.outbox;
3106 self.failed_commits = rollback.failed_commits;
3107 self.conflicts = rollback.conflicts;
3108 self.rejections = rollback.rejections;
3109 self.schema_floor = rollback.schema_floor;
3110 self.lease_state = rollback.lease_state;
3111 self.stopped = rollback.stopped;
3112 self.upgrading = rollback.upgrading;
3113 self.sync_needed = rollback.sync_needed;
3114 self.active_round = rollback.active_round;
3115 self.overlay_dirty.restore(rollback.overlay_dirty);
3116 self.change_queue.truncate(rollback.change_queue);
3117 self.sync_intent_queue.truncate(rollback.sync_intent_queue);
3118 self.last_change = rollback.last_change;
3119 }
3120
3121 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
3124 let Some(open) = &self.coalesced else {
3125 return Ok(());
3126 };
3127 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
3128 self.commit_coalesced_apply()?;
3129 self.begin_coalesced_apply()?;
3130 }
3131 if let Some(open) = &mut self.coalesced {
3132 open.rows += rows;
3133 }
3134 Ok(())
3135 }
3136
3137 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
3142 let Some(open) = self.coalesced.take() else {
3143 return Ok(());
3144 };
3145 let committed = if self.conn.is_autocommit() {
3146 Err("the coalesced apply transaction was rolled back".to_owned())
3147 } else {
3148 self.conn
3149 .execute_batch("COMMIT")
3150 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
3151 };
3152 let Err(error) = committed else {
3153 return Ok(());
3154 };
3155 if !self.conn.is_autocommit() {
3156 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3157 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3158 }
3159 }
3160 self.restore(open.rollback);
3161 self.coalesced_failure = Some(error.clone());
3162 Err(error)
3163 }
3164
3165 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
3167 let committed = self.commit_coalesced_apply();
3168 match self.coalesced_failure.take() {
3169 Some(failure) => Err(failure),
3170 None => committed,
3171 }
3172 }
3173
3174 fn rollback_observation(&self, name: &str) {
3175 if let Err(error) = self
3176 .conn
3177 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
3178 {
3179 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
3180 if failure.rollback_failure.is_none() {
3181 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
3182 }
3183 }
3184 }
3185 }
3186
3187 fn finish_observation(
3188 &mut self,
3189 name: &str,
3190 mut batch: ChangeAccumulator,
3191 ) -> Result<(), String> {
3192 #[cfg(feature = "bench-internals")]
3193 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
3194 if self.failed_commits.iter().any(|(commit, _)| {
3195 commit
3196 .ops
3197 .iter()
3198 .any(|operation| batch.tables.contains_key(&operation.table))
3199 }) {
3200 batch.outcomes = true;
3201 }
3202 if !batch.touched() {
3203 self.conn
3204 .execute_batch(&format!("RELEASE {name}"))
3205 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3206 return Ok(());
3207 }
3208 let revision = self
3209 .local_revision()?
3210 .checked_add(1)
3211 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
3212 self.conn
3213 .execute(
3214 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
3215 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
3216 )
3217 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3218 let status = batch.status.then(|| self.status_snapshot());
3219 let event = ClientChangeBatch {
3220 revision: revision.to_string(),
3221 tables: batch
3222 .tables
3223 .into_iter()
3224 .map(|(table, scope_keys)| TableChange {
3225 table,
3226 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
3227 })
3228 .collect(),
3229 windows: batch
3230 .windows
3231 .into_iter()
3232 .map(|((base_key, table), units)| WindowChange {
3233 base_key,
3234 table,
3235 units: units.into_iter().collect(),
3236 })
3237 .collect(),
3238 status,
3239 conflicts_changed: batch.conflicts,
3240 rejections_changed: batch.rejections,
3241 outcomes_changed: batch.outcomes,
3242 applied_commits: Vec::new(),
3243 };
3244 self.conn
3245 .execute_batch(&format!("RELEASE {name}"))
3246 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3247 let mut diagnostic_tables = event
3248 .tables
3249 .iter()
3250 .map(|entry| entry.table.clone())
3251 .collect::<BTreeSet<_>>()
3252 .into_iter()
3253 .collect::<Vec<_>>();
3254 let mut diagnostic_windows = event
3255 .windows
3256 .iter()
3257 .map(|entry| entry.table.clone())
3258 .collect::<BTreeSet<_>>()
3259 .into_iter()
3260 .collect::<Vec<_>>();
3261 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
3262 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
3263 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
3264 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
3265 self.last_change = Some(DiagnosticLastChange {
3266 revision: event.revision.clone(),
3267 recorded_at_ms: self.clock_now_ms(),
3268 tables: diagnostic_tables,
3269 windows: diagnostic_windows,
3270 domains_truncated,
3271 status_changed: event.status.is_some(),
3272 conflicts_changed: event.conflicts_changed,
3273 rejections_changed: event.rejections_changed,
3274 outcomes_changed: event.outcomes_changed,
3275 });
3276 self.change_queue.push_back(event);
3277 Ok(())
3278 }
3279
3280 fn record_scope_map(
3281 &self,
3282 batch: &mut ChangeAccumulator,
3283 table_name: &str,
3284 scopes: &[(String, Vec<String>)],
3285 ) {
3286 let Some(table) = self.schema.table(table_name) else {
3287 return;
3288 };
3289 if table.scope_variables.iter().any(|scope| {
3292 !scopes
3293 .iter()
3294 .any(|(variable, _)| *variable == scope.variable)
3295 }) {
3296 batch.table(table_name);
3297 return;
3298 }
3299 for (variable, values) in scopes {
3300 let Some(scope) = table
3301 .scope_variables
3302 .iter()
3303 .find(|scope| &scope.variable == variable)
3304 else {
3305 continue;
3306 };
3307 for value in values {
3308 batch.scope(table_name, format!("{}:{value}", scope.prefix));
3309 }
3310 }
3311 }
3312
3313 fn record_row_scopes(
3315 &self,
3316 batch: &mut ChangeAccumulator,
3317 table_name: &str,
3318 row_id: &str,
3319 base: bool,
3320 ) -> bool {
3321 let Some(table) = self.schema.table(table_name) else {
3322 return false;
3323 };
3324 if table.scope_variables.is_empty() {
3325 return false;
3326 }
3327 let columns = table
3328 .scope_variables
3329 .iter()
3330 .map(|scope| quote_ident(&scope.column))
3331 .collect::<Vec<_>>()
3332 .join(", ");
3333 let full_table = if base {
3334 base_table(table_name)
3335 } else {
3336 visible_table(table_name)
3337 };
3338 let sql = format!(
3339 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
3340 row_id_predicate(table)
3341 );
3342 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
3343 return false;
3344 };
3345 let values = stmt.query_row(rusqlite::params![row_id], |row| {
3346 let mut values = Vec::with_capacity(table.scope_variables.len());
3347 for index in 0..table.scope_variables.len() {
3348 values.push(row.get::<_, Option<String>>(index)?);
3349 }
3350 Ok(values)
3351 });
3352 let Ok(values) = values else {
3353 return false;
3354 };
3355 let mut recorded = false;
3356 for (scope, value) in table.scope_variables.iter().zip(values) {
3357 if let Some(value) = value {
3358 batch.scope(table_name, format!("{}:{value}", scope.prefix));
3359 recorded = true;
3360 }
3361 }
3362 recorded
3363 }
3364
3365 fn record_commit_changes(
3366 &self,
3367 batch: &mut ChangeAccumulator,
3368 tables: &[String],
3369 changes: &[ssp2::model::Change],
3370 ) {
3371 #[cfg(feature = "bench-internals")]
3372 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
3373 for change in changes {
3374 let Some(table_name) = tables.get(change.table_index as usize) else {
3375 continue;
3376 };
3377 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
3378 if let Some(table) = self.schema.table(table_name) {
3379 for (variable, value) in &change.scopes {
3380 if let Some(scope) = table
3381 .scope_variables
3382 .iter()
3383 .find(|scope| &scope.variable == variable)
3384 {
3385 batch.scope(table_name, format!("{}:{value}", scope.prefix));
3386 precise = true;
3387 }
3388 }
3389 }
3390 if !precise {
3391 batch.table(table_name);
3392 }
3393 }
3394 }
3395
3396 fn scoped_rows_exist(
3397 &self,
3398 table_name: &str,
3399 effective: &[(String, Vec<String>)],
3400 owners: &[Vec<(String, Vec<String>)>],
3401 ) -> bool {
3402 let Some(table) = self.schema.table(table_name) else {
3403 return false;
3404 };
3405 let Ok(Some((predicate, params))) = local_scope_predicate(table, effective, owners) else {
3406 return false;
3407 };
3408 let sql = format!(
3409 "SELECT 1 FROM {} WHERE {predicate} LIMIT 1",
3410 base_table(table_name)
3411 );
3412 self.conn
3413 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
3414 .is_ok()
3415 }
3416
3417 pub fn upgrading(&self) -> bool {
3419 self.upgrading
3420 }
3421
3422 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
3430 let new_schema = parse_schema_json(schema_json)?;
3431 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
3432 self.conn
3433 .execute_batch("BEGIN IMMEDIATE")
3434 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3435 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
3436 let rollback = self.capture();
3442 let guarded = (|| -> Result<(), String> {
3443 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
3448 self.create_bookkeeping_tables()?;
3449 match marker {
3450 Some(version) if version < self.schema.version => self.run_schema_reset()?,
3451 None => {
3452 self.create_synced_tables()?;
3453 self.persist_schema_version()?;
3454 }
3455 _ => {}
3456 }
3457 self.outbox = self.load_outbox()?.into();
3462 self.drop_unencodable_outbox(false, None)?;
3467 if marker == Some(self.schema.version)
3468 && (!self.outbox.is_empty()
3469 || !self.failed_commits.is_empty()
3470 || self.has_acknowledged_rows()?)
3471 {
3472 self.overlay_dirty.set(true);
3473 self.rebuild_overlay()?;
3474 }
3475 self.prune_unknown_subscriptions(false)?;
3476 self.save_subscription_scope_schema()?;
3477 self.enqueue_startup_sync_if_needed()?;
3481 Ok(())
3482 })();
3483 let outcome = match guarded {
3484 Ok(()) => self
3485 .conn
3486 .execute_batch("COMMIT")
3487 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
3488 Err(error) => Err(error),
3489 };
3490 if let Err(error) = outcome {
3491 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3492 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
3494 }
3495 self.schema = prior_schema;
3496 self.restore(rollback);
3497 self.insert_sql.borrow_mut().clear();
3499 return Err(error);
3500 }
3501 Ok(())
3502 }
3503
3504 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
3505 let startup_work = !self.stopped
3506 && (!self.outbox.is_empty()
3507 || self.has_acknowledged_rows()?
3508 || !self.load_pending_evictions()?.is_empty()
3509 || self.subs.iter().any(|sub| sub.state == SubState::Active));
3510 if startup_work {
3511 self.sync_needed = true;
3512 self.sync_intent_queue.push_back(SyncIntent::Interactive);
3513 }
3514 Ok(())
3515 }
3516
3517 fn clear_satisfied_persisted_schema_floor(&mut self) -> Result<(), String> {
3524 let satisfied = self
3525 .schema_floor
3526 .as_ref()
3527 .and_then(|floor| floor.required_schema_version)
3528 .is_some_and(|required| self.schema.version >= required);
3529 if !satisfied {
3530 return Ok(());
3531 }
3532 self.delete_meta(SCHEMA_FLOOR_KEY)?;
3533 self.schema_floor = None;
3534 self.stopped = false;
3535 Ok(())
3536 }
3537
3538 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
3543 let Some(replica_path) = self.previous_version_replica_path() else {
3544 return Ok(());
3545 };
3546 let max_age_ms = self
3547 .previous_version
3548 .filter(|config| config.enabled)
3549 .map(|config| config.max_age_ms);
3550 reconcile_previous_version_at_boot(
3551 &self.conn,
3552 &replica_path,
3553 self.schema.version,
3554 max_age_ms,
3555 self.clock_now_ms(),
3556 )
3557 }
3558
3559 fn previous_version_replica_path(&self) -> Option<String> {
3563 let path = self.conn.path()?;
3564 if path.is_empty() || path.starts_with("file:") {
3565 return None;
3566 }
3567 Some(path.to_owned())
3568 }
3569
3570 fn run_schema_reset(&mut self) -> Result<(), String> {
3576 self.begin_observation("syncular_schema_reset")?;
3577 let rollback = self.capture();
3578 let mut batch = ChangeAccumulator::default();
3579 let result = self
3580 .run_schema_reset_observed(&mut batch, true)
3581 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
3582 if let Err(error) = result {
3583 self.rollback_observation("syncular_schema_reset");
3584 self.restore(rollback);
3585 return Err(error);
3586 }
3587 Ok(())
3588 }
3589
3590 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
3591 if self.get_meta(LOG_EPOCH_KEY)?.is_none() {
3592 self.begin_observation("syncular_epoch_acquisition")?;
3593 let rollback = self.capture();
3594 if let Err(error) = self.conn.execute(
3595 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3596 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3597 ) {
3598 self.rollback_observation("syncular_epoch_acquisition");
3599 return Err(Self::sqlite_failure(&self.storage_failure, error));
3600 }
3601 self.sync_needed = true;
3602 let batch = ChangeAccumulator {
3603 status: true,
3604 ..ChangeAccumulator::default()
3605 };
3606 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
3607 self.rollback_observation("syncular_epoch_acquisition");
3608 self.restore(rollback);
3609 return Err(error);
3610 }
3611 self.sync_intent_queue.push_back(SyncIntent::Interactive);
3612 return Ok(Vec::new());
3613 }
3614 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
3615 self.begin_observation("syncular_log_epoch_reset")?;
3616 let rollback = self.capture();
3617 let mut batch = ChangeAccumulator::default();
3618 let result = self
3619 .run_schema_reset_observed(&mut batch, false)
3620 .and_then(|()| {
3621 self.conn
3622 .execute(
3623 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
3624 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
3625 )
3626 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3627 self.sync_needed = true;
3628 batch.status = true;
3629 self.finish_observation("syncular_log_epoch_reset", batch)
3630 });
3631 if let Err(error) = result {
3632 self.rollback_observation("syncular_log_epoch_reset");
3633 self.restore(rollback);
3634 return Err(error);
3635 }
3636 self.sync_intent_queue.push_back(SyncIntent::Interactive);
3637 Ok(resets)
3638 }
3639
3640 fn run_schema_reset_observed(
3641 &mut self,
3642 batch: &mut ChangeAccumulator,
3643 capture: bool,
3644 ) -> Result<(), String> {
3645 read_local_schema_version(&self.conn, self.schema.version)?;
3651 self.conn
3652 .execute_batch(
3653 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
3654 )
3655 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3656 self.cancel_sync_round();
3657 self.upgrading = true;
3658 batch.status = true;
3659 if self.previous_version.is_some_and(|config| config.enabled) {
3667 let previous_version = self
3668 .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3669 .and_then(|value| value.parse::<i32>().ok())
3670 .unwrap_or(self.schema.version);
3671 let pending = self
3672 .outbox
3673 .iter()
3674 .map(|commit| PendingCommitAudit {
3675 commit_id: &commit.client_commit_id,
3676 operations: commit_audit_operations(commit),
3677 })
3678 .collect::<Vec<_>>();
3679 crate::previous_version::write_previous_version_audit(
3680 &self.conn,
3681 &crate::previous_version::build_previous_version_audit(
3682 &self.schema,
3683 &pending,
3684 previous_version,
3685 self.schema.version,
3686 self.clock_now_ms(),
3687 ),
3688 )?;
3689 }
3690 if let Some(replica_path) = self.previous_version_replica_path() {
3695 sweep_previous_version_container(&self.conn, &replica_path)?;
3696 if capture {
3697 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
3698 let previous_version = self
3699 .get_meta(LOCAL_SCHEMA_VERSION_KEY)?
3700 .and_then(|value| value.parse::<i32>().ok())
3701 .unwrap_or(self.schema.version);
3702 capture_previous_version_from_replica(
3703 &self.conn,
3704 &replica_path,
3705 previous_version,
3706 self.schema.version,
3707 &config,
3708 self.clock_now_ms(),
3709 )?;
3710 }
3711 }
3712 }
3713 for table in &self.schema.tables {
3714 batch.table(&table.name);
3715 }
3716 for (base_key, unit, table) in self.load_registered_window_units() {
3717 batch.window(&base_key, &table, &unit);
3718 }
3719 if capture {
3720 self.prune_unknown_subscriptions(true)?;
3721 }
3722 self.insert_sql.borrow_mut().clear();
3724 self.overlay_dirty.set(true);
3725 let virtual_tables: Vec<String> = {
3733 let mut stmt = self
3734 .conn
3735 .prepare(
3736 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
3737 )
3738 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3739 let rows = stmt
3740 .query_map([], |row| row.get::<_, String>(0))
3741 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3742 rows.filter_map(Result::ok)
3743 .filter(|name| is_synced_table_name(name))
3744 .collect()
3745 };
3746 for name in virtual_tables {
3747 self.conn
3748 .execute_batch(&format!(
3749 "DROP TABLE IF EXISTS {}",
3750 quote_ident(&format!("_syncular_fts_{name}"))
3751 ))
3752 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3753 self.conn
3754 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3755 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3756 }
3757 let existing: Vec<String> = {
3758 let mut stmt = self
3759 .conn
3760 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
3761 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3762 let rows = stmt
3763 .query_map([], |row| row.get::<_, String>(0))
3764 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3765 rows.filter_map(Result::ok)
3766 .filter(|name| is_synced_table_name(name))
3767 .collect()
3768 };
3769 for name in &existing {
3770 if let Some(table) = name.strip_prefix("_syncular_base_") {
3771 batch.table(table);
3772 } else if !name.starts_with("_syncular_") {
3773 batch.table(name);
3774 }
3775 }
3776 for name in existing {
3777 self.conn
3778 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
3779 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3780 }
3781 self.create_synced_tables()?;
3783 for sub in self.subs.iter_mut() {
3785 sub.cursor = -1;
3786 sub.bootstrap_state = None;
3787 sub.effective = None;
3788 sub.state = SubState::Active;
3789 sub.reason_code = None;
3790 sub.synced_once = false;
3791 }
3792 let subs = self.subs.clone();
3793 for sub in subs.iter() {
3794 self.persist_sub(sub)?;
3795 }
3796 self.stopped = false;
3798 self.schema_floor = None;
3799 self.delete_meta(SCHEMA_FLOOR_KEY)?;
3800 self.persist_schema_version()?;
3802 self.save_subscription_scope_schema()?;
3803 self.drop_unencodable_outbox(false, Some(batch))?;
3814 self.rebuild_overlay()?;
3816 Ok(())
3817 }
3818
3819 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
3828 let incompatible = self
3829 .outbox
3830 .iter()
3831 .filter(|commit| {
3832 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
3833 })
3834 .cloned()
3835 .collect::<Vec<_>>();
3836 if incompatible.is_empty() {
3837 return Ok(false);
3838 }
3839 self.drop_outbox_commits(
3840 &incompatible,
3841 OUTBOX_INCOMPATIBLE_CODE,
3842 "the persisted commit cannot encode under the current schema",
3843 )?;
3844 Ok(true)
3845 }
3846
3847 fn drop_outbox_commits(
3851 &mut self,
3852 incompatible: &[Arc<OutboxCommit>],
3853 code: &str,
3854 message: &str,
3855 ) -> Result<(), String> {
3856 let rollback = self.capture();
3862 let owns_transaction = self.conn.is_autocommit();
3863 self.begin_observation("syncular_outbox_drop")?;
3864 let mut batch = ChangeAccumulator::default();
3865 let dropped = (|| -> Result<(), String> {
3866 let mut rejections = Vec::new();
3867 for commit in incompatible {
3868 let results = commit
3869 .ops
3870 .iter()
3871 .enumerate()
3872 .map(|(op_index, operation)| {
3873 let rejection = RejectionRecord {
3874 client_commit_id: commit.client_commit_id.clone(),
3875 op_index: op_index as i32,
3876 code: code.to_owned(),
3877 message: message.to_owned(),
3878 retryable: false,
3879 details: None,
3880 operation: Some(CommitOperation::from(operation)),
3881 };
3882 rejections.push(rejection.clone());
3883 CommitOperationOutcome::Error { rejection }
3884 })
3885 .collect::<Vec<_>>();
3886 self.persist_commit_outcome(
3887 &commit.client_commit_id,
3888 CommitOutcomeStatus::Rejected,
3889 &results,
3890 Some(&commit.ops),
3891 )?;
3892 self.delete_outbox_persisted(&commit.client_commit_id)?;
3893 for operation in &commit.ops {
3896 if self.schema.table(&operation.table).is_some() {
3897 batch.table(&operation.table);
3898 }
3899 }
3900 }
3901 self.prune_commit_outcomes()?;
3902 let incompatible_ids = incompatible
3903 .iter()
3904 .map(|commit| commit.client_commit_id.as_str())
3905 .collect::<BTreeSet<_>>();
3906 self.outbox
3907 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
3908 self.rejections.extend(rejections);
3909 self.overlay_dirty.set(true);
3912 self.rebuild_overlay()?;
3913 batch.status = true;
3914 batch.rejections = true;
3915 batch.outcomes = true;
3916 Ok(())
3917 })();
3918 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
3919 if let Err(error) = result {
3920 if owns_transaction {
3924 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
3925 let mut failure = self.storage_failure.borrow_mut();
3926 let failure =
3927 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
3928 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
3929 }
3930 } else {
3931 self.rollback_observation("syncular_outbox_drop");
3932 }
3933 self.restore(rollback);
3934 return Err(error);
3935 }
3936 Ok(())
3937 }
3938
3939 fn create_synced_tables(&self) -> Result<(), String> {
3942 for table in &self.schema.tables {
3943 for (full, index_prefix) in [
3947 (base_table(&table.name), "_syncular_base_"),
3948 (visible_table(&table.name), ""),
3949 ] {
3950 let mut cols: Vec<String> =
3951 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
3952 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
3953 let sql = format!(
3954 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
3955 cols.join(", "),
3956 quote_ident(&table.primary_key)
3957 );
3958 self.conn
3959 .execute(&sql, [])
3960 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3961 for index in &table.indexes {
3965 let unique = if index.unique { "UNIQUE " } else { "" };
3966 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
3967 let cols_sql = index
3968 .columns
3969 .iter()
3970 .map(|c| quote_ident(c))
3971 .collect::<Vec<_>>()
3972 .join(", ");
3973 let index_sql = format!(
3974 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
3975 );
3976 self.conn
3977 .execute(&index_sql, [])
3978 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
3979 }
3980 }
3981 }
3982 for table in &self.schema.tables {
3985 for index in &table.fts_indexes {
3986 self.create_fts_projection(table, index)?;
3987 }
3988 }
3989 Ok(())
3990 }
3991
3992 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
3993 let count: i64 = self
3994 .conn
3995 .query_row(
3996 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
3997 rusqlite::params![index.name],
3998 |row| row.get(0),
3999 )
4000 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4001 Ok(count > 0)
4002 }
4003
4004 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
4005 for suffix in ["ai", "ad", "au"] {
4006 self.conn
4007 .execute(
4008 &format!(
4009 "DROP TRIGGER IF EXISTS {}",
4010 quote_ident(&format!("{}_{suffix}", index.name))
4011 ),
4012 [],
4013 )
4014 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4015 }
4016 Ok(())
4017 }
4018
4019 fn create_fts_triggers(
4020 &self,
4021 table: &TableSchema,
4022 index: &FtsIndexSchema,
4023 ) -> Result<(), String> {
4024 let fts = quote_ident(&index.name);
4025 let source = visible_table(&table.name);
4026 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4027 let pk = quote_ident(&table.primary_key);
4028 let projection_columns = std::iter::once(source_id.clone())
4029 .chain(index.columns.iter().map(|column| quote_ident(column)))
4030 .collect::<Vec<_>>()
4031 .join(", ");
4032 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
4033 .chain(
4034 index
4035 .columns
4036 .iter()
4037 .map(|column| format!("new.{}", quote_ident(column))),
4038 )
4039 .collect::<Vec<_>>()
4040 .join(", ");
4041 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4042 let delete_new = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)");
4043 let delete_old = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)");
4044 let insert_new = format!("INSERT INTO {mapping}(source_id) VALUES (CAST(new.{pk} AS TEXT)); INSERT INTO {fts} (rowid, {projection_columns}) VALUES ((SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)), {new_values})");
4045 let bi = quote_ident(&format!("{}_bi", index.name));
4046 let ai = quote_ident(&format!("{}_ai", index.name));
4047 let ad = quote_ident(&format!("{}_ad", index.name));
4048 let au = quote_ident(&format!("{}_au", index.name));
4049 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
4050 let sql = format!(
4051 "DROP TRIGGER IF EXISTS {bi};
4052 DROP TRIGGER IF EXISTS {ai};
4053 DROP TRIGGER IF EXISTS {ad};
4054 DROP TRIGGER IF EXISTS {au};
4055 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
4056 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
4057 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
4058 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
4059 );
4060 self.conn
4061 .execute_batch(&sql)
4062 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4063 }
4064
4065 fn rebuild_fts_projection(
4066 &self,
4067 table: &TableSchema,
4068 index: &FtsIndexSchema,
4069 ) -> Result<(), String> {
4070 let fts = quote_ident(&index.name);
4071 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
4072 let pk = quote_ident(&table.primary_key);
4073 let indexed_columns = index
4074 .columns
4075 .iter()
4076 .map(|column| quote_ident(column))
4077 .collect::<Vec<_>>();
4078 let projection_columns = std::iter::once(source_id.clone())
4079 .chain(indexed_columns.iter().cloned())
4080 .collect::<Vec<_>>()
4081 .join(", ");
4082 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4083 let sql = format!(
4084 "DELETE FROM {fts}; DELETE FROM {mapping}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source}; INSERT INTO {mapping}(id, source_id) SELECT rowid, {source_id} FROM {fts};",
4085 columns = indexed_columns.join(", "),
4086 source = visible_table(&table.name),
4087 );
4088 self.conn
4089 .execute_batch(&sql)
4090 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4091 }
4092
4093 fn create_fts_projection(
4094 &self,
4095 table: &TableSchema,
4096 index: &FtsIndexSchema,
4097 ) -> Result<(), String> {
4098 self.begin_observation("syncular_fts_schema")?;
4099 let result = (|| {
4100 let existed = self.fts_projection_exists(index)?;
4101 let tokenizer = index.tokenize.replace('\'', "''");
4102 let columns = index
4103 .columns
4104 .iter()
4105 .map(|column| quote_ident(column))
4106 .collect::<Vec<_>>()
4107 .join(", ");
4108 let sql = format!(
4109 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
4110 fts = quote_ident(&index.name),
4111 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
4112 );
4113 self.conn.execute(&sql, []).map_err(|error| {
4114 format!(
4115 "cannot create local FTS5 projection {:?}: {error}",
4116 index.name
4117 )
4118 })?;
4119 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
4120 let mapped: bool = self
4121 .conn
4122 .query_row(
4123 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
4124 [format!("_syncular_fts_{}", index.name)],
4125 |row| row.get(0),
4126 )
4127 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4128 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4129 if !mapped && existed {
4130 self.conn
4131 .execute_batch(&format!(
4132 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
4133 quote_ident(FTS_SOURCE_ID_COLUMN),
4134 quote_ident(&index.name)
4135 ))
4136 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4137 }
4138 self.create_fts_triggers(table, index)?;
4139 if !existed {
4140 self.rebuild_fts_projection(table, index)?;
4141 }
4142 Ok(())
4143 })();
4144 if let Err(error) = result {
4145 self.rollback_observation("syncular_fts_schema");
4146 return Err(error);
4147 }
4148 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
4149 self.rollback_observation("syncular_fts_schema");
4150 return Err(error.to_string());
4151 }
4152 Ok(())
4153 }
4154
4155 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
4158 let state = serde_json::json!({
4159 "requested": scope_map_to_json(&sub.requested),
4160 "params": sub.params,
4161 "cursor": sub.cursor,
4162 "bootstrapState": sub.bootstrap_state,
4163 "status": sub.state.name(),
4164 "reasonCode": sub.reason_code,
4165 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
4166 "syncedOnce": sub.synced_once,
4167 });
4168 self.conn.execute(
4169 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
4170 rusqlite::params![sub.id, sub.table, state.to_string()],
4171 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4172 }
4173
4174 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
4175 let ops: Vec<Value> = commit
4176 .ops
4177 .iter()
4178 .map(|op| {
4179 let mut entry = serde_json::json!({
4180 "op": if op.upsert { "upsert" } else { "delete" },
4181 "table": op.table,
4182 "rowId": op.row_id,
4183 "baseVersion": op.base_version,
4184 "values": op.values.clone().map(Value::Object),
4185 });
4186 if op.insert {
4187 entry["insert"] = Value::Bool(true);
4188 }
4189 entry
4190 })
4191 .collect();
4192 self.conn
4193 .execute(
4194 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
4195 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
4196 )
4197 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4198 for operation in &commit.ops {
4199 let Some(values) = &operation.values else {
4200 continue;
4201 };
4202 let Some(table) = self.schema.table(&operation.table) else {
4203 continue;
4204 };
4205 for column in table
4206 .columns
4207 .iter()
4208 .filter(|column| column.ty == ColumnType::BlobRef)
4209 {
4210 let Some(Value::String(raw)) = values.get(&column.name) else {
4211 continue;
4212 };
4213 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
4214 continue;
4215 };
4216 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
4217 continue;
4218 };
4219 self.conn
4220 .execute(
4221 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
4222 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
4223 rusqlite::params![commit.client_commit_id, blob_id],
4224 )
4225 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4226 }
4227 }
4228 Ok(())
4229 }
4230
4231 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
4232 self.conn
4233 .execute(
4234 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
4235 rusqlite::params![client_commit_id],
4236 )
4237 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4238 self.conn
4239 .execute(
4240 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
4241 rusqlite::params![client_commit_id],
4242 )
4243 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4244 self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
4245 .map(|_| ())
4246 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4247 }
4248
4249 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
4250 match status {
4251 CommitOutcomeStatus::Applied => "applied",
4252 CommitOutcomeStatus::Cached => "cached",
4253 CommitOutcomeStatus::Conflict => "conflict",
4254 CommitOutcomeStatus::Rejected => "rejected",
4255 }
4256 }
4257
4258 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
4259 match resolution {
4260 CommitOutcomeResolution::Active => "active",
4261 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
4262 CommitOutcomeResolution::Superseded => "superseded",
4263 CommitOutcomeResolution::Dismissed => "dismissed",
4264 }
4265 }
4266
4267 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
4268 match value {
4269 "applied" => Ok(CommitOutcomeStatus::Applied),
4270 "cached" => Ok(CommitOutcomeStatus::Cached),
4271 "conflict" => Ok(CommitOutcomeStatus::Conflict),
4272 "rejected" => Ok(CommitOutcomeStatus::Rejected),
4273 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
4274 }
4275 }
4276
4277 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
4278 match value {
4279 "active" => Ok(CommitOutcomeResolution::Active),
4280 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
4281 "superseded" => Ok(CommitOutcomeResolution::Superseded),
4282 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
4283 _ => Err(format!(
4284 "invalid persisted commit outcome resolution {value:?}"
4285 )),
4286 }
4287 }
4288
4289 fn persist_commit_outcome(
4290 &self,
4291 client_commit_id: &str,
4292 status: CommitOutcomeStatus,
4293 results: &[CommitOperationOutcome],
4294 operations: Option<&[OutboxOp]>,
4295 ) -> Result<(), String> {
4296 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
4297 let operations_json = operations
4298 .map(|items| {
4299 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
4300 })
4301 .transpose()
4302 .map_err(|error| error.to_string())?;
4303 self.conn
4304 .execute(
4305 "INSERT INTO _syncular_commit_outcomes (
4306 client_commit_id, status, recorded_at_ms, results_json,
4307 operations_json, resolution
4308 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
4309 rusqlite::params![
4310 client_commit_id,
4311 Self::outcome_status_name(status),
4312 self.clock_now_ms(),
4313 results_json,
4314 operations_json
4315 ],
4316 )
4317 .map(|_| ())
4318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
4319 }
4320
4321 fn stored_cell_is_representable(cell: &Value) -> bool {
4324 cell.is_null()
4325 || cell.is_string()
4326 || cell.is_boolean()
4327 || cell.is_number()
4328 || is_canonical_bytes_envelope(cell)
4329 }
4330
4331 fn stored_row_is_representable(value: &Value) -> bool {
4332 value
4333 .as_object()
4334 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
4335 }
4336
4337 fn stored_operation_is_representable(value: &Value) -> bool {
4338 value.get("table").is_some_and(Value::is_string)
4339 && value.get("rowId").is_some_and(Value::is_string)
4340 && value.get("baseVersion").is_none_or(|v| {
4341 v.as_i64()
4342 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
4343 })
4344 && match value.get("op").and_then(Value::as_str) {
4345 Some("upsert") => {
4346 value
4347 .get("values")
4348 .is_some_and(Self::stored_row_is_representable)
4349 && value
4350 .get("insert")
4351 .is_none_or(|insert| *insert == Value::Bool(true))
4352 }
4353 Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
4354 _ => false,
4355 }
4356 }
4357
4358 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
4362 serde_json::to_value(CommitOperation::from(operation))
4363 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
4364 }
4365
4366 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
4367 let StoredCommitOutcomeRow {
4368 sequence,
4369 client_commit_id,
4370 status,
4371 recorded_at_ms,
4372 results_json,
4373 operations_json,
4374 resolution,
4375 resolved_at_ms,
4376 replacement_client_commit_id,
4377 } = row;
4378 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
4379 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
4380 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
4381 {
4382 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
4383 }
4384 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
4385 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
4386 for result in &stored {
4387 let nested = match result.get("status").and_then(Value::as_str) {
4388 Some("conflict") => result.get("conflict"),
4389 Some("error") => result.get("rejection"),
4390 _ => None,
4391 };
4392 if nested
4393 .and_then(|record| record.get("operation"))
4394 .is_some_and(|op| !Self::stored_operation_is_representable(op))
4395 || (result.get("status").and_then(Value::as_str) == Some("conflict")
4396 && nested
4397 .and_then(|record| record.get("serverRow"))
4398 .is_some_and(|row| !Self::stored_row_is_representable(row)))
4399 {
4400 return Err(invalid());
4401 }
4402 }
4403 let results: Vec<CommitOperationOutcome> =
4404 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
4405 let has_conflict = results
4406 .iter()
4407 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
4408 let has_error = results
4409 .iter()
4410 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
4411 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
4412 let redacted = results.is_empty() && operations_json.is_none();
4413 if match status {
4414 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
4415 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
4416 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
4417 } {
4418 return Err(invalid());
4419 }
4420 let operations: Option<Vec<CommitOperation>> = operations_json
4421 .map(|raw| {
4422 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
4423 if !stored.iter().all(Self::stored_operation_is_representable) {
4424 return Err(invalid());
4425 }
4426 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
4427 })
4428 .transpose()?;
4429 for result in &results {
4430 match result {
4431 CommitOperationOutcome::Conflict { conflict }
4432 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
4433 {
4434 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
4435 }
4436 CommitOperationOutcome::Error { rejection } => {
4437 if let Some(details) = &rejection.details {
4438 let encoded = serde_json::to_string(details).map_err(|_| {
4439 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4440 })?;
4441 RejectionDetails::parse(&encoded).map_err(|_| {
4442 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
4443 })?;
4444 }
4445 }
4446 _ => {}
4447 }
4448 }
4449 Ok(CommitOutcome {
4450 sequence,
4451 client_commit_id,
4452 status,
4453 recorded_at_ms,
4454 results,
4455 operations,
4456 retained_rows: None,
4457 resolution: Self::parse_outcome_resolution(&resolution)?,
4458 resolved_at_ms,
4459 replacement_client_commit_id,
4460 })
4461 }
4462
4463 fn unique_conflicts(
4465 &self,
4466 table: &TableSchema,
4467 values: &Map<String, Value>,
4468 base: bool,
4469 ) -> Result<Vec<RetainedUniqueConflict>, String> {
4470 let source = if base {
4471 base_table(&table.name)
4472 } else {
4473 visible_table(&table.name)
4474 };
4475 let mut conflicts = Vec::new();
4476 for index in table.indexes.iter().filter(|index| index.unique) {
4477 let mut params = vec![json_param_to_sql(
4478 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
4479 )?];
4480 for name in &index.columns {
4481 let column = table
4482 .columns
4483 .iter()
4484 .find(|column| column.name == *name)
4485 .ok_or("sync.local_corrupt")?;
4486 let cell = json_to_column_value(column, values.get(name))?;
4487 params.push(owned_sql_value(RowParam::Cell(&cell))?);
4488 }
4489 let sql = format!(
4490 "SELECT * FROM {source} WHERE {} != ? AND {}",
4491 quote_ident(&table.primary_key),
4492 index
4493 .columns
4494 .iter()
4495 .map(|column| format!("{} = ?", quote_ident(column)))
4496 .collect::<Vec<_>>()
4497 .join(" AND ")
4498 );
4499 let mut statement = self
4500 .conn
4501 .prepare_cached(&sql)
4502 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4503 let records = statement
4504 .query_map(rusqlite::params_from_iter(params), |record| {
4505 let values = table
4506 .columns
4507 .iter()
4508 .enumerate()
4509 .map(|(index, column)| {
4510 let value = sql_ref_to_json(column, record.get_ref(index)?);
4511 json_to_column_value(column, Some(&value)).map_err(|message| {
4512 rusqlite::Error::ToSqlConversionFailure(message.into())
4513 })
4514 })
4515 .collect::<Result<Row, _>>()?;
4516 Ok((values, record.get::<_, i64>(table.columns.len())?))
4517 })
4518 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4519 for record in records {
4520 let (row, version) =
4521 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4522 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
4523 conflicts.push(RetainedUniqueConflict {
4524 index: index.name.clone(),
4525 columns: index.columns.clone(),
4526 row_id,
4527 server_row: table
4528 .columns
4529 .iter()
4530 .zip(&row)
4531 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
4532 .collect(),
4533 server_version: version,
4534 });
4535 }
4536 }
4537 Ok(conflicts)
4538 }
4539
4540 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
4541 if let Some((commit, initial)) = self
4542 .failed_commits
4543 .iter()
4544 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
4545 {
4546 let mut rows = Vec::new();
4547 for operation in &commit.ops {
4548 let Some(table) = self.schema.table(&operation.table) else {
4550 continue;
4551 };
4552 let key =
4553 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
4554 .map_err(|_| "sync.local_corrupt".to_owned())?;
4555 let base = self.stored_row(table, &operation.row_id, true)?;
4556 rows.push(RetainedCommitRow {
4557 table: operation.table.clone(),
4558 row_id: operation.row_id.clone(),
4559 unique_conflicts: if operation.upsert {
4560 self.unique_conflicts(
4561 table,
4562 initial
4563 .get(&key)
4564 .and_then(Value::as_object)
4565 .or(operation.values.as_ref())
4566 .ok_or("sync.local_corrupt")?,
4567 true,
4568 )?
4569 } else {
4570 Vec::new()
4571 },
4572 local_row: if operation.upsert {
4573 initial
4574 .get(&key)
4575 .and_then(Value::as_object)
4576 .cloned()
4577 .or_else(|| operation.values.clone())
4578 } else {
4579 None
4580 },
4581 server_row: base.as_ref().map(|(values, _)| {
4582 table
4583 .columns
4584 .iter()
4585 .zip(values)
4586 .map(|(column, value)| {
4587 (column.name.clone(), column_value_to_json(value))
4588 })
4589 .collect()
4590 }),
4591 server_version: base.map(|(_, version)| version),
4592 });
4593 }
4594 outcome.retained_rows = Some(rows);
4595 }
4596 Ok(outcome)
4597 }
4598
4599 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
4600 let row = self
4601 .conn
4602 .query_row(
4603 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4604 resolution, resolved_at_ms, replacement_client_commit_id
4605 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
4606 rusqlite::params![client_commit_id],
4607 |row| {
4608 Ok(StoredCommitOutcomeRow {
4609 sequence: row.get(0)?,
4610 client_commit_id: row.get(1)?,
4611 status: row.get(2)?,
4612 recorded_at_ms: row.get(3)?,
4613 results_json: row.get(4)?,
4614 operations_json: row.get(5)?,
4615 resolution: row.get(6)?,
4616 resolved_at_ms: row.get(7)?,
4617 replacement_client_commit_id: row.get(8)?,
4618 })
4619 },
4620 )
4621 .optional()
4622 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4623 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
4624 .transpose()
4625 }
4626
4627 #[doc(hidden)]
4629 pub fn prepare_current_base(
4630 &self,
4631 client_commit_id: &str,
4632 sequence: i64,
4633 ) -> Result<CurrentBasePreparation, CurrentBaseProof> {
4634 let outcome = self
4635 .commit_outcome(client_commit_id)
4636 .map_err(|_| CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed))?;
4637 let valid = outcome.as_ref().is_some_and(|outcome| {
4638 outcome.sequence == sequence
4639 && outcome.resolution == CommitOutcomeResolution::Active
4640 && matches!(
4641 outcome.status,
4642 CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4643 )
4644 && outcome
4645 .operations
4646 .as_ref()
4647 .zip(outcome.retained_rows.as_ref())
4648 .is_some_and(|(operations, rows)| {
4649 !rows.is_empty() && operations.len() == rows.len()
4650 })
4651 });
4652 if !valid {
4653 return Err(CurrentBaseProof::failure(
4654 CurrentBaseProofReason::OutcomeUnavailable,
4655 ));
4656 }
4657 if !self.transport_enabled {
4658 return Err(CurrentBaseProof::failure(CurrentBaseProofReason::Offline));
4659 }
4660 Ok(CurrentBasePreparation {
4661 client_commit_id: client_commit_id.to_owned(),
4662 sequence,
4663 subscriptions: self.subs.to_vec(),
4664 transport_epoch: self.transport_epoch,
4665 subscription_epoch: self.subscription_epoch,
4666 security_epoch: self.security_epoch,
4667 })
4668 }
4669
4670 #[doc(hidden)]
4672 pub fn finish_current_base(
4673 &mut self,
4674 prepared: CurrentBasePreparation,
4675 drive: SyncOutcome,
4676 ) -> CurrentBaseProof {
4677 if !self.transport_enabled || self.transport_epoch != prepared.transport_epoch {
4678 return CurrentBaseProof::failure(CurrentBaseProofReason::Offline);
4679 }
4680 if self.subscription_epoch != prepared.subscription_epoch {
4681 return CurrentBaseProof::failure(CurrentBaseProofReason::MissingCoverage);
4682 }
4683 if self.security_preflight || self.security_epoch != prepared.security_epoch {
4684 return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed);
4685 }
4686 let report = match drive {
4687 SyncOutcome::Ok(report) => report,
4688 SyncOutcome::BudgetExhausted(_) => {
4689 return CurrentBaseProof::failure(CurrentBaseProofReason::BudgetExhausted)
4690 }
4691 _ => return CurrentBaseProof::failure(CurrentBaseProofReason::SyncFailed),
4692 };
4693 if report.schema_floor.is_some() {
4694 return CurrentBaseProof::failure(CurrentBaseProofReason::SchemaFloor);
4695 }
4696 if self
4697 .conn
4698 .execute_batch("SAVEPOINT syncular_base_proof")
4699 .is_err()
4700 {
4701 return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4702 }
4703 let proof = (|| {
4704 let snapshot = snapshot_read_connection(
4705 &self.conn,
4706 &SnapshotReadRequest {
4707 subscriptions: prepared
4708 .subscriptions
4709 .iter()
4710 .map(|sub| sub.id.clone())
4711 .collect(),
4712 commit_ids: vec![prepared.client_commit_id.clone()],
4713 ..Default::default()
4714 },
4715 )
4716 .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4717 let current = self
4718 .commit_outcome(&prepared.client_commit_id)
4719 .map_err(|_| CurrentBaseProofReason::SnapshotFailed)?;
4720 let unavailable = if report.revoked.is_empty() {
4721 CurrentBaseProofReason::OutcomeUnavailable
4722 } else {
4723 CurrentBaseProofReason::Revoked
4724 };
4725 let Some(outcome) = current else {
4726 return Err(unavailable);
4727 };
4728 if !matches!(
4729 snapshot.deliveries.first(),
4730 Some(CommitDelivery::Known { .. })
4731 ) || outcome.sequence != prepared.sequence
4732 || outcome.resolution != CommitOutcomeResolution::Active
4733 || !matches!(
4734 outcome.status,
4735 CommitOutcomeStatus::Conflict | CommitOutcomeStatus::Rejected
4736 )
4737 {
4738 return Err(unavailable);
4739 }
4740 let (Some(operations), Some(rows)) = (outcome.operations, outcome.retained_rows) else {
4741 return Err(unavailable);
4742 };
4743 if rows.is_empty() || operations.len() != rows.len() {
4744 return Err(unavailable);
4745 }
4746 let after: HashMap<&str, &Subscription> =
4747 self.subs.iter().map(|sub| (sub.id.as_str(), sub)).collect();
4748 let complete: HashSet<&str> = snapshot
4749 .subscriptions
4750 .iter()
4751 .filter_map(|sub| match sub {
4752 SubscriptionCatchup::Known {
4753 id,
4754 bootstrap_complete: true,
4755 ..
4756 } => Some(id.as_str()),
4757 _ => None,
4758 })
4759 .collect();
4760 let candidates: Vec<_> = prepared
4761 .subscriptions
4762 .iter()
4763 .filter_map(|before| {
4764 let after = after.get(before.id.as_str())?;
4765 if before.table != after.table
4766 || before.params != after.params
4767 || canonical_scope_json(&before.requested)
4768 != canonical_scope_json(&after.requested)
4769 || !complete.contains(before.id.as_str())
4770 {
4771 return None;
4772 }
4773 after
4774 .effective
4775 .as_ref()
4776 .map(|scopes| (after.table.as_str(), scopes))
4777 })
4778 .collect();
4779 let covered = |table_name: &str, row: &Map<String, Value>| {
4780 let Some(table) = self.schema.table(table_name) else {
4781 return false;
4782 };
4783 candidates.iter().any(|(candidate_table, scopes)| {
4784 *candidate_table == table_name
4785 && !scopes.is_empty()
4786 && scopes.iter().all(|(variable, allowed)| {
4787 table
4788 .scope_column(variable)
4789 .and_then(|column| match row.get(column) {
4790 Some(Value::String(value)) => Some(value.clone()),
4791 Some(Value::Bool(value)) => Some(value.to_string()),
4792 Some(Value::Number(value)) => value.as_f64().map(|number| {
4793 ryu_js::Buffer::new().format_finite(number).to_owned()
4794 }),
4795 _ => None,
4796 })
4797 .is_some_and(|value| !value.is_empty() && allowed.contains(&value))
4798 })
4799 })
4800 };
4801 for row in &rows {
4802 let Some(base) = row.server_row.as_ref().or(row.local_row.as_ref()) else {
4803 return Err(if report.revoked.is_empty() {
4804 CurrentBaseProofReason::MissingCoverage
4805 } else {
4806 CurrentBaseProofReason::Revoked
4807 });
4808 };
4809 if !covered(&row.table, base)
4810 || row
4811 .unique_conflicts
4812 .iter()
4813 .any(|conflict| !covered(&row.table, &conflict.server_row))
4814 {
4815 return Err(if report.revoked.is_empty() {
4816 CurrentBaseProofReason::MissingCoverage
4817 } else {
4818 CurrentBaseProofReason::Revoked
4819 });
4820 }
4821 }
4822 Ok(rows)
4823 })();
4824 if self
4825 .conn
4826 .execute_batch("RELEASE syncular_base_proof")
4827 .is_err()
4828 {
4829 return CurrentBaseProof::failure(CurrentBaseProofReason::SnapshotFailed);
4830 }
4831 match proof {
4832 Ok(rows) => CurrentBaseProof {
4833 proven: true,
4834 rows: Some(rows),
4835 reason: None,
4836 },
4837 Err(reason) => CurrentBaseProof::failure(reason),
4838 }
4839 }
4840
4841 pub fn prove_current_base(
4842 &mut self,
4843 transport: &mut dyn Transport,
4844 client_commit_id: &str,
4845 sequence: i64,
4846 max_rounds: Option<u32>,
4847 ) -> CurrentBaseProof {
4848 let prepared = match self.prepare_current_base(client_commit_id, sequence) {
4849 Ok(prepared) => prepared,
4850 Err(proof) => return proof,
4851 };
4852 let drive = self.sync_until_idle(transport, max_rounds);
4853 self.finish_current_base(prepared, drive)
4854 }
4855
4856 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
4857 if query.limit == Some(0) {
4858 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
4859 }
4860 let mut sql = String::from(
4861 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
4862 resolution, resolved_at_ms, replacement_client_commit_id
4863 FROM _syncular_commit_outcomes",
4864 );
4865 if query.active_only {
4866 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
4867 }
4868 sql.push_str(" ORDER BY seq DESC");
4869 if let Some(limit) = query.limit {
4870 sql.push_str(&format!(" LIMIT {limit}"));
4871 }
4872 let mut stmt = self
4873 .conn
4874 .prepare(&sql)
4875 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4876 let rows = stmt
4877 .query_map([], |row| {
4878 Ok(StoredCommitOutcomeRow {
4879 sequence: row.get(0)?,
4880 client_commit_id: row.get(1)?,
4881 status: row.get(2)?,
4882 recorded_at_ms: row.get(3)?,
4883 results_json: row.get(4)?,
4884 operations_json: row.get(5)?,
4885 resolution: row.get(6)?,
4886 resolved_at_ms: row.get(7)?,
4887 replacement_client_commit_id: row.get(8)?,
4888 })
4889 })
4890 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4891 let mut outcomes = Vec::new();
4892 for row in rows {
4893 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
4894 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
4895 )?)?);
4896 }
4897 Ok(outcomes)
4898 }
4899
4900 fn prune_commit_outcomes(&self) -> Result<(), String> {
4901 #[cfg(test)]
4902 self.outcome_prune_count
4903 .set(self.outcome_prune_count.get() + 1);
4904 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
4905 let count = self
4906 .conn
4907 .query_row(
4908 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
4909 [],
4910 |row| row.get::<_, i64>(0),
4911 )
4912 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
4913 as usize;
4914 let excess = count.saturating_sub(max_entries);
4915 if excess == 0 {
4916 return Ok(());
4917 }
4918 let mut stmt = self
4919 .conn
4920 .prepare(
4921 "SELECT seq FROM _syncular_commit_outcomes
4922 WHERE status IN ('applied', 'cached') OR resolution != 'active'
4923 ORDER BY seq ASC LIMIT ?1",
4924 )
4925 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4926 let rows = stmt
4927 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
4928 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4929 let sequences = rows
4930 .collect::<Result<Vec<_>, _>>()
4931 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4932 drop(stmt);
4933 for sequence in sequences {
4934 self.conn
4935 .execute(
4936 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
4937 rusqlite::params![sequence],
4938 )
4939 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
4940 }
4941 Ok(())
4942 }
4943
4944 pub fn subscribe(
4948 &mut self,
4949 id: String,
4950 table: String,
4951 scopes: Vec<(String, Vec<String>)>,
4952 params: Option<String>,
4953 ) -> Result<(), String> {
4954 self.subscribe_many(vec![SubscriptionRequest {
4955 id,
4956 table,
4957 scopes,
4958 params,
4959 }])
4960 }
4961
4962 fn register_subscription(
4965 &mut self,
4966 id: String,
4967 table: String,
4968 scopes: Vec<(String, Vec<String>)>,
4969 params: Option<String>,
4970 ) -> Result<bool, String> {
4971 if self.schema.table(&table).is_none() {
4972 return Err(format!("unknown table {table:?}"));
4973 }
4974 let registered_at = self
4975 .subs
4976 .iter()
4977 .position(|subscription| subscription.id == id);
4978 if let Some(index) = registered_at {
4979 let existing = &self.subs[index];
4980 let same_intent = existing.table == table
4981 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
4982 && existing.params == params;
4983 if !same_intent {
4984 return Err(
4985 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
4986 .to_owned(),
4987 );
4988 }
4989 if existing.state != SubState::Revoked {
4994 return Ok(false);
4995 }
4996 }
4997 let sub = Subscription {
4998 id: id.clone(),
4999 table,
5000 requested: scopes,
5001 params,
5002 cursor: -1,
5003 bootstrap_state: None,
5004 state: SubState::Active,
5005 reason_code: None,
5006 effective: None,
5007 synced_once: false,
5008 };
5009 self.persist_sub(&sub)?;
5010 match registered_at {
5011 Some(index) => self.subs[index] = sub,
5012 None => self.subs.push(sub),
5013 }
5014 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5015 Ok(true)
5016 }
5017
5018 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
5027 let rollback = self.capture();
5028 self.begin_observation("syncular_subscribe")?;
5029 let mut registered_any = false;
5030 let registered = (|| {
5031 for request in requests {
5032 registered_any |= self.register_subscription(
5033 request.id,
5034 request.table,
5035 request.scopes,
5036 request.params,
5037 )?;
5038 }
5039 self.conn
5040 .execute_batch("RELEASE syncular_subscribe")
5041 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5042 })();
5043 if let Err(error) = registered {
5044 self.rollback_observation("syncular_subscribe");
5045 self.restore(rollback);
5046 return Err(error);
5047 }
5048 if registered_any {
5049 self.set_sync_needed(true, true);
5050 }
5051 Ok(())
5052 }
5053
5054 pub fn unsubscribe(&mut self, id: &str) -> Result<(), String> {
5058 if !self.subs.iter().any(|s| s.id == id) {
5059 return Ok(());
5060 }
5061 self.conn
5062 .execute(
5063 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5064 rusqlite::params![id],
5065 )
5066 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5067 self.cancel_sync_round();
5068 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5069 self.subs.retain(|s| s.id != id);
5070 self.set_sync_needed(true, true);
5071 Ok(())
5072 }
5073
5074 pub fn set_window(
5082 &mut self,
5083 base: &WindowBase,
5084 units: &[String],
5085 ) -> Result<CommandEffects, String> {
5086 let table = self
5087 .schema
5088 .table(&base.table)
5089 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
5090 if table.scope_column(&base.variable).is_none() {
5091 return Err(format!(
5092 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
5093 base.table, base.variable
5094 ));
5095 }
5096 let base_key = window_base_key(base);
5097 let wanted: std::collections::HashSet<&String> = units.iter().collect();
5098 let live = self.load_window_units(&base_key);
5099 self.begin_observation("syncular_window")?;
5100 let mut batch = ChangeAccumulator::default();
5101 let mut changed = false;
5102
5103 let rollback = self.capture();
5104 let widened = (|| {
5105 for unit in units {
5107 if live.iter().any(|(u, _)| u == unit) {
5108 continue;
5109 }
5110 let sub_id = derive_sub_id(base, unit);
5111 self.delete_pending_evict(&sub_id)?;
5112 self.insert_window_unit(&base_key, unit, &sub_id)?;
5113 self.register_subscription(
5114 sub_id,
5115 base.table.clone(),
5116 unit_scopes(base, unit),
5117 base.params.clone(),
5118 )?;
5119 batch.window(&base_key, &base.table, unit);
5120 changed = true;
5121 }
5122
5123 self.finish_observation("syncular_window", batch)
5124 })();
5125 if let Err(error) = widened {
5126 self.rollback_observation("syncular_window");
5127 self.restore(rollback);
5128 return Err(error);
5129 }
5130 for (unit, sub_id) in live {
5131 if wanted.contains(&unit) {
5132 continue;
5133 }
5134 self.rebuild_overlay_if_dirty()?;
5135 let rollback = self.capture();
5136 self.begin_observation("syncular_window")?;
5137 let effective = self
5138 .subs
5139 .iter()
5140 .find(|sub| sub.id == sub_id)
5141 .and_then(|sub| sub.effective.clone())
5142 .unwrap_or_else(|| unit_scopes(base, &unit));
5143 let mut batch = ChangeAccumulator::default();
5144 self.record_scope_map(&mut batch, &base.table, &effective);
5145 batch.window(&base_key, &base.table, &unit);
5146 let remaining =
5147 self.evict_unit(&base_key, base, &unit, &sub_id)
5148 .and_then(|remaining| {
5149 self.finish_observation("syncular_window", batch)
5150 .map(|()| remaining)
5151 });
5152 let remaining = match remaining {
5153 Ok(remaining) => remaining,
5154 Err(error) => {
5155 self.rollback_observation("syncular_window");
5156 self.restore(rollback);
5157 return Err(error);
5158 }
5159 };
5160 if remaining {
5161 self.drain_pending_evictions()?;
5162 }
5163 changed = true;
5164 }
5165 Ok(if changed {
5166 CommandEffects::interactive()
5167 } else {
5168 CommandEffects::none()
5169 })
5170 }
5171
5172 pub fn window_state(&self, base: &WindowBase) -> WindowState {
5177 let mut units = Vec::new();
5178 let mut pending = Vec::new();
5179 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
5180 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
5181 Some(sub) => {
5182 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
5183 }
5184 None => true,
5185 };
5186 if is_pending {
5187 pending.push(unit.clone());
5188 }
5189 units.push(unit);
5190 }
5191 WindowState { units, pending }
5192 }
5193
5194 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
5195 let mut stmt = match self
5196 .conn
5197 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
5198 {
5199 Ok(stmt) => stmt,
5200 Err(_) => return Vec::new(),
5201 };
5202 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
5203 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5204 });
5205 match rows {
5206 Ok(rows) => rows.filter_map(Result::ok).collect(),
5207 Err(_) => Vec::new(),
5208 }
5209 }
5210
5211 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
5212 let mut stmt = match self.conn.prepare(
5213 "SELECT windows.base, windows.unit, subscriptions.tbl
5214 FROM _syncular_windows AS windows
5215 JOIN _syncular_subscriptions AS subscriptions
5216 ON subscriptions.id = windows.sub_id
5217 ORDER BY windows.base, windows.unit",
5218 ) {
5219 Ok(stmt) => stmt,
5220 Err(_) => return Vec::new(),
5221 };
5222 let rows = stmt.query_map([], |row| {
5223 Ok((
5224 row.get::<_, String>(0)?,
5225 row.get::<_, String>(1)?,
5226 row.get::<_, String>(2)?,
5227 ))
5228 });
5229 match rows {
5230 Ok(rows) => rows.filter_map(Result::ok).collect(),
5231 Err(_) => Vec::new(),
5232 }
5233 }
5234
5235 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
5236 self.conn
5237 .query_row(
5238 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
5239 rusqlite::params![sub_id],
5240 |row| Ok((row.get(0)?, row.get(1)?)),
5241 )
5242 .ok()
5243 }
5244
5245 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
5246 self.conn
5247 .execute(
5248 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
5249 rusqlite::params![base_key, unit, sub_id],
5250 )
5251 .map(|_| ())
5252 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5253 }
5254
5255 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
5256 self.conn
5257 .execute(
5258 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
5259 rusqlite::params![base_key, unit],
5260 )
5261 .map(|_| ())
5262 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5263 }
5264
5265 fn evict_unit(
5272 &mut self,
5273 base_key: &str,
5274 base: &WindowBase,
5275 unit: &str,
5276 sub_id: &str,
5277 ) -> Result<bool, String> {
5278 let effective = self
5279 .subs
5280 .iter()
5281 .find(|s| s.id == sub_id)
5282 .and_then(|s| s.effective.clone())
5283 .unwrap_or_else(|| unit_scopes(base, unit));
5284 let pinned = self.pinned_row_ids(&base.table);
5285 let owners = self.scope_owners(&base.table, sub_id);
5286 let (remaining, deferred) =
5287 self.evict_scope_rows(&base.table, &effective, &owners, &pinned)?;
5288 self.delete_window_unit(base_key, unit)?;
5289 self.conn
5290 .execute(
5291 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5292 [sub_id],
5293 )
5294 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5295 self.subs.retain(|sub| sub.id != sub_id);
5296 self.cancel_sync_round();
5299 self.subscription_epoch = self.subscription_epoch.wrapping_add(1);
5300 if deferred {
5301 self.save_pending_evict(sub_id, &base.table, &effective)?;
5302 } else {
5303 self.delete_pending_evict(sub_id)?;
5304 }
5305 Ok(remaining)
5306 }
5307
5308 fn evict_scope_rows(
5313 &self,
5314 table_name: &str,
5315 effective: &[(String, Vec<String>)],
5316 owners: &[Vec<(String, Vec<String>)>],
5317 pinned: &std::collections::HashSet<String>,
5318 ) -> Result<(bool, bool), String> {
5319 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
5320 let Some((scope, mut params)) = local_scope_predicate(table, effective, owners)
5321 .map_err(|()| "sync.scope_revoked".to_owned())?
5322 else {
5323 return Ok((false, false));
5324 };
5325 let scope_params = params.len();
5326 let pk = quote_ident(&table.primary_key);
5327 let predicate = if pinned.is_empty() {
5329 scope.clone()
5330 } else {
5331 params.extend(pinned.iter().cloned().map(SqlValue::Text));
5332 let holes = vec!["?"; pinned.len()].join(", ");
5333 format!("{scope} AND CAST({pk} AS TEXT) NOT IN ({holes})")
5334 };
5335 let base = base_table(table_name);
5336 let visible = visible_table(table_name);
5337 let mut ids: Vec<SqlValue> = Vec::new();
5338 for source in [&base, &visible] {
5339 let mut stmt = self
5340 .conn
5341 .prepare_cached(&format!(
5342 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
5343 ))
5344 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5345 ids = stmt
5346 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
5347 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
5348 .collect::<Result<_, _>>()
5349 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5350 if !ids.is_empty() {
5351 break;
5352 }
5353 }
5354 if !ids.is_empty() {
5355 for id in &ids {
5356 self.conn
5357 .execute(
5358 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
5359 rusqlite::params![table_name, id],
5360 )
5361 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5362 }
5363 let holes = vec!["?"; ids.len()].join(", ");
5364 for source in [&base, &visible] {
5365 self.conn
5366 .execute(
5367 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
5368 rusqlite::params_from_iter(&ids),
5369 )
5370 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5371 }
5372 }
5373 let mut remaining = false;
5374 let mut deferred = false;
5375 for source in [&base, &visible] {
5376 remaining |= self
5377 .conn
5378 .query_row(
5379 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
5380 rusqlite::params_from_iter(¶ms),
5381 |row| row.get::<_, bool>(0),
5382 )
5383 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5384 deferred |= self
5385 .conn
5386 .query_row(
5387 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
5388 rusqlite::params_from_iter(¶ms[..scope_params]),
5389 |row| row.get::<_, bool>(0),
5390 )
5391 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5392 }
5393 Ok((remaining, deferred))
5394 }
5395
5396 fn drain_pending_evictions(&mut self) -> Result<(), String> {
5398 let pending = self.load_pending_evictions()?;
5399 if pending.is_empty() {
5400 return Ok(());
5401 }
5402 self.rebuild_overlay_if_dirty()?;
5403 for (sub_id, table_name, effective) in pending {
5404 if self.schema.table(&table_name).is_none() {
5405 self.delete_pending_evict(&sub_id)?;
5406 continue;
5407 }
5408 loop {
5409 std::thread::yield_now();
5410 self.begin_observation("syncular_evict")?;
5411 let pinned = self.pinned_row_ids(&table_name);
5412 let owners = self.scope_owners(&table_name, &sub_id);
5413 let result = self.evict_scope_rows(&table_name, &effective, &owners, &pinned);
5414 let (remaining, deferred) = match result {
5415 Ok(result) => result,
5416 Err(error) => {
5417 self.rollback_observation("syncular_evict");
5418 return Err(error);
5419 }
5420 };
5421 if !deferred {
5422 if let Err(error) = self.delete_pending_evict(&sub_id) {
5423 self.rollback_observation("syncular_evict");
5424 return Err(error);
5425 }
5426 }
5427 let mut batch = ChangeAccumulator::default();
5428 self.record_scope_map(&mut batch, &table_name, &effective);
5429 if let Err(error) = self.finish_observation("syncular_evict", batch) {
5430 self.rollback_observation("syncular_evict");
5431 return Err(error);
5432 }
5433 if !remaining {
5434 break;
5435 }
5436 }
5437 }
5438 Ok(())
5439 }
5440
5441 fn scope_owners(&self, table: &str, except_id: &str) -> Vec<Vec<(String, Vec<String>)>> {
5445 self.subs
5446 .iter()
5447 .filter(|sub| {
5448 sub.id != except_id
5449 && sub.table == table
5450 && sub.state == SubState::Active
5451 && sub.cursor >= 0
5452 })
5453 .filter_map(|sub| sub.effective.clone())
5454 .collect()
5455 }
5456
5457 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
5460 let mut pinned = std::collections::HashSet::new();
5461 for commit in self
5462 .outbox
5463 .iter()
5464 .map(|commit| &**commit)
5465 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
5466 {
5467 for op in &commit.ops {
5468 if op.table == table {
5469 pinned.insert(op.row_id.clone());
5470 }
5471 }
5472 }
5473 pinned
5474 }
5475
5476 fn save_pending_evict(
5477 &self,
5478 sub_id: &str,
5479 table: &str,
5480 effective: &[(String, Vec<String>)],
5481 ) -> Result<(), String> {
5482 self.conn.execute(
5483 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
5484 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
5485 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5486 }
5487
5488 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
5489 self.conn
5490 .execute(
5491 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5492 rusqlite::params![sub_id],
5493 )
5494 .map(|_| ())
5495 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
5496 }
5497
5498 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
5499 let mut stmt = self
5500 .conn
5501 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
5502 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5503 let rows = stmt
5504 .query_map([], |row| {
5505 Ok((
5506 row.get::<_, String>(0)?,
5507 row.get::<_, String>(1)?,
5508 row.get::<_, String>(2)?,
5509 ))
5510 })
5511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5512 rows.map(|row| {
5513 let (sub_id, table, json) =
5514 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5515 let value: Value = serde_json::from_str(&json)
5516 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5517 let effective = json_to_scope_map(&value)
5518 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
5519 Ok((sub_id, table, effective))
5520 })
5521 .collect()
5522 }
5523
5524 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
5526 self.retain_failed_commits = enabled;
5527 for (commit, _) in self.failed_commits.iter() {
5528 for operation in &commit.ops {
5529 self.overlay_dirty.table(&operation.table);
5530 }
5531 }
5532 self.failed_commits = self.load_failed_commits()?.into();
5533 for (commit, _) in self.failed_commits.iter() {
5534 for operation in &commit.ops {
5535 self.overlay_dirty.table(&operation.table);
5536 }
5537 }
5538 Ok(())
5539 }
5540
5541 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
5542 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
5543 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5544 let rows = statement
5545 .query_map([], |row| {
5546 Ok((
5547 row.get::<_, String>(0)?,
5548 row.get::<_, String>(1)?,
5549 row.get::<_, String>(2)?,
5550 ))
5551 })
5552 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5553 rows.map(|row| {
5554 let (id, operations, initial) =
5555 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5556 let operations: Vec<CommitOperation> =
5557 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
5558 let initial =
5559 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
5560 Ok((
5561 OutboxCommit {
5562 client_commit_id: id,
5563 ops: operations
5564 .into_iter()
5565 .map(|op| OutboxOp {
5566 upsert: op.op == "upsert",
5567 table: op.table,
5568 row_id: op.row_id,
5569 base_version: op.base_version,
5570 values: op.values,
5571 insert: op.insert,
5572 })
5573 .collect(),
5574 },
5575 initial,
5576 ))
5577 })
5578 .collect()
5579 }
5580
5581 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
5582 let mut initial = Map::new();
5583 for operation in operations {
5584 let Some(table) = self.schema.table(&operation.table) else {
5586 continue;
5587 };
5588 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
5589 Some(row) => Some(row),
5590 None => self.visible_row(table, &operation.row_id)?,
5591 };
5592 if let Some((values, _)) = initial_row {
5593 let mut intended: Map<String, Value> = table
5594 .columns
5595 .iter()
5596 .zip(values.iter())
5597 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
5598 .collect();
5599 if let Some(patch) = &operation.values {
5600 intended.extend(patch.clone());
5601 }
5602 initial.insert(
5603 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5604 .map_err(|_| "sync.local_corrupt".to_owned())?,
5605 Value::Object(intended),
5606 );
5607 }
5608 }
5609 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
5610 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
5611 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
5612 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5613 self.failed_commits.push((
5614 OutboxCommit {
5615 client_commit_id: id.to_owned(),
5616 ops: operations.to_vec(),
5617 },
5618 initial,
5619 ));
5620 Ok(())
5621 }
5622
5623 fn drop_failed_commits(
5624 &mut self,
5625 matches: impl Fn(&str, &Map<String, Value>) -> bool,
5626 ) -> Result<Vec<OutboxCommit>, String> {
5627 let mut doomed = self.drop_acknowledged_rows(&matches)?;
5628 for (commit, initial) in self.failed_commits.iter() {
5629 for operation in &commit.ops {
5630 let key =
5631 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
5632 .map_err(|_| "sync.local_corrupt".to_owned())?;
5633 if initial
5634 .get(&key)
5635 .and_then(Value::as_object)
5636 .is_some_and(|values| matches(&operation.table, values))
5637 {
5638 doomed.push(commit.clone());
5639 break;
5640 }
5641 }
5642 }
5643 for commit in &doomed {
5644 self.conn
5645 .execute(
5646 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
5647 rusqlite::params![commit.client_commit_id],
5648 )
5649 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5650 }
5651 for commit in &doomed {
5652 self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5653 }
5654 self.conflicts.retain(|record| {
5655 !doomed
5656 .iter()
5657 .any(|commit| commit.client_commit_id == record.client_commit_id)
5658 });
5659 self.rejections.retain(|record| {
5660 !doomed
5661 .iter()
5662 .any(|commit| commit.client_commit_id == record.client_commit_id)
5663 });
5664 self.failed_commits.retain(|(commit, _)| {
5665 !doomed
5666 .iter()
5667 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
5668 });
5669 for commit in &doomed {
5670 for operation in &commit.ops {
5671 self.overlay_dirty.table(&operation.table);
5672 }
5673 }
5674 Ok(doomed)
5675 }
5676
5677 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
5681 let prior = self.storage_failure.borrow_mut().take();
5682 let outcome = (|| -> Result<String, String> {
5683 if mutations.is_empty() {
5684 return Err(
5685 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
5686 .to_owned(),
5687 );
5688 }
5689 let mut ops = Vec::with_capacity(mutations.len());
5690 for mutation in mutations {
5691 match mutation {
5692 Mutation::Patch {
5693 table,
5694 values,
5695 base_version,
5696 } => {
5697 let schema_table = self
5698 .schema
5699 .table(&table)
5700 .ok_or_else(|| "sync.unknown_table".to_owned())?;
5701 let values = normalize_values_casing(schema_table, values)?;
5702 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5703 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
5704 }
5705 Mutation::Upsert {
5706 table,
5707 values,
5708 base_version,
5709 } => {
5710 let schema_table = self.schema.table(&table).ok_or_else(|| {
5711 format!("sync.unknown_table: unknown table {table:?}")
5712 })?;
5713 let values = normalize_values_casing(schema_table, values)?;
5717 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5718 let values = full_row_values(schema_table, values)?;
5721 self.validate_author_encode(schema_table, &row_id, &values)?;
5727 let insert = !self.visible_row_exists(schema_table, &row_id)?;
5731 ops.push(OutboxOp {
5732 upsert: true,
5733 table,
5734 row_id,
5735 base_version,
5736 values: Some(values),
5737 insert,
5738 });
5739 }
5740 Mutation::Delete {
5741 table,
5742 row_id,
5743 base_version,
5744 } => {
5745 if self.schema.table(&table).is_none() {
5746 return Err(format!("sync.unknown_table: unknown table {table:?}"));
5747 }
5748 if row_id.contains('\0') {
5750 return Err(
5751 "sync.invalid_request: rowId holds U+0000 (§2.4)".to_owned()
5752 );
5753 }
5754 ops.push(OutboxOp {
5755 upsert: false,
5756 table,
5757 row_id,
5758 base_version,
5759 values: None,
5760 insert: false,
5761 });
5762 }
5763 }
5764 }
5765 self.record_outbox_commit(ops)
5766 })();
5767 self.finish_authoring(prior, outcome)
5768 }
5769
5770 fn finish_authoring(
5776 &self,
5777 prior: Option<QueryReadFailure>,
5778 outcome: Result<String, String>,
5779 ) -> Result<String, ClientError> {
5780 let fresh = self.storage_failure.borrow_mut().take();
5781 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
5782 match outcome {
5783 Ok(id) => Ok(id),
5784 Err(message) => Err(fresh
5785 .map(ClientError::from)
5786 .unwrap_or_else(|| ClientError::from(message))),
5787 }
5788 }
5789
5790 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
5791 let commit = OutboxCommit {
5792 client_commit_id: uuid::Uuid::new_v4().to_string(),
5793 ops,
5794 };
5795 self.begin_observation("syncular_mutation")?;
5796 let mut batch = ChangeAccumulator::default();
5797 for op in &commit.ops {
5798 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
5799 if let Some(values) = &op.values {
5800 if let Some(table) = self.schema.table(&op.table) {
5801 for scope in &table.scope_variables {
5802 if let Some(Value::String(value)) = values.get(&scope.column) {
5803 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
5804 precise = true;
5805 }
5806 }
5807 }
5808 }
5809 if !precise {
5810 batch.table(&op.table);
5811 }
5812 }
5813 if let Err(error) = self.persist_outbox_insert(&commit) {
5814 self.rollback_observation("syncular_mutation");
5815 return Err(error);
5816 }
5817 let was_dirty = self.overlay_dirty.snapshot();
5823 let id = commit.client_commit_id.clone();
5824 let appended = self.rebuild_overlay_if_dirty().and_then(|()| {
5825 commit
5826 .ops
5827 .iter()
5828 .try_for_each(|op| self.apply_outbox_op(op))?;
5829 self.outbox.push(Arc::new(commit));
5830 batch.status = true;
5831 self.finish_observation("syncular_mutation", batch)
5832 .inspect_err(|_| drop(self.outbox.pop()))
5833 });
5834 if let Err(error) = appended {
5835 self.rollback_observation("syncular_mutation");
5836 self.overlay_dirty.restore(was_dirty);
5837 return Err(error);
5838 }
5839 Ok(id)
5840 }
5841
5842 pub fn patch(
5848 &mut self,
5849 table: &str,
5850 row_id: &str,
5851 partial: Map<String, Value>,
5852 base_version: Option<i64>,
5853 ) -> Result<String, ClientError> {
5854 let prior = self.storage_failure.borrow_mut().take();
5855 let outcome = self.perform_patch(table, row_id, partial, base_version);
5856 self.finish_authoring(prior, outcome)
5857 }
5858
5859 fn perform_patch(
5862 &mut self,
5863 table: &str,
5864 row_id: &str,
5865 partial: Map<String, Value>,
5866 base_version: Option<i64>,
5867 ) -> Result<String, String> {
5868 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
5869 self.record_outbox_commit(vec![operation])
5870 }
5871
5872 fn prepare_patch(
5873 &self,
5874 table: &str,
5875 row_id: &str,
5876 partial: Map<String, Value>,
5877 base_version: Option<i64>,
5878 ) -> Result<OutboxOp, String> {
5879 let schema_table = self
5880 .schema
5881 .table(table)
5882 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
5883 let partial = normalize_values_casing(schema_table, partial)?;
5884 if let Some(pk) = partial.get(&schema_table.primary_key) {
5885 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
5886 return Err(format!(
5887 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
5888 ));
5889 }
5890 }
5891 if !self.visible_row_exists(schema_table, row_id)? {
5892 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
5893 }
5894 for scope in &schema_table.scope_variables {
5904 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
5905 continue;
5906 }
5907 let Some(column) = schema_table
5908 .columns
5909 .iter()
5910 .find(|column| column.name == scope.column)
5911 else {
5912 continue;
5913 };
5914 let stored = self
5915 .conn
5916 .query_row(
5917 &format!(
5918 "SELECT {} FROM {} WHERE {}",
5919 quote_ident(&scope.column),
5920 visible_table(&schema_table.name),
5921 row_id_predicate(schema_table)
5922 ),
5923 rusqlite::params![row_id],
5924 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
5925 )
5926 .optional()
5927 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
5928 let incoming = json_to_column_value(column, partial.get(&scope.column))
5933 .ok()
5934 .map(|value| column_value_to_json(&value));
5935 let equal = stored.as_ref().is_some_and(|stored| {
5939 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
5940 });
5941 if !equal {
5942 return Err(format!(
5943 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
5944 scope.column
5945 ));
5946 }
5947 }
5948 let mut values = partial;
5949 for scope in &schema_table.scope_variables {
5952 values.remove(&scope.column);
5953 }
5954 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
5955 ColumnType::Integer => match row_id.parse::<i64>() {
5957 Ok(v)
5958 if v.to_string() == row_id
5959 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
5960 .contains(&v) =>
5961 {
5962 Value::from(v)
5963 }
5964 _ => {
5965 return Err(format!(
5966 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
5967 ))
5968 }
5969 },
5970 ColumnType::Boolean => match row_id {
5971 "true" => Value::Bool(true),
5972 "false" => Value::Bool(false),
5973 _ => {
5974 return Err(format!(
5975 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
5976 ))
5977 }
5978 },
5979 _ => Value::from(row_id.to_owned()),
5980 };
5981 values.insert(schema_table.primary_key.clone(), pk_value);
5982 self.validate_author_encode(schema_table, row_id, &values)?;
5988 Ok(OutboxOp {
5989 upsert: true,
5990 table: table.to_owned(),
5991 row_id: row_id.to_owned(),
5992 base_version,
5993 values: Some(values),
5994 insert: false,
5995 })
5996 }
5997
5998 fn stored_key_fallback(
6003 &self,
6004 table: &TableSchema,
6005 row_id: &str,
6006 ) -> Result<Option<Row>, String> {
6007 let Some(selector) = self.encryption.key_id_columns.get(&table.name) else {
6008 return Ok(None);
6009 };
6010 let Some(index) = table
6011 .columns
6012 .iter()
6013 .position(|column| &column.name == selector)
6014 else {
6015 return Ok(None);
6016 };
6017 let sql = format!(
6018 "SELECT {} FROM {} WHERE {}",
6019 quote_ident(selector),
6020 visible_table(&table.name),
6021 row_id_predicate(table)
6022 );
6023 let key = self
6024 .conn
6025 .query_row(&sql, rusqlite::params![row_id], |row| {
6026 row.get::<_, Option<String>>(0)
6027 })
6028 .optional()
6029 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6030 .flatten()
6031 .filter(|key| !key.is_empty());
6032 let Some(key) = key else {
6033 return Ok(None);
6034 };
6035 let mut fallback: Row = vec![None; table.columns.len()];
6036 fallback[index] = Some(ColumnValue::String(key));
6037 Ok(Some(fallback))
6038 }
6039
6040 fn validate_author_encode(
6045 &self,
6046 table: &TableSchema,
6047 row_id: &str,
6048 values: &Map<String, Value>,
6049 ) -> Result<(), String> {
6050 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
6051 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
6052 result => result.map(|_| ()),
6053 }
6054 }
6055
6056 fn drop_unencodable_outbox(
6070 &mut self,
6071 with_encryption: bool,
6072 batch: Option<&mut ChangeAccumulator>,
6073 ) -> Result<bool, String> {
6074 let mut failures = Vec::new();
6075 for commit in self.outbox.iter() {
6076 for op in &commit.ops {
6077 let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
6078 continue;
6079 };
6080 let Some(values) = op.presented_values(table) else {
6083 continue;
6084 };
6085 let fallback = self.stored_key_fallback(table, &op.row_id)?;
6086 let failure = match encode_sparse_row_json(
6087 table,
6088 &op.row_id,
6089 &values,
6090 &self.encryption,
6091 fallback.as_ref(),
6092 ) {
6093 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
6094 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
6095 }
6096 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
6101 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
6102 OUTBOX_INCOMPATIBLE_CODE,
6103 )),
6104 _ => None,
6105 };
6106 if let Some((error, code)) = failure {
6107 failures.push((commit.clone(), error, code));
6108 break;
6109 }
6110 }
6111 }
6112 if failures.is_empty() {
6113 return Ok(false);
6114 }
6115 let rollback = self.capture();
6121 let owns_observation = batch.is_none();
6122 let owns_transaction = self.conn.is_autocommit();
6123 let mut domains = ChangeAccumulator::default();
6124 if owns_observation {
6125 self.begin_observation("syncular_value_recovery")?;
6126 }
6127 let dropped = (|| -> Result<(), String> {
6128 for (commit, message, code) in failures {
6129 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
6130 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
6131 }
6132 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
6133 let rejection = RejectionRecord {
6134 client_commit_id: commit.client_commit_id.clone(),
6135 op_index: 0,
6136 code: code.to_owned(),
6137 message,
6138 retryable: false,
6139 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
6140 field_paths: None,
6141 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
6142 required_action: None,
6143 references: None,
6144 }),
6145 operation: representable
6146 .then(|| commit.ops.first())
6147 .flatten()
6148 .map(CommitOperation::from),
6149 };
6150 self.persist_commit_outcome(
6151 &commit.client_commit_id,
6152 CommitOutcomeStatus::Rejected,
6153 &[CommitOperationOutcome::Error {
6154 rejection: rejection.clone(),
6155 }],
6156 representable.then_some(commit.ops.as_slice()),
6157 )?;
6158 self.delete_outbox_persisted(&commit.client_commit_id)?;
6159 self.outbox
6160 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
6161 self.rejections.push(rejection);
6162 for operation in &commit.ops {
6163 domains.table(operation.table.as_str());
6164 }
6165 }
6166 domains.status = true;
6167 domains.rejections = true;
6168 domains.outcomes = true;
6169 self.prune_commit_outcomes()?;
6170 self.overlay_dirty.set(true);
6172 self.rebuild_overlay()?;
6173 Ok(())
6174 })();
6175 let result = match batch {
6176 Some(batch) => {
6177 if dropped.is_ok() {
6178 for table in domains.tables.keys() {
6179 batch.table(table.as_str());
6180 }
6181 batch.status |= domains.status;
6182 batch.rejections |= domains.rejections;
6183 batch.outcomes |= domains.outcomes;
6184 }
6185 dropped
6186 }
6187 None => {
6188 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
6189 }
6190 };
6191 if let Err(error) = result {
6192 if owns_observation {
6193 if owns_transaction {
6194 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
6195 let mut failure = self.storage_failure.borrow_mut();
6196 let failure =
6197 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
6198 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
6199 }
6200 } else {
6201 self.rollback_observation("syncular_value_recovery");
6202 }
6203 }
6204 self.restore(rollback);
6205 return Err(error);
6206 }
6207 Ok(true)
6208 }
6209
6210 pub fn pending_commit_ids(&self) -> Vec<String> {
6211 self.outbox
6212 .iter()
6213 .map(|c| c.client_commit_id.clone())
6214 .collect()
6215 }
6216
6217 pub fn pending_payloads(&self) -> Result<Vec<Vec<u8>>, String> {
6221 let mut payloads = Vec::new();
6222 for commit in self.outbox.iter() {
6223 for op in &commit.ops {
6224 let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
6225 continue;
6226 };
6227 let Some(values) = op.presented_values(table) else {
6228 continue;
6229 };
6230 let fallback = self.stored_key_fallback(table, &op.row_id)?;
6231 if let Ok(payload) = encode_sparse_row_json(
6232 table,
6233 &op.row_id,
6234 &values,
6235 &self.encryption,
6236 fallback.as_ref(),
6237 ) {
6238 payloads.push(payload);
6239 }
6240 }
6241 }
6242 Ok(payloads)
6243 }
6244
6245 pub fn conflicts(&self) -> &[ConflictRecord] {
6246 &self.conflicts
6247 }
6248
6249 pub fn rejections(&self) -> &[RejectionRecord] {
6250 &self.rejections
6251 }
6252
6253 pub fn resolve_commit_outcome(
6254 &mut self,
6255 input: ResolveCommitOutcomeInput,
6256 ) -> Result<CommitOutcome, String> {
6257 let current = self
6258 .commit_outcome(&input.client_commit_id)?
6259 .ok_or_else(|| {
6260 format!(
6261 "sync.outcome_not_found: no durable outcome exists for {:?}",
6262 input.client_commit_id
6263 )
6264 })?;
6265 if current.resolution != CommitOutcomeResolution::Active {
6266 return Ok(current);
6267 }
6268 if input.resolution == CommitOutcomeResolution::Active {
6269 return Err("sync.invalid_request: resolution must leave active state".to_owned());
6270 }
6271 match input.resolution {
6272 CommitOutcomeResolution::Superseded => {
6273 let replacement = input
6274 .replacement_client_commit_id
6275 .as_deref()
6276 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6277 .ok_or_else(|| {
6278 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6279 .to_owned()
6280 })?;
6281 let _ = replacement;
6282 }
6283 _ if input.replacement_client_commit_id.is_some() => {
6284 return Err(
6285 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6286 .to_owned(),
6287 );
6288 }
6289 _ => {}
6290 }
6291 let allowed = match current.status {
6292 CommitOutcomeStatus::Conflict => matches!(
6293 input.resolution,
6294 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6295 ),
6296 CommitOutcomeStatus::Rejected => {
6297 matches!(
6298 input.resolution,
6299 CommitOutcomeResolution::Superseded
6300 | CommitOutcomeResolution::ResolvedKeepServer
6301 )
6302 }
6303 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6304 input.resolution == CommitOutcomeResolution::Dismissed
6305 }
6306 };
6307 if !allowed {
6308 return Err(format!(
6309 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6310 input.resolution, current.status
6311 ));
6312 }
6313
6314 let rollback = self.capture();
6315 self.begin_observation("syncular_outcome_resolution")?;
6316 let result = (|| {
6317 self.conn
6318 .execute(
6319 "UPDATE _syncular_commit_outcomes
6320 SET resolution = ?1, resolved_at_ms = ?2,
6321 replacement_client_commit_id = ?3
6322 WHERE client_commit_id = ?4 AND resolution = 'active'",
6323 rusqlite::params![
6324 Self::outcome_resolution_name(input.resolution),
6325 self.clock_now_ms(),
6326 input.replacement_client_commit_id,
6327 input.client_commit_id
6328 ],
6329 )
6330 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6331 let resolved = self
6332 .commit_outcome(¤t.client_commit_id)?
6333 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6334 self.conn
6335 .execute(
6336 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
6337 rusqlite::params![input.client_commit_id],
6338 )
6339 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6340 for (commit, _) in self.failed_commits.iter() {
6341 if commit.client_commit_id == input.client_commit_id {
6342 for operation in &commit.ops {
6343 self.overlay_dirty.table(&operation.table);
6344 }
6345 }
6346 }
6347 self.failed_commits
6348 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
6349 self.prune_commit_outcomes()?;
6350 Ok(resolved)
6351 })();
6352 let resolved = match result {
6353 Ok(outcome) => outcome,
6354 Err(error) => {
6355 self.rollback_observation("syncular_outcome_resolution");
6356 self.restore(rollback);
6357 return Err(error);
6358 }
6359 };
6360 self.conflicts
6361 .retain(|record| record.client_commit_id != current.client_commit_id);
6362 self.rejections
6363 .retain(|record| record.client_commit_id != current.client_commit_id);
6364 let mut batch = ChangeAccumulator {
6365 conflicts: current.status == CommitOutcomeStatus::Conflict,
6366 rejections: current.status == CommitOutcomeStatus::Rejected,
6367 outcomes: true,
6368 ..ChangeAccumulator::default()
6369 };
6370 if let Some(operations) = ¤t.operations {
6371 for operation in operations {
6372 batch.table(&operation.table);
6373 }
6374 }
6375 if let Err(error) = self
6376 .rebuild_overlay_if_dirty()
6377 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
6378 {
6379 self.rollback_observation("syncular_outcome_resolution");
6380 self.restore(rollback);
6381 return Err(error);
6382 }
6383 Ok(resolved)
6384 }
6385
6386 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6387 self.schema_floor.as_ref()
6388 }
6389
6390 pub fn lease_state(&self) -> Option<&LeaseState> {
6392 self.lease_state.as_ref()
6393 }
6394
6395 fn record_lease_error(&mut self, code: &str) -> Result<(), String> {
6398 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6399 return Ok(());
6400 }
6401 let mut next = self.lease_state.clone().unwrap_or_default();
6402 next.error_code = Some(code.to_owned());
6403 self.set_lease_state(Some(next))
6404 }
6405
6406 fn set_lease_state(&mut self, next: Option<LeaseState>) -> Result<(), String> {
6407 if self.lease_state == next {
6408 return Ok(());
6409 }
6410 self.begin_observation("syncular_lease")?;
6411 let rollback = self.capture();
6412 self.lease_state = next;
6413 let persisted = match &self.lease_state {
6414 Some(lease) => serde_json::to_string(lease)
6415 .map_err(|error| error.to_string())
6416 .and_then(|json| self.set_meta(LEASE_STATE_KEY, &json)),
6417 None => self.delete_meta(LEASE_STATE_KEY),
6418 };
6419 let batch = ChangeAccumulator {
6420 status: true,
6421 ..ChangeAccumulator::default()
6422 };
6423 if let Err(error) =
6424 persisted.and_then(|()| self.finish_observation("syncular_lease", batch))
6425 {
6426 self.rollback_observation("syncular_lease");
6427 self.restore(rollback);
6428 return Err(error);
6429 }
6430 Ok(())
6431 }
6432
6433 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) -> Result<(), String> {
6434 if self.schema_floor == next {
6435 return Ok(());
6436 }
6437 self.begin_observation("syncular_schema_floor")?;
6438 let rollback = self.capture();
6439 self.stopped = next.is_some();
6440 self.schema_floor = next;
6441 let persisted = match &self.schema_floor {
6442 Some(floor) => serde_json::to_string(floor)
6443 .map_err(|error| error.to_string())
6444 .and_then(|json| self.set_meta(SCHEMA_FLOOR_KEY, &json)),
6445 None => self.delete_meta(SCHEMA_FLOOR_KEY),
6446 };
6447 let batch = ChangeAccumulator {
6448 status: true,
6449 ..ChangeAccumulator::default()
6450 };
6451 if let Err(error) =
6452 persisted.and_then(|()| self.finish_observation("syncular_schema_floor", batch))
6453 {
6454 self.rollback_observation("syncular_schema_floor");
6455 self.restore(rollback);
6456 return Err(error);
6457 }
6458 Ok(())
6459 }
6460
6461 fn set_upgrading(&mut self, value: bool) {
6462 if self.upgrading == value {
6463 return;
6464 }
6465 if self.begin_observation("syncular_upgrading").is_err() {
6466 return;
6467 }
6468 self.upgrading = value;
6469 let batch = ChangeAccumulator {
6470 status: true,
6471 ..ChangeAccumulator::default()
6472 };
6473 if self
6474 .finish_observation("syncular_upgrading", batch)
6475 .is_err()
6476 {
6477 self.rollback_observation("syncular_upgrading");
6478 }
6479 }
6480
6481 pub fn sync_needed(&self) -> bool {
6482 self.sync_needed
6483 }
6484
6485 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
6486 let sub = self.subs.iter().find(|s| s.id == id)?;
6487 Some(SubscriptionStateView {
6488 id: sub.id.clone(),
6489 table: sub.table.clone(),
6490 status: sub.state.name().to_owned(),
6491 cursor: sub.cursor,
6492 has_resume_token: sub.bootstrap_state.is_some(),
6493 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6494 reason_code: sub.reason_code.clone(),
6495 })
6496 }
6497
6498 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6499 let schema_table = self
6500 .schema
6501 .table(table)
6502 .ok_or_else(|| format!("unknown table {table:?}"))?;
6503 let sql = format!(
6504 "SELECT * FROM {} ORDER BY {} ASC",
6505 visible_table(table),
6506 quote_ident(&schema_table.primary_key)
6507 );
6508 let mut stmt = self
6509 .conn
6510 .prepare(&sql)
6511 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6512 let mut rows = stmt
6513 .query([])
6514 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6515 let mut out = Vec::new();
6516 while let Some(row) = rows
6517 .next()
6518 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6519 {
6520 let mut values = Map::new();
6521 for (i, column) in schema_table.columns.iter().enumerate() {
6522 let value = row
6523 .get_ref(i)
6524 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6525 values.insert(column.name.clone(), sql_ref_to_json(column, value));
6526 }
6527 let version: i64 = row
6528 .get(schema_table.columns.len())
6529 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
6530 let row_id = match values.get(&schema_table.primary_key) {
6531 Some(Value::String(s)) => s.clone(),
6532 Some(Value::Number(n)) => n.to_string(),
6533 Some(Value::Bool(b)) => b.to_string(),
6534 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6535 };
6536 out.push(RowState {
6537 row_id,
6538 version,
6539 values,
6540 });
6541 }
6542 Ok(out)
6543 }
6544
6545 #[cfg(feature = "crdt-yjs")]
6561 fn crdt_column_bytes(
6562 &self,
6563 table: &str,
6564 row_id: &str,
6565 column: &str,
6566 ) -> Result<Option<Vec<u8>>, String> {
6567 let schema_table = self
6568 .schema
6569 .table(table)
6570 .ok_or_else(|| format!("unknown table {table:?}"))?;
6571 let col = schema_table
6572 .columns
6573 .iter()
6574 .find(|c| c.name == column)
6575 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6576 if col.ty != ColumnType::Crdt {
6577 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6578 }
6579 let sql = format!(
6580 "SELECT {} FROM {} WHERE {}",
6581 quote_ident(column),
6582 visible_table(table),
6583 row_id_predicate(schema_table)
6584 );
6585 let bytes: Option<Vec<u8>> = self
6586 .conn
6587 .prepare_cached(&sql)
6588 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
6589 .query_row(rusqlite::params![row_id], |row| {
6590 row.get::<_, Option<Vec<u8>>>(0)
6591 })
6592 .map_err(|e| match e {
6593 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6594 other => Self::sqlite_failure(&self.storage_failure, other),
6595 })?;
6596 Ok(bytes)
6597 }
6598
6599 #[cfg(feature = "crdt-yjs")]
6603 pub fn crdt_text(
6604 &self,
6605 table: &str,
6606 row_id: &str,
6607 column: &str,
6608 name: &str,
6609 ) -> Result<String, String> {
6610 let bytes = self
6611 .crdt_column_bytes(table, row_id, column)?
6612 .unwrap_or_default();
6613 crate::crdt::text(&bytes, name)
6614 }
6615
6616 #[cfg(feature = "crdt-yjs")]
6620 pub fn crdt_insert_text(
6621 &mut self,
6622 table: &str,
6623 row_id: &str,
6624 column: &str,
6625 name: &str,
6626 index: u32,
6627 value: &str,
6628 ) -> Result<String, ClientError> {
6629 let prior = self.storage_failure.borrow_mut().take();
6630 let outcome = (|| -> Result<String, String> {
6631 let current = self
6632 .crdt_column_bytes(table, row_id, column)?
6633 .unwrap_or_default();
6634 let update = crate::crdt::insert_text(¤t, name, index, value)?;
6635 self.perform_crdt_push_update(table, row_id, column, &update)
6636 })();
6637 self.finish_authoring(prior, outcome)
6638 }
6639
6640 #[cfg(feature = "crdt-yjs")]
6644 pub fn crdt_delete_text(
6645 &mut self,
6646 table: &str,
6647 row_id: &str,
6648 column: &str,
6649 name: &str,
6650 index: u32,
6651 len: u32,
6652 ) -> Result<String, ClientError> {
6653 let prior = self.storage_failure.borrow_mut().take();
6654 let outcome = (|| -> Result<String, String> {
6655 let current = self
6656 .crdt_column_bytes(table, row_id, column)?
6657 .unwrap_or_default();
6658 let update = crate::crdt::delete_text(¤t, name, index, len)?;
6659 self.perform_crdt_push_update(table, row_id, column, &update)
6660 })();
6661 self.finish_authoring(prior, outcome)
6662 }
6663
6664 #[cfg(feature = "crdt-yjs")]
6669 pub fn crdt_apply_update(
6670 &mut self,
6671 table: &str,
6672 row_id: &str,
6673 column: &str,
6674 update: &[u8],
6675 ) -> Result<String, ClientError> {
6676 let prior = self.storage_failure.borrow_mut().take();
6677 let outcome = (|| -> Result<String, String> {
6678 let current = self
6679 .crdt_column_bytes(table, row_id, column)?
6680 .unwrap_or_default();
6681 let next = crate::crdt::apply_update(¤t, update)?;
6682 self.perform_crdt_push_update(table, row_id, column, &next)
6683 })();
6684 self.finish_authoring(prior, outcome)
6685 }
6686
6687 #[cfg(feature = "crdt-yjs")]
6694 fn perform_crdt_push_update(
6695 &mut self,
6696 table: &str,
6697 row_id: &str,
6698 column: &str,
6699 crdt_bytes: &[u8],
6700 ) -> Result<String, String> {
6701 let mut bytes_obj = Map::new();
6702 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6703 let mut partial = Map::new();
6704 partial.insert(column.to_owned(), Value::Object(bytes_obj));
6705 self.perform_patch(table, row_id, partial, None)
6706 }
6707
6708 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6723 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
6724 }
6725
6726 #[cfg(feature = "bench-internals")]
6728 #[doc(hidden)]
6729 pub fn benchmark_connection(&mut self) -> &mut Connection {
6730 &mut self.conn
6731 }
6732
6733 #[cfg(feature = "bench-internals")]
6735 #[doc(hidden)]
6736 pub fn benchmark_phases(
6737 &self,
6738 enabled: Option<bool>,
6739 reset: bool,
6740 ) -> Result<Option<Value>, String> {
6741 self.benchmark_phases.configure(enabled, reset)?;
6742 Ok(self.benchmark_phases.snapshot())
6743 }
6744
6745 pub fn authority_snapshot(&self) -> Result<Value, String> {
6747 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
6748 if self.limits.authority_reads.is_empty() {
6749 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
6750 }
6751 self.conn
6752 .execute_batch("SAVEPOINT authority_snapshot")
6753 .map_err(|e| e.to_string())?;
6754 let result = (|| {
6755 let revision = self
6756 .get_meta(LOCAL_REVISION_KEY)?
6757 .ok_or("sync.local_corrupt: authority revision is missing")?;
6758 let parsed_revision = revision
6759 .parse::<u64>()
6760 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
6761 if parsed_revision.to_string() != revision {
6762 return Err("sync.local_corrupt: authority revision is invalid".into());
6763 }
6764 let mut tables = Vec::new();
6765 for read in &self.limits.authority_reads {
6766 let table = self
6767 .schema
6768 .table(&read.table)
6769 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
6770 let scalar = |column: &str, value: &Value| -> Result<String, String> {
6771 let ty = table
6772 .columns
6773 .iter()
6774 .find(|c| c.name == column)
6775 .expect("validated authority column")
6776 .ty;
6777 if ty == ColumnType::Boolean {
6778 if let Some(value) = value.as_bool() {
6779 return Ok(value.to_string());
6780 }
6781 return match value.as_i64() {
6782 Some(0) => Ok("false".into()),
6783 Some(1) => Ok("true".into()),
6784 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
6785 };
6786 }
6787 match value {
6788 Value::String(s) => Ok(s.clone()),
6789 Value::Number(n) => Ok(n.to_string()),
6790 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
6791 .get("$bigint")
6792 .and_then(Value::as_str)
6793 .map(str::to_owned)
6794 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
6795 _ => {
6796 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
6797 }
6798 }
6799 };
6800 let matches = |row: &QueryRow| -> Result<bool, String> {
6801 for (variable, values) in &read.scopes {
6802 let column = table
6803 .scope_column(variable)
6804 .expect("validated authority scope");
6805 let value = row
6806 .get(column)
6807 .ok_or("sync.local_corrupt: authority scope column is missing")?;
6808 if value.is_null() || !values.contains(&scalar(column, value)?) {
6809 return Ok(false);
6810 }
6811 }
6812 Ok(true)
6813 };
6814 let selected = read
6815 .columns
6816 .iter()
6817 .map(|c| quote_ident(c))
6818 .collect::<Vec<_>>()
6819 .join(",");
6820 let mut intent = BTreeSet::new();
6821 let mut relevant = BTreeSet::new();
6822 for row in query_connection(&self.conn,
6823 "SELECT id,values_json FROM (SELECT json_extract(value,'$.table') AS tbl,json_extract(value,'$.rowId') AS id,json_extract(value,'$.values') AS values_json FROM _syncular_outbox,json_each(ops_json) UNION ALL SELECT json_extract(value,'$.table'),json_extract(value,'$.rowId'),json_extract(value,'$.values') FROM _syncular_failed_commits,json_each(operations_json) UNION ALL SELECT tbl,id,intent_json FROM _syncular_acked_rows) WHERE tbl=?",
6824 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
6825 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
6826 intent.insert(id.clone());
6827 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
6828 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
6829 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
6830 }
6831 }
6832 let mut rows = BTreeMap::new();
6833 for full in [base_table(&read.table), visible_table(&read.table)] {
6834 for mut values in query_connection(
6835 &self.conn,
6836 &format!(
6837 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
6838 ),
6839 &[],
6840 )
6841 .map_err(|e| e.to_string())?
6842 {
6843 if !matches(&values)? {
6844 continue;
6845 }
6846 let id = scalar(
6847 &table.primary_key,
6848 values
6849 .get(&table.primary_key)
6850 .expect("selected primary key"),
6851 )?;
6852 relevant.insert(id.clone());
6853 if full == base_table(&read.table) {
6854 let version = values
6855 .remove("authority_version")
6856 .expect("selected version");
6857 let version = version
6858 .as_i64()
6859 .ok_or("sync.local_corrupt: authority server version is invalid")?;
6860 if version >= 0 {
6861 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
6862 }
6863 }
6864 }
6865 }
6866 intent.retain(|id| relevant.contains(id));
6867 let rows = rows.into_values().collect::<Vec<_>>();
6868 let mut persisted = Vec::new();
6869 for row in query_connection(
6870 &self.conn,
6871 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
6872 &[Value::String(read.table.clone())],
6873 )
6874 .map_err(|e| e.message)?
6875 {
6876 let state: Value = serde_json::from_str(
6877 row.get("state_json")
6878 .and_then(Value::as_str)
6879 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
6880 )
6881 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
6882 let effective = state.get("effectiveScopes").and_then(Value::as_object);
6883 let status = state
6884 .get("status")
6885 .and_then(Value::as_str)
6886 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
6887 let cursor = state
6888 .get("cursor")
6889 .and_then(Value::as_i64)
6890 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
6891 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
6892 && status == "active"
6893 && cursor >= 0
6894 && state.get("bootstrapState").is_none_or(Value::is_null)
6895 && state.get("params").is_none_or(Value::is_null);
6896 let exposed = effective.map(|s| {
6897 read.scopes
6898 .keys()
6899 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
6900 .collect::<Map<_, _>>()
6901 });
6902 let requested = read
6903 .scopes
6904 .keys()
6905 .map(|v| {
6906 (
6907 v.clone(),
6908 state
6909 .get("requested")
6910 .and_then(|s| s.get(v))
6911 .cloned()
6912 .unwrap_or(json!([])),
6913 )
6914 })
6915 .collect::<Map<_, _>>();
6916 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
6917 }
6918 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
6919 match selectors.split_first() {
6920 None => !candidates.is_empty(),
6921 Some(((variable, values), rest)) => values.iter().all(|value| {
6922 covered(
6923 rest,
6924 &candidates
6925 .iter()
6926 .copied()
6927 .filter(|p| {
6928 p["effectiveScopes"][variable.as_str()]
6929 .as_array()
6930 .is_some_and(|held| {
6931 held.contains(&Value::String(value.clone()))
6932 || held.contains(&Value::String("*".into()))
6933 })
6934 })
6935 .collect::<Vec<_>>(),
6936 )
6937 }),
6938 }
6939 }
6940 let coverage = if covered(
6941 &read.scopes.iter().collect::<Vec<_>>(),
6942 &persisted
6943 .iter()
6944 .filter(|p| p["complete"] == true)
6945 .collect::<Vec<_>>(),
6946 ) {
6947 "complete"
6948 } else if persisted.iter().any(|p| {
6949 p["status"] == "active"
6950 && read.scopes.iter().all(|(v, values)| {
6951 values.iter().any(|value| {
6952 p["requestedScopes"][v].as_array().is_some_and(|held| {
6953 held.contains(&Value::String(value.clone()))
6954 || held.contains(&Value::String("*".into()))
6955 })
6956 })
6957 })
6958 }) {
6959 "pending"
6960 } else {
6961 "missing"
6962 };
6963 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
6964 }
6965 Ok(
6966 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
6967 )
6968 })();
6969 match result {
6970 Ok(snapshot) => {
6971 self.conn
6972 .execute_batch("RELEASE authority_snapshot")
6973 .map_err(|e| e.to_string())?;
6974 Ok(snapshot)
6975 }
6976 Err(error) => {
6977 self.conn
6978 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
6979 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
6980 Err(error)
6981 }
6982 }
6983 }
6984
6985 pub fn query_snapshot(
6989 &mut self,
6990 sql: &str,
6991 params: &[QueryValue],
6992 coverage: &[WindowCoverage],
6993 owner: Option<QueryOwner<'_>>,
6994 ) -> Result<QuerySnapshot, String> {
6995 let result = snapshot_connection(&self.conn, sql, params, coverage);
6996 if let Some(owner) = owner {
6997 self.record_query_read(owner, result.as_ref().err());
6998 }
6999 result.map_err(|failure| failure.to_string())
7000 }
7001
7002 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
7006 let position = self
7007 .query_failures
7008 .iter()
7009 .position(|entry| entry.id == owner.id);
7010 let Some(failure) = failure else {
7011 if let Some(index) = position {
7012 self.query_failures.remove(index);
7013 }
7014 return;
7015 };
7016 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
7017 .into_iter()
7018 .map(str::to_owned)
7019 .collect();
7020 let code = failure.code.unwrap_or("client.query_failed").to_owned();
7021 if position.is_some_and(|index| {
7022 let entry = &self.query_failures[index];
7023 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
7024 }) {
7025 return;
7026 }
7027 if let Some(index) = position {
7028 self.query_failures.remove(index);
7029 }
7030 self.query_failures.push(DiagnosticQueryFailure {
7031 id: owner.id.to_owned(),
7032 tables,
7033 code,
7034 sqlite_code: failure.sqlite_code,
7035 at_ms: self.clock_now_ms(),
7036 });
7037 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
7038 self.query_failures.remove(0);
7039 }
7040 }
7041
7042 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
7045 #[cfg(feature = "bench-internals")]
7046 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
7047 let log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
7048 Box::new(SyncOutcome::Failed {
7049 error_code: "storage.failed".into(),
7050 message,
7051 details: None,
7052 recommended_action: None,
7053 })
7054 })?;
7055 let max_commits = self.limits.max_push_commits_per_request;
7056 let max_ops = self
7057 .limits
7058 .max_push_operations_per_request
7059 .unwrap_or(PUSH_OPS_PER_REQUEST);
7060 let byte_cap = self.limits.max_push_request_bytes;
7061 let accept = self.limits.accept.unwrap_or(if url_capable {
7064 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
7065 } else {
7066 DEFAULT_ACCEPT
7067 });
7068 let header = Frame::ReqHeader {
7069 client_id: self.client_id.clone(),
7070 schema_version: self.schema.version,
7071 log_epoch: log_epoch.clone(),
7072 };
7073 let mut lead = vec![header];
7077 lead.extend(
7078 self.round_failure
7079 .iter()
7080 .map(|code| Frame::RoundFailure { code: code.clone() }),
7081 );
7082 let pull = Frame::PullHeader {
7083 limit_commits: self.limits.limit_commits.unwrap_or(0),
7084 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
7085 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
7086 accept,
7087 };
7088 let mut fresh = Vec::new();
7089 let mut sub_frames = Vec::new();
7090 for sub in self.subs.iter() {
7091 if sub.state != SubState::Active {
7092 continue;
7093 }
7094 let mut scopes = sub.requested.clone();
7095 sort_scope_map(&mut scopes);
7096 sub_frames.push(Frame::Subscription {
7097 id: sub.id.clone(),
7098 table: sub.table.clone(),
7099 scopes,
7100 params: sub.params.clone().map(RawJson),
7101 cursor: sub.cursor,
7102 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
7103 });
7104 fresh.push((
7105 sub.id.clone(),
7106 sub.cursor < 0 && sub.bootstrap_state.is_none(),
7107 ));
7108 }
7109 let fixed_bytes = match byte_cap {
7112 Some(_) => {
7113 let mut frames = lead.clone();
7114 frames.push(pull.clone());
7115 frames.extend(sub_frames.iter().cloned());
7116 encode_message(&Message {
7117 wire_version: WIRE_VERSION,
7118 msg_kind: MsgKind::Request,
7119 frames,
7120 })
7121 .len()
7122 }
7123 None => 0,
7124 };
7125 if let Some(cap) = byte_cap {
7126 if fixed_bytes > cap {
7127 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
7128 }
7129 }
7130 let base_bytes = match byte_cap {
7131 Some(_) => {
7132 let mut frames = lead.clone();
7133 frames.push(pull.clone());
7134 encode_message(&Message {
7135 wire_version: WIRE_VERSION,
7136 msg_kind: MsgKind::Request,
7137 frames,
7138 })
7139 .len()
7140 }
7141 None => 0,
7142 };
7143 let total_outbox = if log_epoch.is_some() {
7144 self.outbox.len()
7145 } else {
7146 0
7147 };
7148 let mut push_frames: Vec<Frame> = Vec::new();
7149 let mut pushed_ids = Vec::new();
7150 let mut ops_in_request = 0usize;
7151 let mut bytes_in_request = 0usize;
7152 let mut deferred_commits = 0usize;
7153 #[cfg(feature = "bench-internals")]
7154 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
7155 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
7156 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
7160 deferred_commits = total_outbox - index;
7161 break;
7162 }
7163 if ops_in_request + commit.ops.len() > max_ops {
7164 if push_frames.is_empty() {
7165 return Err(Self::push_capacity_outcome(
7166 "operations",
7167 max_ops,
7168 commit.ops.len(),
7169 Some(commit.client_commit_id.as_str()),
7170 ));
7171 }
7172 deferred_commits = total_outbox - index;
7173 break;
7174 }
7175 let operations = commit
7176 .ops
7177 .iter()
7178 .map(|op| {
7179 let payload = match self
7180 .schema
7181 .table(&op.table)
7182 .and_then(|table| Some((table, op.presented_values(table)?)))
7183 {
7184 Some((table, values)) => {
7185 let fallback =
7195 self.stored_key_fallback(table, &op.row_id)
7196 .map_err(|message| {
7197 Box::new(SyncOutcome::Failed {
7198 error_code: "storage.failed".into(),
7199 message,
7200 details: None,
7201 recommended_action: None,
7202 })
7203 })?;
7204 encode_sparse_row_json(
7205 table,
7206 &op.row_id,
7207 &values,
7208 &self.encryption,
7209 fallback.as_ref(),
7210 )
7211 .ok()
7212 }
7213 None => None,
7214 };
7215 Ok(ssp2::model::Operation {
7216 table: op.table.clone(),
7217 row_id: op.row_id.clone(),
7218 op: if op.upsert { Op::Upsert } else { Op::Delete },
7219 base_version: op.base_version,
7220 payload,
7221 })
7222 })
7223 .collect::<Result<Vec<_>, Box<SyncOutcome>>>()?;
7224 let frame = Frame::PushCommit {
7225 client_commit_id: commit.client_commit_id.clone(),
7226 operations,
7227 };
7228 let frame_bytes = match byte_cap {
7229 Some(_) => {
7230 let mut frames = lead.clone();
7231 frames.push(frame.clone());
7232 frames.push(pull.clone());
7233 encode_message(&Message {
7234 wire_version: WIRE_VERSION,
7235 msg_kind: MsgKind::Request,
7236 frames,
7237 })
7238 .len()
7239 - base_bytes
7240 }
7241 None => 0,
7242 };
7243 if let Some(cap) = byte_cap {
7247 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
7248 deferred_commits = total_outbox - index;
7249 break;
7250 }
7251 }
7252 push_frames.push(frame);
7253 pushed_ids.push(commit.client_commit_id.clone());
7254 ops_in_request += commit.ops.len();
7255 bytes_in_request += frame_bytes;
7256 }
7257 #[cfg(feature = "bench-internals")]
7258 drop(outbox_phase);
7259 if let Some(cap) = byte_cap {
7260 if fixed_bytes + bytes_in_request > cap {
7261 let blocked = if push_frames.is_empty() {
7264 None
7265 } else {
7266 self.outbox.first().map(|c| c.client_commit_id.as_str())
7267 };
7268 return Err(Self::push_capacity_outcome(
7269 "bytes",
7270 cap,
7271 fixed_bytes + bytes_in_request,
7272 blocked,
7273 ));
7274 }
7275 }
7276 let mut frames = lead;
7277 frames.extend(push_frames);
7278 frames.push(pull);
7279 frames.extend(sub_frames);
7280 let message = Message {
7281 wire_version: WIRE_VERSION,
7282 msg_kind: MsgKind::Request,
7283 frames,
7284 };
7285 Ok((
7286 message,
7287 RequestMeta {
7288 pushed_ids,
7289 fresh,
7290 accept,
7291 deferred_commits,
7292 },
7293 ))
7294 }
7295
7296 fn push_capacity_outcome(
7298 kind: &str,
7299 limit: usize,
7300 size: usize,
7301 client_commit_id: Option<&str>,
7302 ) -> Box<SyncOutcome> {
7303 let mut details = Map::new();
7304 details.insert("kind".to_owned(), Value::from(kind));
7305 details.insert("limit".to_owned(), Value::from(limit as u64));
7306 details.insert("size".to_owned(), Value::from(size as u64));
7307 if let Some(id) = client_commit_id {
7308 details.insert("clientCommitId".to_owned(), Value::from(id));
7309 }
7310 Box::new(SyncOutcome::Failed {
7311 error_code: "client.push_request_too_large".into(),
7312 message: "the push request exceeds a configured capacity limit".into(),
7313 details: Some(Value::Object(details)),
7314 recommended_action: None,
7315 })
7316 }
7317
7318 pub fn progress(&self) -> ProgressObserver {
7322 self.progress.clone()
7323 }
7324
7325 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
7326 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
7327 Ok(prepared) => prepared,
7328 Err(outcome) => return *outcome,
7329 };
7330 let mut next = prepared;
7335 loop {
7336 match self.apply_sync_round(next.exchange(transport)) {
7337 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
7338 crate::AppliedSyncRound::Complete {
7339 outcome, controls, ..
7340 } => {
7341 if self.transport_enabled {
7342 for text in controls {
7343 let _ = transport.realtime_send(&text);
7344 }
7345 } else {
7346 self.disconnect_realtime(transport);
7347 }
7348 return outcome;
7349 }
7350 }
7351 }
7352 }
7353
7354 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
7355 if let SyncOutcome::Failed {
7356 error_code,
7357 message,
7358 details,
7359 ..
7360 } = &mut outcome
7361 {
7362 if let Some((failure, code)) = self
7363 .storage_failure
7364 .borrow()
7365 .as_ref()
7366 .and_then(|failure| failure.code.map(|code| (failure, code)))
7367 {
7368 *error_code = code.to_owned();
7369 *message = match code {
7370 "client.storage_busy" => "local SQLite storage is busy",
7371 "client.storage_full" => "local SQLite storage is full",
7372 "client.storage_io" => "local SQLite storage I/O failed",
7373 "client.storage_corrupt" => "local SQLite storage is corrupt",
7374 _ => unreachable!("unrecognized classified storage failure"),
7375 }
7376 .to_owned();
7377 *details = failure.details();
7378 }
7379 }
7380 let details = match &outcome {
7381 SyncOutcome::Failed { details, .. } => details.as_ref(),
7382 _ => None,
7383 };
7384 let cause_kind = details
7385 .and_then(|details| details.get("causeKind"))
7386 .and_then(Value::as_str)
7387 .and_then(|cause| match cause {
7388 "network" => Some(crate::TransportCauseKind::Network),
7389 "connect" => Some(crate::TransportCauseKind::Connect),
7390 "tls" => Some(crate::TransportCauseKind::Tls),
7391 "io" => Some(crate::TransportCauseKind::Io),
7392 "request" => Some(crate::TransportCauseKind::Request),
7393 "protocol" => Some(crate::TransportCauseKind::Protocol),
7394 "timeout" => Some(crate::TransportCauseKind::Timeout),
7395 "round_deadline" => Some(crate::TransportCauseKind::RoundDeadline),
7396 "aborted" => Some(crate::TransportCauseKind::Aborted),
7397 "body" => Some(crate::TransportCauseKind::Body),
7398 "status" => Some(crate::TransportCauseKind::Status),
7399 "redirect" => Some(crate::TransportCauseKind::Redirect),
7400 "unknown" => Some(crate::TransportCauseKind::Unknown),
7401 _ => None,
7402 });
7403 let http_status = if matches!(
7404 cause_kind,
7405 Some(
7406 crate::TransportCauseKind::Status
7407 | crate::TransportCauseKind::Body
7408 | crate::TransportCauseKind::Redirect
7409 )
7410 ) {
7411 details
7412 .and_then(|details| details.get("httpStatus"))
7413 .and_then(Value::as_f64)
7414 .filter(|status| status.fract() == 0.0 && (100.0..=599.0).contains(status))
7415 .map(|status| status as u16)
7416 } else {
7417 None
7418 };
7419 let retry_delay_ms = self.round_retry_delay_ms;
7420 self.progress.update(|p| match &outcome {
7421 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
7422 if report.failed.is_empty() =>
7423 {
7424 p.state = ProgressState::Complete
7425 }
7426 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
7427 p.state = ProgressState::Failed;
7428 p.error_code = Some("sync.scope_revoked".into());
7429 }
7430 SyncOutcome::Failed { error_code, .. } => {
7431 p.state = ProgressState::Failed;
7432 p.error_code = Some(Self::diagnostic_code(error_code));
7433 p.cause_kind = cause_kind;
7434 p.http_status = http_status;
7435 p.retry_delay_ms = retry_delay_ms;
7436 }
7437 SyncOutcome::RealtimeUnavailable { .. } => {
7438 p.state = ProgressState::Failed;
7439 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
7440 p.retry_delay_ms = retry_delay_ms;
7441 }
7442 });
7443 let completed_at_ms = self.clock_now_ms();
7444 self.last_round = Some(match &outcome {
7445 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
7446 status: "succeeded".to_owned(),
7447 started_at_ms,
7448 completed_at_ms,
7449 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7450 counters: Some(DiagnosticRoundCounters {
7451 pushed: report.pushed,
7452 applied: report.applied.len(),
7453 rejected: report.rejected.len(),
7454 retryable: report.retryable.len(),
7455 conflicts: report.conflicts,
7456 commits_applied: report.commits_applied,
7457 segment_rows_applied: report.segment_rows_applied,
7458 bootstrapping: report.bootstrapping.len(),
7459 resets: report.resets.len(),
7460 revoked: report.revoked.len(),
7461 failed: report.failed.len(),
7462 deferred_commits: report.deferred_commits,
7463 }),
7464 error_code: None,
7465 cause_kind: None,
7466 http_status: None,
7467 },
7468 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
7469 status: "failed".to_owned(),
7470 started_at_ms,
7471 completed_at_ms,
7472 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7473 counters: None,
7474 error_code: Some(Self::diagnostic_code(error_code)),
7475 cause_kind,
7476 http_status,
7477 },
7478 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
7479 status: "failed".to_owned(),
7480 started_at_ms,
7481 completed_at_ms,
7482 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
7483 counters: None,
7484 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
7485 cause_kind: None,
7486 http_status: None,
7487 },
7488 });
7489 outcome
7490 }
7491
7492 pub fn prepare_sync_round(
7495 &mut self,
7496 url_capable: bool,
7497 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
7498 if !self.transport_enabled {
7499 return Err(Box::new(SyncOutcome::Failed {
7500 error_code: "sync.offline".into(),
7501 message: "transport gate is closed".into(),
7502 details: None,
7503 recommended_action: None,
7504 }));
7505 }
7506 if self.active_round.is_some() {
7507 return Err(Box::new(SyncOutcome::Failed {
7508 error_code: "client.round_in_progress".into(),
7509 message: "a sync round is already in flight".into(),
7510 details: None,
7511 recommended_action: None,
7512 }));
7513 }
7514 self.storage_failure.borrow_mut().take();
7515 self.progress.start();
7516 self.round_retry_delay_ms = None;
7517 let started_at_ms = self.clock_now_ms();
7518 let prepared = (|| {
7519 if self.stopped {
7520 return Err(Box::new(SyncOutcome::Ok(SyncReport {
7521 schema_floor: self.schema_floor.clone(),
7522 ..SyncReport::default()
7523 })));
7524 }
7525 if self.security_preflight {
7526 return Err(Box::new(SyncOutcome::Failed {
7527 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
7528 message: "security preflight must complete before syncing".into(),
7529 details: None,
7530 recommended_action: None,
7531 }));
7532 }
7533 self.set_sync_needed(false, false);
7534 self.drain_pending_evictions()
7535 .and_then(|_| self.drop_incompatible_outbox())
7536 .and_then(|_| self.drop_unencodable_outbox(true, None))
7537 .map_err(|message| {
7538 Box::new(SyncOutcome::Failed {
7539 error_code: "storage.failed".into(),
7540 message,
7541 details: None,
7542 recommended_action: None,
7543 })
7544 })?;
7545 let has_log_epoch = self.get_meta(LOG_EPOCH_KEY).map_err(|message| {
7546 Box::new(SyncOutcome::Failed {
7547 error_code: "storage.failed".into(),
7548 message,
7549 details: None,
7550 recommended_action: None,
7551 })
7552 })?;
7553 let uploads = if has_log_epoch.is_some() && self.schema_has_blobs() {
7554 self.pending_blob_uploads().map_err(|e| {
7555 Box::new(SyncOutcome::Failed {
7556 error_code: e.code,
7557 message: e.message,
7558 details: e.details,
7559 recommended_action: None,
7560 })
7561 })?
7562 } else {
7563 Vec::new()
7564 };
7565 if self.realtime_state != RealtimeState::Connected
7566 && self.realtime_policy == RealtimePolicy::Required
7567 {
7568 let retry_delay_ms = self.schedule_background_retry();
7569 self.realtime_retry_delay_ms = Some(retry_delay_ms);
7570 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
7571 state: self.realtime_state,
7572 reason_code: self.realtime_reason_code.clone(),
7573 retry_delay_ms,
7574 }));
7575 }
7576 let (message, meta) = self.build_request(url_capable)?;
7577 let id = uuid::Uuid::new_v4();
7578 self.active_round = Some(id);
7579 let phase = if uploads.is_empty() {
7580 crate::round::RoundPhase::Sync
7581 } else {
7582 crate::round::RoundPhase::UploadGrant
7583 };
7584 Ok(crate::PreparedSyncRound {
7585 id,
7586 started_at_ms,
7587 message,
7588 meta,
7589 uploads: uploads.into(),
7590 phase,
7591 encoded: None,
7592 continuation: None,
7593 completed: None,
7594 realtime: self.realtime_state == RealtimeState::Connected,
7595 scopes: self
7596 .subs
7597 .iter()
7598 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
7599 .collect(),
7600 #[cfg(feature = "bench-internals")]
7601 benchmark_phases: self.benchmark_phases.clone(),
7602 progress: self.progress.clone(),
7603 fixed_now: self.now_ms,
7604 round_deadline_at: None,
7605 })
7606 })();
7607 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
7608 }
7609
7610 pub fn cancel_sync_round(&mut self) {
7613 self.active_round = None;
7614 }
7615
7616 pub fn apply_sync_round(
7619 &mut self,
7620 completed: crate::CompletedSyncRound,
7621 ) -> crate::AppliedSyncRound {
7622 let mut prepared = completed.prepared;
7623 if self.active_round != Some(prepared.id) || self.security_preflight {
7624 return crate::AppliedSyncRound::Complete {
7625 outcome: SyncOutcome::Failed {
7626 error_code: "client.round_cancelled".into(),
7627 message: "sync round belongs to a released client context".into(),
7628 details: None,
7629 recommended_action: None,
7630 },
7631 controls: Vec::new(),
7632 more: false,
7633 bootstrap_advanced: false,
7634 };
7635 }
7636 self.storage_failure.borrow_mut().take();
7637 let (response, mut downloads, transport_failed) = match completed.exchange {
7638 crate::round::ExchangeResult::Upload { id, result } => {
7639 let result = match result {
7640 Err(error)
7645 if error
7646 .details
7647 .as_ref()
7648 .and_then(|details| details.get("retryable"))
7649 == Some(&Value::Bool(false)) =>
7650 {
7651 self.conn
7652 .prepare(
7653 "SELECT commit_id FROM _syncular_blob_commit_refs WHERE blob_id = ?",
7654 )
7655 .and_then(|mut statement| {
7656 statement
7657 .query_map(rusqlite::params![id], |row| row.get::<_, String>(0))?
7658 .collect::<Result<HashSet<_>, _>>()
7659 })
7660 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
7661 .and_then(|refused| {
7662 let commits = self
7663 .outbox
7664 .iter()
7665 .filter(|commit| refused.contains(&commit.client_commit_id))
7666 .cloned()
7667 .collect::<Vec<_>>();
7668 if !commits.is_empty() {
7669 self.drop_outbox_commits(&commits, &error.code, &error.message)?;
7670 }
7671 prepared.message.frames.retain(|frame| {
7672 !matches!(frame, Frame::PushCommit { client_commit_id, .. }
7673 if refused.contains(client_commit_id))
7674 });
7675 prepared.meta.pushed_ids.retain(|id| !refused.contains(id));
7676 prepared.continuation = Some(crate::round::RoundPhase::UploadGrant);
7677 Ok(())
7678 })
7679 .map_err(|message| TransportError::new("client.failed", message))
7680 }
7681 result => result,
7682 };
7683 let result = result.and_then(|()| {
7684 self.conn
7685 .execute(
7686 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
7687 rusqlite::params![id],
7688 )
7689 .map(|_| ())
7690 .map_err(|error| {
7691 TransportError::new(
7692 "client.failed",
7693 Self::sqlite_failure(&self.storage_failure, error),
7694 )
7695 })
7696 });
7697 match result {
7698 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
7699 Err(TransportError {
7700 code,
7701 message,
7702 details,
7703 }) => {
7704 self.active_round = None;
7705 if Self::unreachable_transport(&details) {
7706 self.set_sync_needed(true, false);
7709 } else if Self::retryable_failure_code(&code) {
7710 self.schedule_background_retry();
7711 }
7712 let recommended_action = recommended_action_of(&details);
7713 let outcome = self.finish_sync_round(
7714 prepared.started_at_ms,
7715 SyncOutcome::Failed {
7716 error_code: code,
7717 message,
7718 details,
7719 recommended_action,
7720 },
7721 );
7722 return crate::AppliedSyncRound::Complete {
7723 outcome,
7724 controls: Vec::new(),
7725 more: false,
7726 bootstrap_advanced: false,
7727 };
7728 }
7729 }
7730 }
7731 crate::round::ExchangeResult::Reply {
7732 response,
7733 downloads,
7734 transport_failed,
7735 } => (response, downloads, transport_failed),
7736 };
7737 self.active_round = None;
7738 let before = self
7739 .subs
7740 .iter()
7741 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
7742 .collect::<Vec<_>>();
7743 let outcome = (|| {
7744 let response = match response {
7745 Ok(response) => {
7746 self.round_failure = None;
7749 response
7750 }
7751 Err(TransportError {
7752 code,
7753 message,
7754 details,
7755 }) => {
7756 if ssp2::model::valid_round_failure_code(&code) {
7761 self.round_failure = Some(code.clone());
7762 }
7763 if transport_failed {
7764 if prepared.realtime {
7765 self.set_realtime_state(RealtimeState::Lost, Some(&code));
7766 }
7767 if let Err(message) = self.record_lease_error(&code) {
7768 return SyncOutcome::Failed {
7769 error_code: "storage.failed".to_owned(),
7770 message,
7771 details: None,
7772 recommended_action: None,
7773 };
7774 }
7775 }
7776 if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
7777 {
7778 let delay = self.schedule_background_retry();
7779 if prepared.realtime && transport_failed {
7780 self.realtime_retry_delay_ms = Some(delay);
7781 }
7782 }
7783 let recommended_action = recommended_action_of(&details);
7784 return SyncOutcome::Failed {
7785 error_code: code,
7786 message,
7787 details,
7788 recommended_action,
7789 };
7790 }
7791 };
7792 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
7793 if let SyncOutcome::Ok(report) = &mut outcome {
7794 report.deferred_commits = prepared.meta.deferred_commits;
7795 }
7796 match &outcome {
7797 SyncOutcome::Ok(_) => self.reset_background_retry(),
7798 SyncOutcome::Failed {
7799 error_code,
7800 details,
7801 ..
7802 } if Self::retryable_failure_code(error_code)
7803 && !Self::unreachable_transport(details) =>
7804 {
7805 self.schedule_background_retry();
7806 }
7807 _ => {}
7808 }
7809 if prepared.meta.deferred_commits > 0 {
7810 self.set_sync_needed(true, true);
7811 }
7812 self.previous_version_lifetime_check();
7813 outcome
7814 })();
7815 match &outcome {
7816 SyncOutcome::Ok(report) if !report.bootstrapping.is_empty() => {
7819 self.set_sync_needed(true, false)
7820 }
7821 SyncOutcome::Failed { details, .. } if Self::unreachable_transport(details) => {
7824 self.set_sync_needed(true, false)
7825 }
7826 _ => {}
7827 }
7828 let more = self.transport_enabled
7829 && matches!(&outcome, SyncOutcome::Ok(report)
7830 if report.schema_floor.is_none()
7831 && (!report.bootstrapping.is_empty()
7832 || report.commits_applied > 0
7833 || report.segment_rows_applied > 0
7834 || !report.resets.is_empty()
7835 || report.deferred_commits > 0
7836 || self.sync_needed));
7837 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
7838 && before
7839 != self
7840 .subs
7841 .iter()
7842 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
7843 .collect::<Vec<_>>();
7844 crate::AppliedSyncRound::Complete {
7845 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
7846 controls: if self.transport_enabled {
7847 downloads.controls
7848 } else {
7849 Vec::new()
7850 },
7851 more,
7852 bootstrap_advanced,
7853 }
7854 }
7855
7856 pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
7858
7859 pub fn sync_until_idle(
7860 &mut self,
7861 transport: &mut dyn Transport,
7862 max_rounds: Option<u32>,
7863 ) -> SyncOutcome {
7864 self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
7865 }
7866
7867 pub fn sync_until_idle_with_deadline(
7872 &mut self,
7873 transport: &mut dyn Transport,
7874 max_rounds: Option<u32>,
7875 timeout_ms: Option<u64>,
7876 ) -> SyncOutcome {
7877 if max_rounds == Some(0) {
7880 return SyncOutcome::Failed {
7881 details: None,
7882 error_code: "sync.invalid_request".into(),
7883 message: "maxRounds must be a positive integer".into(),
7884 recommended_action: None,
7885 };
7886 }
7887 if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
7888 return SyncOutcome::Failed {
7889 details: None,
7890 error_code: "sync.invalid_request".into(),
7891 message: "timeoutMs must be a positive integer".into(),
7892 recommended_action: None,
7893 };
7894 }
7895 let started_at_ms = self.clock_now_ms();
7896 let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
7897 self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
7898 }
7899
7900 fn sync_until_idle_bounded(
7901 &mut self,
7902 transport: &mut dyn Transport,
7903 max_rounds: Option<u32>,
7904 started_at_ms: i64,
7905 deadline_ms: Option<i64>,
7906 ) -> SyncOutcome {
7907 let rounds = max_rounds.unwrap_or(20);
7908 let mut aggregate = SyncReport::default();
7909 let mut spent = 0;
7910 while spent < rounds {
7911 if let Some(deadline_ms) = deadline_ms {
7912 if self.clock_now_ms() >= deadline_ms {
7913 return SyncOutcome::Failed {
7914 error_code: "sync.idle_timeout".into(),
7915 message: "sync_until_idle exceeded its deadline".into(),
7916 details: Some(serde_json::json!({
7917 "timeoutMs": deadline_ms
7918 .saturating_sub(started_at_ms) as u64,
7919 "rounds": spent,
7920 })),
7921 recommended_action: Some("retryLater".into()),
7922 };
7923 }
7924 }
7925 spent += 1;
7926 let before = self
7927 .subs
7928 .iter()
7929 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
7930 .collect::<Vec<_>>();
7931 match self.sync(transport) {
7932 SyncOutcome::Failed {
7933 error_code,
7934 message,
7935 details,
7936 recommended_action,
7937 } => {
7938 return SyncOutcome::Failed {
7939 details,
7940 error_code,
7941 message,
7942 recommended_action,
7943 };
7944 }
7945 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
7946 SyncOutcome::BudgetExhausted(_) => {
7947 unreachable!("sync() never exhausts a round budget")
7948 }
7949 SyncOutcome::Ok(report) => {
7950 if max_rounds.is_none()
7951 && report.segment_rows_applied > 0
7952 && !report.bootstrapping.is_empty()
7953 && before
7954 != self
7955 .subs
7956 .iter()
7957 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
7958 .collect::<Vec<_>>()
7959 {
7960 spent = 0;
7961 }
7962 let stop = !self.transport_enabled() || report.schema_floor.is_some();
7965 aggregate.merge(&report);
7966 if stop {
7967 return SyncOutcome::Ok(aggregate);
7968 }
7969 let more = !report.bootstrapping.is_empty()
7975 || report.commits_applied > 0
7976 || report.segment_rows_applied > 0
7977 || !report.resets.is_empty()
7978 || report.deferred_commits > 0
7979 || self.sync_needed;
7980 if !more {
7981 return SyncOutcome::Ok(aggregate);
7982 }
7983 }
7984 }
7985 }
7986 SyncOutcome::BudgetExhausted(aggregate)
7989 }
7990
7991 fn process_response(
7992 &mut self,
7993 transport: &mut dyn Transport,
7994 response: Message,
7995 meta: &RequestMeta,
7996 ) -> SyncOutcome {
7997 #[cfg(feature = "bench-internals")]
7998 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
7999 let mut report = SyncReport {
8000 pushed: meta.pushed_ids.len() as u32,
8001 ..SyncReport::default()
8002 };
8003 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
8004 HashMap::new();
8005 let pushed_ids = meta
8006 .pushed_ids
8007 .iter()
8008 .map(String::as_str)
8009 .collect::<HashSet<_>>();
8010 let mut incomplete_subscriptions: HashSet<&str> =
8011 meta.fresh.iter().map(|(id, _)| id.as_str()).collect();
8012 let mut last_final_push_result_id: Option<String> = None;
8013 for frame in &response.frames {
8014 match frame {
8015 Frame::PushResultDetails {
8016 client_commit_id,
8017 entries,
8018 } => {
8019 let details = rejection_details_by_commit
8020 .entry(client_commit_id.clone())
8021 .or_default();
8022 for entry in entries {
8023 let parsed = match RejectionDetails::parse(&entry.details.0) {
8024 Ok(value) => value,
8025 Err(message) => {
8026 return SyncOutcome::Failed {
8027 details: None,
8028 error_code: "sync.invalid_request".to_owned(),
8029 message,
8030 recommended_action: None,
8031 };
8032 }
8033 };
8034 details.insert(entry.op_index, parsed);
8035 }
8036 }
8037 Frame::PushResult {
8038 client_commit_id,
8039 status,
8040 results,
8041 ..
8042 } if pushed_ids.contains(client_commit_id.as_str())
8043 && Self::push_result_is_final(*status, results) =>
8044 {
8045 last_final_push_result_id = Some(client_commit_id.clone());
8046 }
8047 _ => {}
8048 }
8049 }
8050 if response.wire_version < 2 {
8051 return SyncOutcome::Failed {
8052 details: None,
8053 error_code: "client.invalid_host_response".to_owned(),
8054 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
8055 recommended_action: None,
8056 };
8057 }
8058 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
8059 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
8060 });
8061 let mut frames = response.frames.into_iter().peekable();
8062 match frames.next() {
8063 Some(Frame::RespHeader {
8064 required_schema_version,
8065 latest_schema_version,
8066 log_epoch,
8067 reset_required,
8068 }) => {
8069 if let Some(required) = required_schema_version {
8070 let floor = SchemaFloor {
8073 required_schema_version: Some(required),
8074 latest_schema_version,
8075 };
8076 if let Err(message) = self.set_schema_floor(Some(floor.clone())) {
8077 return SyncOutcome::Failed {
8078 details: None,
8079 error_code: "storage.failed".to_owned(),
8080 message,
8081 recommended_action: None,
8082 };
8083 }
8084 report.schema_floor = Some(floor);
8085 return SyncOutcome::Ok(report);
8086 }
8087 if reset_required {
8088 if frames.next().is_some() {
8089 return SyncOutcome::Failed {
8090 details: None,
8091 error_code: "client.invalid_host_response".to_owned(),
8092 message: "log-epoch reset response contains body frames".to_owned(),
8093 recommended_action: None,
8094 };
8095 }
8096 match self.run_log_epoch_reset(&log_epoch) {
8097 Ok(resets) => {
8098 report.resets = resets;
8099 return SyncOutcome::Ok(report);
8100 }
8101 Err(message) => {
8102 return SyncOutcome::Failed {
8103 details: None,
8104 error_code: "sync.local_corrupt".to_owned(),
8105 message,
8106 recommended_action: None,
8107 };
8108 }
8109 }
8110 }
8111 let stored_epoch = match self.get_meta(LOG_EPOCH_KEY) {
8112 Ok(stored) => stored,
8113 Err(message) => {
8114 return SyncOutcome::Failed {
8115 details: None,
8116 error_code: "storage.failed".to_owned(),
8117 message,
8118 recommended_action: None,
8119 };
8120 }
8121 };
8122 if stored_epoch.as_deref() != Some(log_epoch.as_str()) {
8123 return SyncOutcome::Failed {
8124 details: None,
8125 error_code: "client.invalid_host_response".to_owned(),
8126 message: "server changed logEpoch without requiring a reset".to_owned(),
8127 recommended_action: None,
8128 };
8129 }
8130 }
8131 _ => {
8132 return SyncOutcome::Failed {
8133 details: None,
8134 error_code: "sync.invalid_request".to_owned(),
8135 message: "response does not start with RESP_HEADER".to_owned(),
8136 recommended_action: None,
8137 };
8138 }
8139 }
8140
8141 if let Err(message) = self.begin_coalesced_apply() {
8142 return SyncOutcome::Failed {
8143 details: None,
8144 error_code: "storage.failed".into(),
8145 message,
8146 recommended_action: None,
8147 };
8148 }
8149 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
8150 while let Some(frame) = frames.next() {
8151 match frame {
8152 frame @ Frame::PushResult { .. } => {
8153 let successful = matches!(
8154 &frame,
8155 Frame::PushResult {
8156 status: PushStatus::Applied | PushStatus::Cached,
8157 ..
8158 }
8159 );
8160 let mut results = vec![frame];
8161 if successful {
8162 while matches!(
8163 frames.peek(),
8164 Some(Frame::PushResult {
8165 status: PushStatus::Applied | PushStatus::Cached,
8166 ..
8167 })
8168 ) {
8169 if let Some(next) = frames.next() {
8170 results.push(next);
8171 }
8172 }
8173 }
8174 if self
8175 .handle_push_results(
8176 &results,
8177 &pushed_ids,
8178 &rejection_details_by_commit,
8179 &mut report,
8180 last_final_push_result_id.as_deref(),
8181 )
8182 .is_err()
8183 {
8184 failure = Some((
8185 "client.outcome_persistence_failed".to_owned(),
8186 "local commit outcome could not be persisted".to_owned(),
8187 None,
8188 None,
8189 ));
8190 break;
8191 }
8192 }
8193 Frame::PushResultDetails { .. } => {}
8194 Frame::SubStart {
8195 id,
8196 status,
8197 reason_code,
8198 effective_scopes,
8199 bootstrap: _,
8200 } => {
8201 let mut body = Vec::new();
8202 let mut sub_end: Option<(i64, Option<String>)> = None;
8203 for inner in frames.by_ref() {
8204 match inner {
8205 Frame::SubEnd {
8206 next_cursor,
8207 bootstrap_state,
8208 } => {
8209 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
8210 break;
8211 }
8212 Frame::Unknown { .. } => {}
8213 other => body.push(other),
8214 }
8215 }
8216 if sub_end.is_some() {
8217 incomplete_subscriptions.remove(id.as_str());
8218 }
8219 let section_failure = match self.process_section(
8220 transport,
8221 &id,
8222 status,
8223 &reason_code,
8224 effective_scopes,
8225 body,
8226 sub_end,
8227 meta,
8228 &mut report,
8229 &coalesced_deletes,
8230 ) {
8231 Err(SectionError::Abort(code, message)) => {
8232 Some((code, message, None, None))
8233 }
8234 Err(SectionError::Transport(error)) => {
8235 Some((error.code, error.message, None, error.details))
8236 }
8237 _ => None,
8238 };
8239 if section_failure.is_some() {
8240 failure = section_failure;
8241 break;
8242 }
8243 }
8244 Frame::Lease {
8245 lease_id,
8246 expires_at_ms,
8247 } => {
8248 if let Err(message) = self.set_lease_state(Some(LeaseState {
8251 lease_id: Some(lease_id),
8252 expires_at_ms: Some(expires_at_ms),
8253 error_code: None,
8254 })) {
8255 failure = Some(("storage.failed".to_owned(), message, None, None));
8256 break;
8257 }
8258 }
8259 Frame::Error {
8260 code,
8261 message,
8262 recommended_action,
8263 details,
8264 ..
8265 } => {
8266 let recommended_action =
8269 (!recommended_action.is_empty()).then_some(recommended_action);
8270 let details = details
8271 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
8272 .filter(Value::is_object);
8273 failure = Some((code, message, recommended_action, details));
8274 break;
8275 }
8276 Frame::Unknown { .. } => {}
8277 _ => {
8278 failure = Some((
8279 "sync.invalid_request".to_owned(),
8280 "unexpected frame in response".to_owned(),
8281 None,
8282 None,
8283 ));
8284 break;
8285 }
8286 }
8287 }
8288
8289 let applied = match failure {
8290 Some(failure) => Err(failure),
8291 None => self
8296 .rebuild_overlay_if_dirty()
8297 .and_then(|()| self.drain_pending_evictions())
8298 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
8299 };
8300 if let Err(message) = self.finish_coalesced_apply() {
8303 return SyncOutcome::Failed {
8304 details: None,
8305 error_code: "storage.failed".into(),
8306 message,
8307 recommended_action: None,
8308 };
8309 }
8310 if let Err((error_code, message, recommended_action, details)) = applied {
8311 return SyncOutcome::Failed {
8312 details,
8313 error_code,
8314 message,
8315 recommended_action,
8316 };
8317 }
8318 if !incomplete_subscriptions.is_empty() {
8319 self.set_sync_needed(true, true);
8320 }
8321 self.ack_after_pull(transport);
8322 if self.upgrading && report.bootstrapping.is_empty() {
8325 self.set_upgrading(false);
8326 }
8327 SyncOutcome::Ok(report)
8328 }
8329
8330 fn handle_push_results(
8333 &mut self,
8334 frames: &[Frame],
8335 pushed_ids: &HashSet<&str>,
8336 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
8337 report: &mut SyncReport,
8338 last_final_push_result_id: Option<&str>,
8339 ) -> Result<(), String> {
8340 self.begin_observation("syncular_push_results")?;
8341 let rollback = self.capture();
8342 let conflict_count = self.conflicts.len();
8343 let applied_count = report.applied.len();
8344 let rejected_count = report.rejected.len();
8345 let retryable_count = report.retryable.len();
8346 let mut drained = Vec::new();
8347 let mut batch = ChangeAccumulator::default();
8348 let persisted = (|| {
8349 for frame in frames {
8350 let Frame::PushResult {
8351 client_commit_id,
8352 status,
8353 results,
8354 commit_seq,
8355 } = frame
8356 else {
8357 return Err("unexpected acknowledgement frame".to_owned());
8358 };
8359 if !pushed_ids.contains(client_commit_id.as_str()) {
8360 continue;
8361 }
8362 let Some(index) = self
8363 .outbox
8364 .iter()
8365 .position(|commit| commit.client_commit_id == *client_commit_id)
8366 else {
8367 continue;
8368 };
8369 let status = *status;
8370 let rejection_details = rejection_details_by_commit.get(client_commit_id);
8371 let operations = self.outbox[index].ops.clone();
8372 match status {
8373 PushStatus::Applied | PushStatus::Cached => {
8374 let sequence =
8377 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
8378 let affected: HashSet<_> = operations
8379 .iter()
8380 .map(|op| (op.table.as_str(), op.row_id.as_str()))
8381 .collect();
8382 let prior = self.acknowledged_operations(Some(&affected), None)?;
8383 for (op_index, operation) in operations.iter().enumerate() {
8384 let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
8385 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8386 if delivered {
8387 continue;
8388 }
8389 let table = self
8390 .schema
8391 .table(&operation.table)
8392 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8393 let mut intended: Map<String, Value> = self
8394 .stored_row(table, &operation.row_id, true)?
8395 .map(|(row, _)| {
8396 table
8397 .columns
8398 .iter()
8399 .zip(&row)
8400 .map(|(column, value)| {
8401 (column.name.clone(), column_value_to_json(value))
8402 })
8403 .collect()
8404 })
8405 .unwrap_or_default();
8406 let mut scope_image = intended.clone();
8407 for op in prior
8408 .iter()
8409 .chain(
8410 self.failed_commits
8411 .iter()
8412 .flat_map(|(commit, _)| &commit.ops),
8413 )
8414 .chain(
8415 self.outbox
8416 .iter()
8417 .take(index)
8418 .flat_map(|commit| &commit.ops),
8419 )
8420 .chain(operations.iter().take(op_index + 1))
8421 {
8422 if op.table != operation.table || op.row_id != operation.row_id {
8423 continue;
8424 }
8425 if let Some(values) = &op.values {
8426 intended.extend(values.clone());
8427 } else {
8428 scope_image = intended.clone();
8429 intended.clear();
8430 }
8431 }
8432 self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
8433 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
8434 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
8435 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
8436 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8437 }
8438 let journal_results = results
8439 .iter()
8440 .map(|result| {
8441 let op_index = match result {
8442 OpResult::Applied { op_index }
8443 | OpResult::Conflict { op_index, .. }
8444 | OpResult::Error { op_index, .. } => *op_index,
8445 };
8446 CommitOperationOutcome::Applied { op_index }
8447 })
8448 .collect::<Vec<_>>();
8449 let outcome_status = if status == PushStatus::Applied {
8450 CommitOutcomeStatus::Applied
8451 } else {
8452 CommitOutcomeStatus::Cached
8453 };
8454 let persisted = self
8455 .persist_commit_outcome(
8456 client_commit_id,
8457 outcome_status,
8458 &journal_results,
8459 None,
8460 )
8461 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8462 persisted?;
8463 self.sync_needed = true;
8464 batch.status = true;
8465 batch.outcomes = true;
8466 }
8467 PushStatus::Rejected => {
8468 if results.iter().any(|result| {
8469 matches!(
8470 result,
8471 OpResult::Error {
8472 code,
8473 retryable: true,
8474 ..
8475 } if code == "sync.idempotency_cache_miss"
8476 )
8477 }) {
8478 report.retryable.push(client_commit_id.to_owned());
8481 continue;
8482 }
8483
8484 let mut journal_results = Vec::with_capacity(results.len());
8485 let mut conflicts = Vec::new();
8486 let mut rejections = Vec::new();
8487 for result in results {
8488 match result {
8489 OpResult::Applied { op_index } => {
8490 journal_results.push(CommitOperationOutcome::Applied {
8491 op_index: *op_index,
8492 });
8493 }
8494 OpResult::Conflict {
8495 op_index,
8496 code,
8497 message,
8498 server_version,
8499 server_row,
8500 conflict_columns: conflict_columns_bitmap,
8501 } => {
8502 let operation = operations
8503 .get(*op_index as usize)
8504 .map(CommitOperation::from);
8505 let (table, row_id) = operation
8506 .as_ref()
8507 .map(|op| (op.table.clone(), op.row_id.clone()))
8508 .unwrap_or_default();
8509 let server_row_json = self
8510 .schema
8511 .table(&table)
8512 .and_then(|t| {
8513 decode_row_bytes(t, server_row, &self.encryption)
8514 .ok()
8515 .map(|row| (t, row))
8516 })
8517 .map(|(t, row)| {
8518 let mut map = Map::new();
8519 for (i, column) in t.columns.iter().enumerate() {
8520 map.insert(
8521 column.name.clone(),
8522 column_value_to_json(
8523 row.get(i).unwrap_or(&None),
8524 ),
8525 );
8526 }
8527 map
8528 })
8529 .unwrap_or_default();
8530 let conflict_columns = self
8533 .schema
8534 .table(&table)
8535 .map(|t| {
8536 t.columns
8537 .iter()
8538 .enumerate()
8539 .filter(|(index, _)| {
8540 conflict_columns_bitmap
8541 .get(index / 8)
8542 .is_some_and(|byte| {
8543 byte & (1 << (index % 8)) != 0
8544 })
8545 })
8546 .map(|(_, column)| column.name.clone())
8547 .collect::<Vec<_>>()
8548 })
8549 .unwrap_or_default();
8550 let conflict = ConflictRecord {
8551 client_commit_id: client_commit_id.to_owned(),
8552 op_index: *op_index,
8553 table,
8554 row_id,
8555 code: code.clone(),
8556 message: message.clone(),
8557 server_version: *server_version,
8558 server_row: server_row_json,
8559 conflict_columns,
8560 operation,
8561 };
8562 journal_results.push(CommitOperationOutcome::Conflict {
8563 conflict: conflict.clone(),
8564 });
8565 conflicts.push(conflict);
8566 }
8567 OpResult::Error {
8568 op_index,
8569 code,
8570 message,
8571 retryable,
8572 } => {
8573 let rejection = RejectionRecord {
8574 client_commit_id: client_commit_id.to_owned(),
8575 op_index: *op_index,
8576 code: code.clone(),
8577 message: message.clone(),
8578 retryable: *retryable,
8579 details: rejection_details
8580 .and_then(|details| details.get(op_index))
8581 .cloned(),
8582 operation: operations
8583 .get(*op_index as usize)
8584 .map(CommitOperation::from),
8585 };
8586 journal_results.push(CommitOperationOutcome::Error {
8587 rejection: rejection.clone(),
8588 });
8589 rejections.push(rejection);
8590 }
8591 }
8592 }
8593 if self.retain_failed_commits {
8594 self.retain_failed_commit(client_commit_id, &operations)?;
8595 }
8596 let outcome_status = if conflicts.is_empty() {
8597 CommitOutcomeStatus::Rejected
8598 } else {
8599 CommitOutcomeStatus::Conflict
8600 };
8601 let persisted = self
8602 .persist_commit_outcome(
8603 client_commit_id,
8604 outcome_status,
8605 &journal_results,
8606 Some(&operations),
8607 )
8608 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
8609 persisted?;
8610 batch.conflicts = !conflicts.is_empty();
8611 batch.rejections = !rejections.is_empty();
8612 batch.status = true;
8613 batch.outcomes = true;
8614 self.conflicts.extend(conflicts);
8615 self.rejections.extend(rejections);
8616 }
8617 }
8618 drained.push(self.outbox.remove(index));
8619 if batch.status {
8620 for operation in &operations {
8621 if !self.record_row_scopes(
8622 &mut batch,
8623 &operation.table,
8624 &operation.row_id,
8625 false,
8626 ) {
8627 batch.table(&operation.table);
8628 }
8629 }
8630 }
8631 match status {
8632 PushStatus::Applied | PushStatus::Cached => {
8633 report.applied.push(client_commit_id.to_owned())
8634 }
8635 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
8636 }
8637 }
8638 if batch.outcomes && frames.iter().any(|frame| matches!(frame, Frame::PushResult { client_commit_id, .. } if Some(client_commit_id.as_str()) == last_final_push_result_id)) {
8639 self.prune_commit_outcomes()?;
8640 }
8641 self.reconcile_rows(&drained)?;
8642 self.rebuild_overlay_if_dirty()?;
8643 self.finish_observation("syncular_push_results", batch)
8644 })();
8645 if let Err(error) = persisted {
8646 self.rollback_observation("syncular_push_results");
8647 self.restore(rollback);
8648 report.applied.truncate(applied_count);
8649 report.rejected.truncate(rejected_count);
8650 report.retryable.truncate(retryable_count);
8651 return Err(error);
8652 }
8653 if !rollback.sync_needed && self.sync_needed {
8654 self.sync_intent_queue.push_back(SyncIntent::Interactive);
8655 }
8656 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
8657 Ok(())
8658 }
8659
8660 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
8661 status != PushStatus::Rejected
8662 || !results.iter().any(|result| {
8663 matches!(
8664 result,
8665 OpResult::Error {
8666 code,
8667 retryable: true,
8668 ..
8669 } if code == "sync.idempotency_cache_miss"
8670 )
8671 })
8672 }
8673
8674 #[allow(clippy::too_many_arguments)]
8677 fn process_section(
8678 &mut self,
8679 transport: &mut dyn Transport,
8680 id: &str,
8681 status: SubStatus,
8682 reason_code: &str,
8683 effective_scopes: Vec<(String, Vec<String>)>,
8684 body: Vec<Frame>,
8685 sub_end: Option<(i64, Option<String>)>,
8686 meta: &RequestMeta,
8687 report: &mut SyncReport,
8688 coalesced_deletes: &HashSet<(i64, String, String)>,
8689 ) -> Result<(), SectionError> {
8690 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
8691 return Ok(()); };
8693 if status != SubStatus::Active && sub_end.is_none() {
8694 return Err(SectionError::Abort(
8695 "sync.invalid_request".into(),
8696 "subscription section without SUB_END".into(),
8697 ));
8698 }
8699 match status {
8700 SubStatus::Revoked => {
8701 self.begin_observation("syncular_revocation")
8702 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8703 let rollback = self.capture();
8704 let revoked_count = report.revoked.len();
8705 let failed_count = report.failed.len();
8706 let result = (|| {
8707 let mut batch = ChangeAccumulator::default();
8708 let registered = self.window_unit_by_sub_id(id);
8709 let (table, effective) = {
8711 let sub = &self.subs[sub_index];
8712 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8713 };
8714 let owners = self.scope_owners(&table, id);
8715 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
8716 SectionError::Abort(
8717 "sync.unknown_table".to_owned(),
8718 "revoked scope references an unknown table".to_owned(),
8719 )
8720 })?;
8721 let dropped = self
8722 .drop_failed_commits(|name, values| {
8723 name == table
8724 && owns_local_row(&scoped_table, &effective, &owners, values)
8725 })
8726 .map_err(|message| {
8727 SectionError::Abort("storage.failed".to_owned(), message)
8728 })?;
8729 for commit in dropped {
8730 batch.conflicts = true;
8731 batch.rejections = true;
8732 batch.outcomes = true;
8733 for operation in commit.ops {
8734 batch.table(&operation.table);
8735 }
8736 }
8737 let purged = self.purge_scope_rows(&table, &effective, &owners);
8738 match purged {
8739 Ok(()) => {
8740 self.record_scope_map(&mut batch, &table, &effective);
8741 let sub = &mut self.subs[sub_index];
8742 sub.state = SubState::Revoked;
8743 sub.reason_code = Some(if reason_code.is_empty() {
8744 "sync.scope_revoked".to_owned()
8745 } else {
8746 reason_code.to_owned()
8747 });
8748 report.revoked.push(id.to_owned());
8749 self.persist_sub(&self.subs[sub_index].clone())
8750 .map_err(|message| {
8751 SectionError::Abort("storage.failed".into(), message)
8752 })?;
8753 let dropped = self
8754 .drop_doomed_outbox(&scoped_table, &effective, &owners)
8755 .map_err(|message| {
8756 SectionError::Abort("storage.failed".to_owned(), message)
8757 })?;
8758 if dropped {
8759 batch.status = true;
8760 batch.rejections = true;
8761 batch.outcomes = true;
8762 }
8763 self.delete_unreferenced_cached_blobs().map_err(|message| {
8766 SectionError::Abort("storage.failed".into(), message)
8767 })?;
8768 }
8769 Err(()) => {
8770 let sub = &mut self.subs[sub_index];
8773 sub.state = SubState::Failed;
8774 sub.reason_code = Some("sync.scope_revoked".to_owned());
8775 report.failed.push(id.to_owned());
8776 self.persist_sub(&self.subs[sub_index].clone())
8777 .map_err(|message| {
8778 SectionError::Abort("storage.failed".into(), message)
8779 })?;
8780 }
8781 }
8782 if let Some((base_key, unit)) = registered {
8783 batch.window(&base_key, &self.subs[sub_index].table, &unit);
8784 }
8785 self.rebuild_overlay_if_dirty()
8786 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8787 self.finish_observation("syncular_revocation", batch)
8788 .map_err(|message| {
8789 SectionError::Abort("storage.failed".to_owned(), message)
8790 })?;
8791 Ok(())
8792 })();
8793 if result.is_err() {
8794 self.rollback_observation("syncular_revocation");
8795 self.restore(rollback);
8796 report.revoked.truncate(revoked_count);
8797 report.failed.truncate(failed_count);
8798 }
8799 result
8800 }
8801 SubStatus::Reset => {
8802 self.begin_observation("syncular_reset")
8803 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
8804 let rollback = self.capture();
8805 let reset_count = report.resets.len();
8806 let result = (|| {
8807 let mut batch = ChangeAccumulator::default();
8808 let registered = self.window_unit_by_sub_id(id);
8809 let sub = &mut self.subs[sub_index];
8812 sub.cursor = -1;
8813 sub.bootstrap_state = None;
8814 sub.reason_code = Some(if reason_code.is_empty() {
8815 "sync.cursor_expired".to_owned()
8816 } else {
8817 reason_code.to_owned()
8818 });
8819 report.resets.push(id.to_owned());
8820 self.persist_sub(&self.subs[sub_index].clone())
8821 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8822 if let Some((base_key, unit)) = registered {
8823 batch.window(&base_key, &self.subs[sub_index].table, &unit);
8824 }
8825 self.finish_observation("syncular_reset", batch)
8826 .map_err(|message| {
8827 SectionError::Abort("storage.failed".to_owned(), message)
8828 })?;
8829 Ok(())
8830 })();
8831 if result.is_err() {
8832 self.rollback_observation("syncular_reset");
8833 self.restore(rollback);
8834 report.resets.truncate(reset_count);
8835 }
8836 result
8837 }
8838 SubStatus::Active => {
8839 let fresh = meta
8840 .fresh
8841 .iter()
8842 .find(|(fid, _)| fid == id)
8843 .map(|(_, fresh)| *fresh)
8844 .unwrap_or(false);
8845 let previous = self.subs[sub_index].clone();
8846 self.subs[sub_index].effective = Some(effective_scopes);
8847 let outcome = self
8848 .apply_section_body(
8849 transport,
8850 sub_index,
8851 body,
8852 fresh,
8853 meta,
8854 report,
8855 coalesced_deletes,
8856 )
8857 .and_then(|()| {
8858 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
8859 SectionError::Abort(
8860 "sync.invalid_request".into(),
8861 "subscription section without SUB_END".into(),
8862 )
8863 })?;
8864 self.apply_sub_end(sub_index, cursor, bootstrap_state)
8865 .map_err(|message| {
8866 SectionError::Abort("storage.failed".into(), message)
8867 })
8868 });
8869 match outcome {
8870 Ok(()) => {
8871 if self.subs[sub_index].bootstrap_state.is_some() {
8872 report.bootstrapping.push(id.to_owned());
8873 }
8874 Ok(())
8875 }
8876 Err(SectionError::FailClosed) => {
8877 self.subs[sub_index] = previous.clone();
8878 self.begin_observation("syncular_section_failure")
8879 .map_err(|message| {
8880 SectionError::Abort("storage.failed".into(), message)
8881 })?;
8882 let persisted = (|| {
8883 let mut batch = ChangeAccumulator::default();
8884 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
8885 batch.window(&base_key, &self.subs[sub_index].table, &unit);
8886 }
8887 self.subs[sub_index].state = SubState::Failed;
8888 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
8889 self.persist_sub(&self.subs[sub_index])?;
8890 self.finish_observation("syncular_section_failure", batch)
8891 })();
8892 if let Err(message) = persisted {
8893 self.rollback_observation("syncular_section_failure");
8894 self.subs[sub_index] = previous;
8895 return Err(SectionError::Abort("storage.failed".into(), message));
8896 }
8897 report.failed.push(id.to_owned());
8898 Ok(())
8899 }
8900 Err(error) => {
8901 self.subs[sub_index] = previous;
8904 Err(error)
8905 }
8906 }
8907 }
8908 }
8909 }
8910
8911 fn apply_sub_end(
8912 &mut self,
8913 sub_index: usize,
8914 cursor: i64,
8915 bootstrap_state: Option<String>,
8916 ) -> Result<(), String> {
8917 #[cfg(feature = "bench-internals")]
8918 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
8919 let previous = self.subs[sub_index].clone();
8920 self.begin_observation("syncular_sub_end")?;
8921 let persisted = (|| {
8922 let mut batch = ChangeAccumulator::default();
8923 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
8924 && cursor >= 0
8925 && bootstrap_state.is_none();
8926 if completed {
8927 let table = self
8928 .schema
8929 .table(&previous.table)
8930 .ok_or_else(|| "sync.unknown_table".to_owned())?;
8931 let effective = previous.effective.as_deref().unwrap_or(&[]);
8932 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
8933 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8934 let rows = statement
8935 .query_map(rusqlite::params![previous.table, cursor], |row| {
8936 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
8937 })
8938 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8939 let mut covered = HashSet::new();
8940 for row in rows {
8941 let (id, intent) =
8942 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8943 let values: Map<String, Value> = serde_json::from_str(&intent)
8944 .map_err(|_| "sync.local_corrupt".to_owned())?;
8945 if !effective.is_empty()
8946 && effective.iter().all(|(variable, allowed)| {
8947 table
8948 .scope_column(variable)
8949 .and_then(|column| values.get(column))
8950 .is_some_and(|value| {
8951 allowed.contains(
8952 &value
8953 .as_str()
8954 .map_or_else(|| value.to_string(), str::to_owned),
8955 )
8956 })
8957 })
8958 {
8959 covered.insert(id);
8960 }
8961 }
8962 drop(statement);
8963 for id in covered {
8964 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
8965 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
8966 self.overlay_dirty.table(&previous.table);
8967 batch.table(&previous.table);
8968 }
8969 self.rebuild_overlay_if_dirty()?;
8970 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
8971 batch.window(&base_key, &previous.table, &unit);
8972 }
8973 }
8974 let sub = &mut self.subs[sub_index];
8975 sub.cursor = cursor;
8976 sub.bootstrap_state = bootstrap_state;
8977 sub.reason_code = None;
8980 sub.synced_once = true;
8981 self.persist_sub(&self.subs[sub_index])?;
8982 self.finish_observation("syncular_sub_end", batch)
8983 })();
8984 if persisted.is_err() {
8985 self.rollback_observation("syncular_sub_end");
8986 self.subs[sub_index] = previous;
8987 }
8988 persisted
8989 }
8990
8991 #[allow(clippy::too_many_arguments)]
8992 fn apply_section_body(
8993 &mut self,
8994 transport: &mut dyn Transport,
8995 sub_index: usize,
8996 body: Vec<Frame>,
8997 fresh: bool,
8998 meta: &RequestMeta,
8999 report: &mut SyncReport,
9000 coalesced_deletes: &HashSet<(i64, String, String)>,
9001 ) -> Result<(), SectionError> {
9002 let mut saw_segment = false;
9003 for frame in body {
9004 match frame {
9005 Frame::Commit {
9006 tables,
9007 changes,
9008 commit_seq,
9009 ..
9010 } => {
9011 if self
9012 .apply_commit_frame_coalesced(
9013 &tables,
9014 changes,
9015 commit_seq,
9016 coalesced_deletes,
9017 sub_index,
9018 AppliedServerCommitSource::Pull,
9019 )
9020 .map_err(|(c, m)| SectionError::Abort(c, m))?
9021 {
9022 report.commits_applied += 1;
9023 }
9024 }
9025 Frame::SegmentInline { payload } => {
9026 let segment = decode_rows_segment(&payload)
9027 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
9028 self.progress.update(|p| {
9029 p.phase = ProgressPhase::Import;
9030 p.subscription_id = Some(self.subs[sub_index].id.clone());
9031 p.table = Some(self.subs[sub_index].table.clone());
9032 p.segment_id = None;
9033 p.bytes_received = payload.len() as u64;
9034 p.bytes_total = Some(payload.len() as u64);
9035 p.rows_processed = 0;
9036 p.rows_total =
9037 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
9038 });
9039 let first = !saw_segment;
9040 saw_segment = true;
9041 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9042 report.segment_rows_applied += applied;
9043 }
9044 Frame::SegmentRef {
9045 segment_id,
9046 byte_length,
9047 media_type,
9048 table,
9049 row_count,
9050 as_of_commit_seq,
9051 scope_digest,
9052 row_cursor,
9053 next_row_cursor,
9054 url,
9055 url_expires_at_ms,
9056 ..
9057 } => {
9058 let advertised = match media_type {
9061 MediaType::Rows => {
9062 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
9063 || meta.accept & ACCEPT_INLINE_ROWS != 0
9064 }
9065 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
9066 };
9067 if !advertised {
9068 return Err(SectionError::Abort(
9069 "sync.invalid_request".to_owned(),
9070 format!(
9071 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
9072 media_type.name()
9073 ),
9074 ));
9075 }
9076 self.progress.update(|p| {
9077 p.phase = ProgressPhase::Download;
9078 p.subscription_id = Some(self.subs[sub_index].id.clone());
9079 p.table = Some(table.clone());
9080 p.segment_id = Some(segment_id.clone());
9081 p.bytes_received = 0;
9082 p.bytes_total = u64::try_from(byte_length).ok();
9083 p.rows_processed = 0;
9084 p.rows_total = u64::try_from(row_count).ok();
9085 });
9086 let observer = self.progress.clone();
9087 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
9088 let bytes = if let Some(url) = url {
9089 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
9094 return Err(SectionError::Abort(
9095 "sync.invalid_request".to_owned(),
9096 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
9097 .to_owned(),
9098 ));
9099 }
9100 if url_expires_at_ms.is_some_and(|exp| {
9102 exp <= transport
9103 .segment_fetch_started_at(&url)
9104 .unwrap_or_else(|| self.clock_now_ms())
9105 }) {
9106 return Err(SectionError::Abort(
9107 "sync.segment_expired".to_owned(),
9108 format!(
9109 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
9110 ),
9111 ));
9112 }
9113 transport
9114 .fetch_url(
9115 &url,
9116 u64::try_from(byte_length).unwrap_or(0),
9117 &mut on_progress,
9118 )
9119 .map_err(|e| self.segment_fetch_failed(e))?
9120 } else {
9121 let requested_scopes_json =
9122 canonical_scope_json(&self.subs[sub_index].requested);
9123 transport
9124 .download_segment(
9125 &SegmentRequest {
9126 segment_id: segment_id.clone(),
9127 table,
9128 requested_scopes_json,
9129 byte_length: u64::try_from(byte_length).unwrap_or(0),
9130 },
9131 &mut on_progress,
9132 )
9133 .map_err(|e| self.segment_fetch_failed(e))?
9134 };
9135 if bytes.len() as i64 > byte_length {
9139 self.schedule_background_retry();
9140 return Err(SectionError::Abort(
9141 "sync.invalid_request".to_owned(),
9142 "segment exceeds its byteLength".to_owned(),
9143 ));
9144 }
9145 on_progress(bytes.len() as u64);
9146 let digest = Sha256::digest(&bytes);
9147 let expected = segment_id
9148 .strip_prefix("sha256:")
9149 .unwrap_or(segment_id.as_str());
9150 if bytes_to_hex(&digest) != expected {
9151 self.schedule_background_retry();
9153 return Err(SectionError::Abort(
9154 "sync.invalid_request".to_owned(),
9155 "segment bytes do not match the content address (§5.1)".to_owned(),
9156 ));
9157 }
9158 self.progress.update(|p| p.phase = ProgressPhase::Import);
9159 if media_type == MediaType::Sqlite {
9160 if row_cursor.is_some() || next_row_cursor.is_some() {
9163 return Err(SectionError::Abort(
9164 "sync.invalid_request".to_owned(),
9165 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
9166 .to_owned(),
9167 ));
9168 }
9169 let first = !saw_segment;
9170 saw_segment = true;
9171 let applied = self.apply_sqlite_segment(
9172 sub_index,
9173 &bytes,
9174 fresh && first,
9175 row_count,
9176 as_of_commit_seq,
9177 &scope_digest,
9178 )?;
9179 report.segment_rows_applied += applied;
9180 } else {
9181 let segment = decode_rows_segment(&bytes).map_err(|e| {
9182 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
9183 })?;
9184 let first = row_cursor.is_none();
9185 saw_segment = true;
9186 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
9187 report.segment_rows_applied += applied;
9188 }
9189 }
9190 Frame::Unknown { .. } => {}
9191 Frame::Error { code, message, .. } => {
9192 return Err(SectionError::Abort(code, message))
9193 }
9194 _ => {
9195 return Err(SectionError::Abort(
9196 "sync.invalid_request".to_owned(),
9197 "unexpected frame inside a subscription section".to_owned(),
9198 ));
9199 }
9200 }
9201 }
9202 Ok(())
9203 }
9204
9205 fn apply_commit_frame_coalesced(
9209 &mut self,
9210 tables: &[String],
9211 changes: Vec<ssp2::model::Change>,
9212 commit_seq: i64,
9213 coalesced_deletes: &HashSet<(i64, String, String)>,
9214 sub_index: usize,
9215 source: AppliedServerCommitSource,
9216 ) -> Result<bool, (String, String)> {
9217 let changes: Vec<ssp2::model::Change> = changes
9218 .into_iter()
9219 .filter(|change| {
9220 if change.op != Op::Delete {
9221 return true;
9222 }
9223 let Some(table) = tables.get(change.table_index as usize) else {
9224 return true;
9225 };
9226 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
9227 })
9228 .collect();
9229 if changes.is_empty() {
9230 return Ok(false);
9231 }
9232 let affected_tables = changes
9233 .iter()
9234 .filter_map(|change| tables.get(change.table_index as usize))
9235 .cloned()
9236 .collect::<BTreeSet<_>>();
9237 let previous_batches = self.change_queue.len();
9238 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
9239 if self.change_queue.len() > previous_batches {
9240 let subscription_id = self.subs[sub_index].id.clone();
9241 if let Some(batch) = self.change_queue.back_mut() {
9242 batch
9243 .applied_commits
9244 .extend(
9245 affected_tables
9246 .into_iter()
9247 .map(|table| AppliedServerCommit {
9248 subscription_id: subscription_id.clone(),
9249 table,
9250 commit_seq,
9251 source,
9252 }),
9253 );
9254 }
9255 }
9256 Ok(true)
9257 }
9258
9259 fn apply_commit_frame(
9261 &mut self,
9262 tables: &[String],
9263 changes: &[ssp2::model::Change],
9264 commit_seq: Option<i64>,
9265 ) -> Result<(), (String, String)> {
9266 #[cfg(feature = "bench-internals")]
9267 let _phase = self.benchmark_phases.start(Phase::CommitApply);
9268 let rollback = self.capture();
9269 self.coalesce_unit(changes.len())
9270 .and_then(|()| self.begin_observation("syncular_commit"))
9271 .map_err(|message| ("storage.failed".into(), message))?;
9272 let applied = (|| {
9273 let mut batch = ChangeAccumulator::default();
9274 self.record_commit_changes(&mut batch, tables, changes);
9275 if let Some(sequence) = commit_seq {
9276 let storage_failed = |error| {
9277 (
9278 "storage.failed".to_owned(),
9279 Self::sqlite_failure(&self.storage_failure, error),
9280 )
9281 };
9282 let pinned: HashSet<(&str, &str)> = self
9283 .outbox
9284 .iter()
9285 .flat_map(|commit| &commit.ops)
9286 .map(|op| (op.table.as_str(), op.row_id.as_str()))
9287 .collect();
9288 for change in changes {
9289 let table = tables.get(change.table_index as usize).ok_or_else(|| {
9290 (
9291 "sync.invalid_request".to_owned(),
9292 "change tableIndex out of range".to_owned(),
9293 )
9294 })?;
9295 if pinned.contains(&(table.as_str(), change.row_id.as_str())) {
9296 self.conn.prepare_cached("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)")
9297 .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9298 .map_err(storage_failed)?;
9299 }
9300 self.conn.prepare_cached("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3")
9301 .and_then(|mut statement| statement.execute(rusqlite::params![table, change.row_id, sequence]))
9302 .map_err(storage_failed)?;
9303 }
9304 }
9305 self.apply_commit_changes(tables, changes)?;
9306 self.rebuild_overlay_if_dirty()
9307 .map_err(|message| ("storage.failed".into(), message))?;
9308 self.finish_observation("syncular_commit", batch)
9309 .map_err(|message| ("storage.failed".into(), message))
9310 })();
9311 if applied.is_err() {
9312 self.rollback_observation("syncular_commit");
9313 self.restore(rollback);
9314 }
9315 applied
9316 }
9317
9318 fn apply_commit_changes(
9319 &mut self,
9320 tables: &[String],
9321 changes: &[ssp2::model::Change],
9322 ) -> Result<(), (String, String)> {
9323 let changed_tables: BTreeSet<&str> = changes
9328 .iter()
9329 .filter_map(|change| tables.get(change.table_index as usize))
9330 .map(String::as_str)
9331 .collect();
9332 let mut mirror_visible = !self.overlay_dirty.get();
9333 for name in &changed_tables {
9334 if mirror_visible
9335 && self
9336 .schema
9337 .table(name)
9338 .is_some_and(|table| table.indexes.iter().any(|index| index.unique))
9339 {
9340 let (pending, protected) = self
9341 .table_intent(name)
9342 .map_err(|message| ("storage.failed".to_owned(), message))?;
9343 mirror_visible = !pending && !protected;
9344 }
9345 }
9346 for change in changes {
9347 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
9348 (
9349 "sync.invalid_request".to_owned(),
9350 "change tableIndex out of range".to_owned(),
9351 )
9352 })?;
9353 let table = self.schema.table(table_name).ok_or_else(|| {
9354 (
9355 "sync.schema_mismatch".to_owned(),
9356 format!("change targets unknown table {table_name:?}"),
9357 )
9358 })?;
9359 match change.op {
9360 Op::Upsert => {
9361 let payload = change.row.as_ref().ok_or_else(|| {
9362 (
9363 "sync.invalid_request".to_owned(),
9364 "upsert change without row payload".to_owned(),
9365 )
9366 })?;
9367 let row = {
9369 #[cfg(feature = "bench-internals")]
9370 let _phase = self.benchmark_phases.start(Phase::RowDecode);
9371 decode_row_bytes(table, payload, &self.encryption).map_err(|m| {
9372 match m.strip_prefix("client.decrypt_failed: ") {
9374 Some(detail) => {
9375 ("client.decrypt_failed".to_owned(), detail.to_owned())
9376 }
9377 None => ("sync.invalid_request".to_owned(), m),
9378 }
9379 })?
9380 };
9381 let version = change.row_version.unwrap_or(0);
9382 let table_name = table.name.clone();
9383 self.write_base_row(&table_name, &row, version)
9384 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9385 if mirror_visible {
9386 self.write_row(&visible_table(&table_name), &table_name, &row, version)
9387 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9388 }
9389 }
9390 Op::Delete => {
9391 self.delete_base_row(table_name, &change.row_id)
9392 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
9393 if mirror_visible {
9394 let sql = format!(
9395 "DELETE FROM {} WHERE {}",
9396 visible_table(table_name),
9397 row_id_predicate(table)
9398 );
9399 self.conn
9400 .prepare_cached(&sql)
9401 .and_then(|mut statement| {
9402 statement.execute(rusqlite::params![change.row_id])
9403 })
9404 .map_err(|error| {
9405 ("sync.invalid_request".to_owned(), error.to_string())
9406 })?;
9407 }
9408 }
9409 }
9410 }
9411 if mirror_visible {
9412 let changed_rows: HashSet<(&str, &str)> = changes
9413 .iter()
9414 .map(|change| {
9415 (
9416 tables[change.table_index as usize].as_str(),
9417 change.row_id.as_str(),
9418 )
9419 })
9420 .collect();
9421 self.replay_overlay(&changed_tables, Some(&changed_rows))
9422 .map_err(|message| ("storage.failed".to_owned(), message))?;
9423 self.overlay_dirty.set(false);
9424 }
9425 Ok(())
9426 }
9427
9428 fn apply_segment(
9433 &mut self,
9434 sub_index: usize,
9435 segment: &RowsSegment,
9436 first_fresh_page: bool,
9437 ) -> Result<u32, SectionError> {
9438 let (sub_table, effective, owners) = {
9439 let sub = &self.subs[sub_index];
9440 (
9441 sub.table.clone(),
9442 sub.effective.clone().unwrap_or_default(),
9443 self.scope_owners(&sub.table, &sub.id),
9444 )
9445 };
9446 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9447 SectionError::Abort(
9448 "sync.schema_mismatch".to_owned(),
9449 format!("subscription table {sub_table:?} is not in the client schema"),
9450 )
9451 })?;
9452 let matches = segment.table == table.name
9457 && segment.schema_version == self.schema.version
9458 && segment.columns.len() == table.wire_columns.len()
9459 && segment
9460 .columns
9461 .iter()
9462 .zip(table.wire_columns.iter())
9463 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
9464 if !matches {
9465 return Err(SectionError::Abort(
9466 "sync.schema_mismatch".to_owned(),
9467 "segment column table does not match the generated schema (§5.2)".to_owned(),
9468 ));
9469 }
9470 let mut applied = 0u32;
9471 let empty = Vec::new();
9472 let blocks = if segment.blocks.is_empty() {
9473 std::slice::from_ref(&empty)
9474 } else {
9475 &segment.blocks
9476 };
9477 for (index, block) in blocks.iter().enumerate() {
9478 let clear = first_fresh_page && index == 0;
9479 if !clear && block.is_empty() {
9480 continue;
9481 }
9482 let rollback = self.capture();
9483 let (pending, protected) = self
9484 .table_intent(&table.name)
9485 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9486 let reconcile_pending =
9487 !protected && pending && table.indexes.iter().any(|index| index.unique);
9488 let mirror_visible = !protected && !reconcile_pending;
9489 self.coalesce_unit(block.len())
9490 .and_then(|()| self.begin_observation("syncular_segment_block"))
9491 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9492 let outcome = (|| {
9493 self.rebuild_overlay_if_dirty()
9494 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9495 let mut changed_keys = Vec::new();
9496 let mut batch = ChangeAccumulator::default();
9497 if !block.is_empty()
9498 || (clear && self.scoped_rows_exist(&table.name, &effective, &owners))
9499 {
9500 batch.table(&table.name);
9501 }
9502 if clear {
9503 self.purge_scope_rows(&table.name, &effective, &owners)
9504 .map_err(|()| SectionError::FailClosed)?;
9505 }
9506 for (row_index, row) in block.iter().enumerate() {
9507 let decrypted;
9508 let values = if table.has_encrypted_columns() {
9509 let mut values = row.values.clone();
9510 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
9511 .map_err(|message| {
9512 SectionError::Abort("client.decrypt_failed".into(), message)
9513 })?;
9514 decrypted = values;
9515 &decrypted
9516 } else {
9517 &row.values
9518 };
9519 self.write_base_row(&table.name, values, row.server_version)
9520 .map_err(|message| {
9521 SectionError::Abort("sync.invalid_request".into(), message)
9522 })?;
9523 if reconcile_pending {
9524 changed_keys.push(
9525 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
9526 |message| SectionError::Abort("storage.failed".into(), message),
9527 )?,
9528 );
9529 }
9530 if mirror_visible {
9531 self.write_row(
9532 &visible_table(&table.name),
9533 &table.name,
9534 values,
9535 row.server_version,
9536 )
9537 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9538 }
9539 let processed = u64::from(applied) + row_index as u64 + 1;
9540 if processed.is_multiple_of(1024) {
9541 self.progress.update(|p| p.rows_processed = processed);
9542 }
9543 }
9544 self.reconcile_import(&table, &mut changed_keys, protected, reconcile_pending)
9545 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9546 self.finish_observation("syncular_segment_block", batch)
9547 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
9548 })();
9549 if let Err(error) = outcome {
9550 self.rollback_observation("syncular_segment_block");
9551 self.restore(rollback);
9552 return Err(error);
9553 }
9554 applied += block.len() as u32;
9555 self.progress
9556 .update(|p| p.rows_processed = u64::from(applied));
9557 std::thread::yield_now();
9558 }
9559 self.progress
9560 .update(|p| p.rows_processed = u64::from(applied));
9561 Ok(applied)
9562 }
9563
9564 fn apply_sqlite_segment(
9572 &mut self,
9573 sub_index: usize,
9574 bytes: &[u8],
9575 first_fresh_page: bool,
9576 row_count: i64,
9577 as_of_commit_seq: i64,
9578 scope_digest: &str,
9579 ) -> Result<u32, SectionError> {
9580 let invalid = |detail: &str| {
9581 SectionError::Abort(
9582 "sync.invalid_request".to_owned(),
9583 format!("sqlite segment rejected: {detail} (§5.3)"),
9584 )
9585 };
9586 let (sub_table, effective, owners) = {
9587 let sub = &self.subs[sub_index];
9588 (
9589 sub.table.clone(),
9590 sub.effective.clone().unwrap_or_default(),
9591 self.scope_owners(&sub.table, &sub.id),
9592 )
9593 };
9594 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
9595 SectionError::Abort(
9596 "sync.schema_mismatch".to_owned(),
9597 format!("subscription table {sub_table:?} is not in the client schema"),
9598 )
9599 })?;
9600 if table.has_encrypted_columns() {
9603 return Err(invalid("table has encrypted columns"));
9604 }
9605
9606 let directory = self
9610 .previous_version_replica_path()
9611 .and_then(|replica| std::path::Path::new(&replica).parent().map(Into::into))
9612 .unwrap_or_else(std::env::temp_dir);
9613 let path = directory.join(format!(".syncular-image-{}.tmp", uuid::Uuid::new_v4()));
9614 let outcome = (|| {
9615 let mut options = std::fs::OpenOptions::new();
9616 options.write(true).create_new(true);
9617 #[cfg(unix)]
9618 std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
9619 std::io::Write::write_all(
9620 &mut options
9621 .open(&path)
9622 .map_err(|_| invalid("image temp file write failed"))?,
9623 bytes,
9624 )
9625 .map_err(|_| invalid("image temp file write failed"))?;
9626 let img = rusqlite::Connection::open_with_flags(
9627 &path,
9628 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
9629 )
9630 .map_err(|_| invalid("bytes do not open as a SQLite database"))?;
9631 self.apply_sqlite_image(
9632 &img,
9633 &table,
9634 first_fresh_page,
9635 &effective,
9636 &owners,
9637 row_count,
9638 as_of_commit_seq,
9639 scope_digest,
9640 )
9641 })();
9642 let _ = std::fs::remove_file(&path);
9643 outcome
9644 }
9645
9646 #[allow(clippy::too_many_arguments)]
9647 fn apply_sqlite_image(
9648 &mut self,
9649 img: &rusqlite::Connection,
9650 table: &crate::schema::TableSchema,
9651 first_fresh_page: bool,
9652 effective: &[(String, Vec<String>)],
9653 owners: &[Vec<(String, Vec<String>)>],
9654 row_count: i64,
9655 as_of_commit_seq: i64,
9656 scope_digest: &str,
9657 ) -> Result<u32, SectionError> {
9658 let invalid = |detail: String| {
9659 SectionError::Abort(
9660 "sync.invalid_request".to_owned(),
9661 format!("sqlite segment rejected: {detail} (§5.3)"),
9662 )
9663 };
9664
9665 type MetaRow = (i64, String, i64, i64, String, i64, i64);
9668 let meta: MetaRow = img
9669 .query_row(
9670 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
9671 \"scopeDigest\", \"rowCount\",
9672 (SELECT count(*) FROM _syncular_segment)
9673 FROM _syncular_segment",
9674 [],
9675 |row| {
9676 Ok((
9677 row.get(0)?,
9678 row.get(1)?,
9679 row.get(2)?,
9680 row.get(3)?,
9681 row.get(4)?,
9682 row.get(5)?,
9683 row.get(6)?,
9684 ))
9685 },
9686 )
9687 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
9688 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
9689 if meta_count != 1 {
9690 return Err(invalid(format!(
9691 "_syncular_segment must contain exactly one row, found {meta_count}"
9692 )));
9693 }
9694 if format != 1 {
9695 return Err(invalid(format!("format {format}")));
9696 }
9697 if meta_table != table.name {
9698 return Err(invalid(format!("image table {meta_table:?}")));
9699 }
9700 if schema_version != i64::from(self.schema.version) {
9701 return Err(invalid(format!("schemaVersion {schema_version}")));
9702 }
9703 if pin != as_of_commit_seq {
9704 return Err(invalid(format!("asOfCommitSeq {pin}")));
9705 }
9706 if digest != scope_digest {
9707 return Err(invalid("scopeDigest mismatch".to_owned()));
9708 }
9709 if meta_rows != row_count {
9710 return Err(invalid(format!("rowCount {meta_rows}")));
9711 }
9712
9713 let mut names: Vec<String> = Vec::new();
9715 let mut primary_keys: Vec<String> = Vec::new();
9716 {
9717 let mut stmt = img
9718 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
9719 .map_err(|_| invalid("image data table missing".to_owned()))?;
9720 let mut rows = stmt
9721 .query([])
9722 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
9723 while let Some(row) = rows
9724 .next()
9725 .map_err(|_| invalid("image data table unreadable".to_owned()))?
9726 {
9727 if row
9728 .get::<_, i64>(5)
9729 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
9730 > 0
9731 {
9732 primary_keys.push(row.get(1).map_err(|error| {
9733 invalid(Self::sqlite_failure(&self.storage_failure, error))
9734 })?);
9735 }
9736 names.push(
9737 row.get::<_, String>(1)
9738 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
9739 );
9740 }
9741 }
9742 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
9743 expected.push("_syncular_version");
9744 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
9745 return Err(SectionError::Abort(
9746 "sync.schema_mismatch".to_owned(),
9747 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
9748 ));
9749 }
9750
9751 if primary_keys != vec![table.primary_key.clone()] {
9752 return Err(invalid(
9753 "image primary key does not match generated schema".into(),
9754 ));
9755 }
9756 let actual_count: i64 = img
9757 .query_row(
9758 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
9759 [],
9760 |row| row.get(0),
9761 )
9762 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9763 if actual_count != row_count {
9764 return Err(invalid("image row count does not match descriptor".into()));
9765 }
9766 let insert = self.insert_row_sql(&base_table(&table.name), table);
9767 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
9768 let column_list = names
9769 .iter()
9770 .map(|n| quote_ident(n))
9771 .collect::<Vec<_>>()
9772 .join(", ");
9773 let mut stmt = img
9774 .prepare(&format!(
9775 "SELECT {column_list} FROM {} ORDER BY {}",
9776 quote_ident(&table.name),
9777 quote_ident(&table.primary_key)
9778 ))
9779 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9780 let mut rows = stmt
9781 .query([])
9782 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
9783 let mut applied = 0u32;
9784 loop {
9785 let clear = applied == 0 && first_fresh_page;
9786 let rollback = self.capture();
9787 let (pending, protected) = self
9788 .table_intent(&table.name)
9789 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9790 let reconcile_pending =
9791 !protected && pending && table.indexes.iter().any(|index| index.unique);
9792 let mirror_visible = !protected && !reconcile_pending;
9793 self.coalesce_unit(1024)
9795 .and_then(|()| self.begin_observation("syncular_image_chunk"))
9796 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9797 let result = (|| {
9798 self.rebuild_overlay_if_dirty()
9799 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9800 let mut changed_keys = Vec::new();
9801 let mut batch = ChangeAccumulator::default();
9802 if clear {
9803 if self.scoped_rows_exist(&table.name, effective, owners) {
9804 batch.table(&table.name);
9805 }
9806 self.purge_scope_rows(&table.name, effective, owners)
9807 .map_err(|()| SectionError::FailClosed)?;
9808 }
9809 let mut processed = 0u32;
9810 {
9811 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
9812 invalid(Self::sqlite_failure(&self.storage_failure, error))
9813 })?;
9814 let mut mirror = if mirror_visible {
9815 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
9816 invalid(Self::sqlite_failure(&self.storage_failure, error))
9817 })?)
9818 } else {
9819 None
9820 };
9821 for _ in 0..1024 {
9822 let Some(row) = rows.next().map_err(|error| {
9823 invalid(Self::sqlite_failure(&self.storage_failure, error))
9824 })?
9825 else {
9826 break;
9827 };
9828 for (i, column) in table.columns.iter().enumerate() {
9829 let cell = row.get_ref(i).map_err(|error| {
9830 invalid(Self::sqlite_failure(&self.storage_failure, error))
9831 })?;
9832 let param = image_cell_param(column, cell).map_err(invalid)?;
9833 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
9834 invalid(Self::sqlite_failure(&self.storage_failure, error))
9835 })?;
9836 if let Some(mirror) = &mut mirror {
9837 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
9838 invalid(Self::sqlite_failure(&self.storage_failure, error))
9839 })?;
9840 }
9841 }
9842 let version_index = table.columns.len();
9843 let version: i64 = row.get(version_index).map_err(|error| {
9844 invalid(Self::sqlite_failure(&self.storage_failure, error))
9845 })?;
9846 if version < 1 {
9847 return Err(invalid("image row version must be positive".into()));
9848 }
9849 ins.raw_bind_parameter(version_index + 1, version)
9850 .map_err(|error| {
9851 invalid(Self::sqlite_failure(&self.storage_failure, error))
9852 })?;
9853 ins.raw_execute().map_err(|error| {
9854 invalid(Self::sqlite_failure(&self.storage_failure, error))
9855 })?;
9856 if let Some(mirror) = &mut mirror {
9857 mirror
9858 .raw_bind_parameter(version_index + 1, version)
9859 .map_err(|error| {
9860 invalid(Self::sqlite_failure(&self.storage_failure, error))
9861 })?;
9862 mirror.raw_execute().map_err(|error| {
9863 invalid(Self::sqlite_failure(&self.storage_failure, error))
9864 })?;
9865 }
9866 if reconcile_pending {
9867 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
9868 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
9869 )?);
9870 }
9871 processed += 1;
9872 }
9873 }
9874 if processed > 0 {
9875 batch.table(&table.name);
9876 self.overlay_dirty.table(&table.name);
9877 }
9878 self.reconcile_import(table, &mut changed_keys, protected, reconcile_pending)
9879 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9880 self.finish_observation("syncular_image_chunk", batch)
9881 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
9882 Ok(processed)
9883 })();
9884 let processed = match result {
9885 Ok(processed) => processed,
9886 Err(error) => {
9887 self.rollback_observation("syncular_image_chunk");
9888 self.restore(rollback);
9889 return Err(error);
9890 }
9891 };
9892 applied += processed;
9893 self.progress
9894 .update(|p| p.rows_processed = u64::from(applied));
9895 std::thread::yield_now();
9896 if processed < 1024 || i64::from(applied) == actual_count {
9897 break;
9898 }
9899 }
9900 if i64::from(applied) != actual_count {
9901 return Err(invalid(format!(
9902 "image yielded {applied} rows, expected {actual_count}"
9903 )));
9904 }
9905 Ok(applied)
9906 }
9907
9908 fn reconcile_rows(&self, drained: &[Arc<OutboxCommit>]) -> Result<(), String> {
9913 let names: BTreeSet<&str> = drained
9914 .iter()
9915 .flat_map(|commit| &commit.ops)
9916 .map(|op| op.table.as_str())
9917 .collect();
9918 for name in names {
9919 let Some(table) = self.schema.table(name) else {
9920 continue;
9921 };
9922 let ops = drained.iter().flat_map(|commit| &commit.ops);
9923 let tables = BTreeSet::from([name]);
9924 let rows: HashSet<_> = ops
9925 .clone()
9926 .map(|op| (op.table.as_str(), op.row_id.as_str()))
9927 .collect();
9928 let unique = table.indexes.iter().any(|index| index.unique);
9929 let acknowledged = if unique {
9930 self.acknowledged_operations(None, Some(&tables))?
9931 } else {
9932 Vec::new()
9933 };
9934 let peers = acknowledged
9935 .iter()
9936 .chain(
9937 self.failed_commits
9938 .iter()
9939 .flat_map(|(commit, _)| &commit.ops),
9940 )
9941 .chain(self.outbox.iter().flat_map(|commit| &commit.ops))
9942 .filter(|_| unique);
9943 self.restore_base_rows(table, &mut Vec::new(), peers.chain(ops.clone()))?;
9944 self.replay_overlay(&tables, (!unique).then_some(&rows))?;
9945 }
9946 Ok(())
9947 }
9948
9949 fn table_intent(&self, table: &str) -> Result<(bool, bool), String> {
9952 let touches = |op: &OutboxOp| op.table == table;
9953 Ok((
9954 self.outbox
9955 .iter()
9956 .flat_map(|commit| &commit.ops)
9957 .any(touches),
9958 self.failed_commits
9959 .iter()
9960 .flat_map(|(commit, _)| &commit.ops)
9961 .any(touches)
9962 || self
9963 .conn
9964 .query_row(
9965 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows WHERE tbl=?1)",
9966 [table],
9967 |row| row.get(0),
9968 )
9969 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
9970 ))
9971 }
9972
9973 fn reconcile_import(
9977 &mut self,
9978 table: &TableSchema,
9979 keys: &mut Vec<SqlValue>,
9980 protected: bool,
9981 restore: bool,
9982 ) -> Result<(), String> {
9983 if protected {
9984 return self.rebuild_overlay_if_dirty();
9985 }
9986 let pending = self.outbox.iter().flat_map(|commit| &commit.ops);
9987 if restore {
9988 self.restore_base_rows(table, keys, pending.clone())?;
9989 }
9990 self.apply_outbox_ops(pending.filter(|op| op.table == table.name))?;
9991 self.overlay_dirty.set(false);
9992 Ok(())
9993 }
9994
9995 fn restore_base_rows<'a>(
9999 &self,
10000 table: &TableSchema,
10001 keys: &mut Vec<SqlValue>,
10002 ops: impl IntoIterator<Item = &'a OutboxOp>,
10003 ) -> Result<(), String> {
10004 let visible = visible_table(&table.name);
10005 let base = base_table(&table.name);
10006 let pk = quote_ident(&table.primary_key);
10007 let predicate = row_id_predicate(table);
10008 let mut lookup = self.conn.prepare_cached(&format!(
10009 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
10010 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10011 let mut deleted = HashSet::new();
10012 for op in ops.into_iter().filter(|op| op.table == table.name) {
10013 if op.upsert {
10014 if let Some(values) = &op.values {
10015 let key = json_to_column_value(
10016 &table.columns[table.pk_index],
10017 values.get(&table.primary_key),
10018 )?;
10019 keys.push(owned_sql_value(RowParam::Cell(&key))?);
10020 }
10021 } else if deleted.insert(&op.row_id) {
10022 keys.extend(
10023 lookup
10024 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
10025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10026 .collect::<Result<Vec<_>, _>>()
10027 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10028 );
10029 }
10030 }
10031 for chunk in keys.chunks(400) {
10034 let holes = vec!["?"; chunk.len()].join(",");
10035 self.conn
10036 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
10037 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10038 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10039 }
10040 let updates = table
10041 .columns
10042 .iter()
10043 .map(|column| column.name.as_str())
10044 .chain(std::iter::once("_syncular_version"))
10045 .filter(|name| *name != table.primary_key)
10046 .map(|name| {
10047 let name = quote_ident(name);
10048 format!("{name}=excluded.{name}")
10049 })
10050 .collect::<Vec<_>>()
10051 .join(",");
10052 for chunk in keys.chunks(400) {
10053 let holes = vec!["?"; chunk.len()].join(",");
10054 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
10055 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
10056 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10057 }
10058 Ok(())
10059 }
10060
10061 fn compile_local_data_purge(
10064 &self,
10065 input: &LocalDataPurgeInput,
10066 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
10067 let invalid = |message: String| format!("sync.invalid_request: {message}");
10068 if input.purge_id.is_empty()
10069 || input.purge_id.len() > 128
10070 || !is_local_operation_code_like(&input.purge_id)
10071 {
10072 return Err(invalid(
10073 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
10074 ));
10075 }
10076 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
10077 return Err(invalid(format!(
10078 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
10079 )));
10080 }
10081
10082 let mut deduplicated: BTreeMap<
10083 String,
10084 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
10085 > = BTreeMap::new();
10086 for target in &input.targets {
10087 let table = self.schema.table(&target.table).ok_or_else(|| {
10088 invalid(format!(
10089 "local purge names unknown table {:?}",
10090 target.table
10091 ))
10092 })?;
10093 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
10094 return Err(invalid(format!(
10095 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
10096 target.table
10097 )));
10098 }
10099 let mut selectors = Vec::with_capacity(target.selectors.len());
10100 let mut canonical_selectors = BTreeMap::new();
10101 for (column_name, raw_values) in &target.selectors {
10102 let Some((column_index, column)) = table
10103 .columns
10104 .iter()
10105 .enumerate()
10106 .find(|(_, column)| column.name == *column_name)
10107 else {
10108 return Err(invalid(format!(
10109 "local purge target {:?} names unknown column {:?}",
10110 target.table, column_name
10111 )));
10112 };
10113 let encrypted = table
10114 .encrypted_columns
10115 .iter()
10116 .any(|candidate| candidate.index == column_index);
10117 if column.ty != ColumnType::String || encrypted {
10118 return Err(invalid(format!(
10119 "local purge selector {:?}.{:?} must be a plaintext string column",
10120 target.table, column_name
10121 )));
10122 }
10123 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
10124 return Err(invalid(format!(
10125 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
10126 target.table, column_name
10127 )));
10128 }
10129 let mut values = raw_values.clone();
10130 values.sort();
10131 values.dedup();
10132 if values.iter().any(|value| {
10133 value.is_empty()
10134 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
10135 || !is_local_operation_code_like(value)
10136 }) {
10137 return Err(invalid(format!(
10138 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
10139 )));
10140 }
10141 selectors.push((column_name.clone(), values.clone()));
10142 canonical_selectors.insert(column_name.clone(), values);
10143 }
10144 let canonical = LocalDataPurgeTarget {
10145 table: target.table.clone(),
10146 selectors: canonical_selectors,
10147 };
10148 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10149 deduplicated.insert(
10150 key,
10151 (
10152 CompiledLocalDataPurgeTarget {
10153 table: target.table.clone(),
10154 selectors,
10155 },
10156 canonical,
10157 ),
10158 );
10159 }
10160 let targets = deduplicated
10161 .values()
10162 .map(|(compiled, _)| compiled.clone())
10163 .collect::<Vec<_>>();
10164 let canonical = deduplicated
10165 .values()
10166 .map(|(_, target)| target.clone())
10167 .collect::<Vec<_>>();
10168 let canonical_plan =
10169 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
10170 Ok((targets, canonical_plan))
10171 }
10172
10173 fn local_purge_base_row_ids(
10174 &self,
10175 targets: &[CompiledLocalDataPurgeTarget],
10176 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
10177 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
10178 for target in targets {
10179 let table = self
10180 .schema
10181 .table(&target.table)
10182 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
10183 let mut clauses = Vec::with_capacity(target.selectors.len());
10184 let mut params = Vec::new();
10185 for (column, values) in &target.selectors {
10186 clauses.push(format!(
10187 "{} IN ({})",
10188 quote_ident(column),
10189 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
10190 ));
10191 params.extend(values.iter().cloned().map(SqlValue::Text));
10192 }
10193 let sql = format!(
10194 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
10195 quote_ident(&table.primary_key),
10196 base_table(&target.table),
10197 clauses.join(" AND ")
10198 );
10199 let mut statement = self
10200 .conn
10201 .prepare(&sql)
10202 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10203 let rows = statement
10204 .query_map(rusqlite::params_from_iter(params), |row| {
10205 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
10206 render_row_id_json(Some(&value))
10207 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
10208 })
10209 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10210 let ids = by_table.entry(target.table.clone()).or_default();
10211 for row in rows {
10212 ids.insert(
10213 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
10214 );
10215 }
10216 }
10217 Ok(by_table)
10218 }
10219
10220 fn local_purge_values_match(
10221 target: &CompiledLocalDataPurgeTarget,
10222 values: &Map<String, Value>,
10223 ) -> bool {
10224 target.selectors.iter().all(|(column, allowed)| {
10225 values
10226 .get(column)
10227 .and_then(Value::as_str)
10228 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
10229 })
10230 }
10231
10232 pub fn purge_local_data(
10236 &mut self,
10237 input: &LocalDataPurgeInput,
10238 ) -> Result<LocalDataPurgeResult, String> {
10239 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
10240 let meta_key = format!("localPurge:{}", input.purge_id);
10241 let applied_plan = self
10242 .conn
10243 .query_row(
10244 "SELECT value FROM _syncular_meta WHERE key = ?1",
10245 rusqlite::params![meta_key],
10246 |row| row.get::<_, String>(0),
10247 )
10248 .optional()
10249 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10250 if let Some(applied_plan) = applied_plan {
10251 if applied_plan != canonical_plan {
10252 return Err(format!(
10253 "sync.invalid_request: local purge id {:?} was already used with a different plan",
10254 input.purge_id
10255 ));
10256 }
10257 return Ok(LocalDataPurgeResult {
10258 already_applied: true,
10259 purged_rows: 0,
10260 dropped_commits: 0,
10261 });
10262 }
10263
10264 let _ = self.drop_previous_version();
10268
10269 let rollback = self.capture();
10270 self.begin_observation("syncular_local_purge")?;
10271 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
10272 let row_ids = self.local_purge_base_row_ids(&targets)?;
10273 let retained_dropped = self.drop_failed_commits(|table, values| {
10274 targets.iter().any(|target| {
10275 target.table == table && Self::local_purge_values_match(target, values)
10276 })
10277 })?;
10278 let doomed = self
10279 .outbox
10280 .iter()
10281 .filter(|commit| {
10282 commit.ops.iter().any(|operation| {
10283 let matching_targets = targets
10284 .iter()
10285 .filter(|target| target.table == operation.table)
10286 .collect::<Vec<_>>();
10287 if matching_targets.is_empty() {
10288 return false;
10289 }
10290 if row_ids
10291 .get(&operation.table)
10292 .is_some_and(|ids| ids.contains(&operation.row_id))
10293 {
10294 return true;
10295 }
10296 operation.values.as_ref().is_some_and(|values| {
10297 matching_targets
10298 .iter()
10299 .any(|target| Self::local_purge_values_match(target, values))
10300 })
10301 })
10302 })
10303 .cloned()
10304 .collect::<Vec<_>>();
10305 let doomed_ids = doomed
10306 .iter()
10307 .map(|commit| commit.client_commit_id.clone())
10308 .collect::<BTreeSet<_>>();
10309 let mut batch = ChangeAccumulator::default();
10310 for commit in &retained_dropped {
10311 batch.conflicts = true;
10312 batch.rejections = true;
10313 batch.outcomes = true;
10314 for operation in &commit.ops {
10315 batch.table(&operation.table);
10316 }
10317 }
10318 let mut rejections = Vec::new();
10319 for commit in &doomed {
10320 for operation in &commit.ops {
10321 batch.table(&operation.table);
10322 self.overlay_dirty.table(&operation.table);
10323 }
10324 let results = commit
10325 .ops
10326 .iter()
10327 .enumerate()
10328 .map(|(op_index, operation)| {
10329 let rejection = RejectionRecord {
10330 client_commit_id: commit.client_commit_id.clone(),
10331 op_index: op_index as i32,
10332 code: "client.local_data_purged".to_owned(),
10333 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
10334 retryable: false,
10335 details: None,
10336 operation: Some(CommitOperation::from(operation)),
10337 };
10338 rejections.push(rejection.clone());
10339 CommitOperationOutcome::Error { rejection }
10340 })
10341 .collect::<Vec<_>>();
10342 self.persist_commit_outcome(
10343 &commit.client_commit_id,
10344 CommitOutcomeStatus::Rejected,
10345 &results,
10346 Some(&commit.ops),
10347 )?;
10348 self.delete_outbox_persisted(&commit.client_commit_id)?;
10349 }
10350 if !doomed.is_empty() {
10351 self.outbox
10352 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
10353 self.rejections.extend(rejections);
10354 self.prune_commit_outcomes()?;
10355 batch.status = true;
10356 batch.rejections = true;
10357 batch.outcomes = true;
10358 }
10359
10360 let mut purged_rows = 0usize;
10361 for (table, ids) in &row_ids {
10362 if ids.is_empty() {
10363 continue;
10364 }
10365 batch.table(table);
10366 for row_id in ids {
10367 self.delete_base_row(table, row_id)?;
10368 purged_rows += 1;
10369 }
10370 }
10371 self.rebuild_overlay_if_dirty()?;
10372 self.delete_unreferenced_cached_blobs()?;
10373 self.conn
10374 .execute(
10375 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10376 rusqlite::params![meta_key, canonical_plan],
10377 )
10378 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10379 Ok((
10380 batch,
10381 LocalDataPurgeResult {
10382 already_applied: false,
10383 purged_rows,
10384 dropped_commits: doomed.len(),
10385 },
10386 ))
10387 })()
10388 .and_then(|(batch, result)| {
10389 self.finish_observation("syncular_local_purge", batch)
10390 .map(|()| result)
10391 });
10392 let result = match applied {
10393 Ok(result) => result,
10394 Err(error) => {
10395 self.rollback_observation("syncular_local_purge");
10396 self.restore(rollback);
10397 return Err(error);
10398 }
10399 };
10400 self.cancel_sync_round();
10401 Ok(result)
10402 }
10403
10404 pub fn rebootstrap_local_data(
10410 &mut self,
10411 input: &LocalDataRebootstrapInput,
10412 ) -> Result<LocalDataRebootstrapResult, String> {
10413 if input.rebootstrap_id.is_empty()
10414 || input.rebootstrap_id.len() > 128
10415 || !is_local_operation_code_like(&input.rebootstrap_id)
10416 {
10417 return Err(
10418 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
10419 .to_owned(),
10420 );
10421 }
10422 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
10423 if let Some(persisted) = meta_get(&self.conn, &meta_key).map_err(|_| {
10424 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
10425 })? {
10426 let (retained_commits, reset_subscriptions) =
10427 decode_local_rebootstrap_receipt(&persisted)?;
10428 return Ok(LocalDataRebootstrapResult {
10429 already_applied: true,
10430 retained_commits,
10431 reset_subscriptions,
10432 });
10433 }
10434 if self.stopped || self.schema_floor.is_some() {
10435 return Err(
10436 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
10437 .to_owned(),
10438 );
10439 }
10440
10441 let retained_commits = self.outbox.len();
10442 let reset_subscriptions = self.subs.len();
10443 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
10444 let rollback = self.capture();
10445 self.begin_observation("syncular_local_rebootstrap")?;
10446 let mut batch = ChangeAccumulator::default();
10447 let applied = (|| -> Result<(), String> {
10448 self.run_schema_reset_observed(&mut batch, false)?;
10449 self.conn
10450 .execute(
10451 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
10452 rusqlite::params![meta_key, receipt],
10453 )
10454 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10455 self.sync_needed = true;
10456 self.sync_intent_queue.push_back(SyncIntent::Interactive);
10457 batch.status = true;
10458 Ok(())
10459 })()
10460 .and_then(|()| self.finish_observation("syncular_local_rebootstrap", batch));
10461 if let Err(error) = applied {
10462 self.rollback_observation("syncular_local_rebootstrap");
10463 self.restore(rollback);
10464 return Err(error);
10465 }
10466
10467 Ok(LocalDataRebootstrapResult {
10468 already_applied: false,
10469 retained_commits,
10470 reset_subscriptions,
10471 })
10472 }
10473
10474 fn purge_scope_rows(
10480 &mut self,
10481 table_name: &str,
10482 effective: &[(String, Vec<String>)],
10483 owners: &[Vec<(String, Vec<String>)>],
10484 ) -> Result<(), ()> {
10485 let table = self.schema.table(table_name).ok_or(())?;
10486 let Some((predicate, params)) = local_scope_predicate(table, effective, owners)? else {
10487 return Ok(());
10488 };
10489 self.overlay_dirty.table(table_name);
10490 for source in [base_table(table_name), visible_table(table_name)] {
10491 self.conn
10492 .execute(
10493 &format!("DELETE FROM {source} WHERE {predicate}"),
10494 rusqlite::params_from_iter(¶ms),
10495 )
10496 .map_err(|_| ())?;
10497 }
10498 Ok(())
10499 }
10500
10501 fn drop_doomed_outbox(
10505 &mut self,
10506 table: &TableSchema,
10507 effective: &[(String, Vec<String>)],
10508 owners: &[Vec<(String, Vec<String>)>],
10509 ) -> Result<bool, String> {
10510 let doomed: Vec<_> =
10511 self.outbox
10512 .iter()
10513 .filter(|commit| {
10514 commit.ops.iter().any(|op| {
10515 op.upsert
10516 && op.table == table.name
10517 && op.values.as_ref().is_some_and(|values| {
10518 owns_local_row(table, effective, owners, values)
10519 })
10520 })
10521 })
10522 .cloned()
10523 .collect();
10524 if doomed.is_empty() {
10525 return Ok(false);
10526 }
10527 let mut rejections = Vec::new();
10528 for commit in &doomed {
10529 let results = commit
10530 .ops
10531 .iter()
10532 .enumerate()
10533 .map(|(op_index, operation)| {
10534 self.overlay_dirty.table(&operation.table);
10535 let rejection = RejectionRecord {
10536 client_commit_id: commit.client_commit_id.clone(),
10537 op_index: op_index as i32,
10538 code: "sync.scope_revoked".to_owned(),
10539 message: "the commit was dropped because its effective scope was revoked"
10540 .to_owned(),
10541 retryable: false,
10542 details: None,
10543 operation: Some(CommitOperation::from(operation)),
10544 };
10545 rejections.push(rejection.clone());
10546 CommitOperationOutcome::Error { rejection }
10547 })
10548 .collect::<Vec<_>>();
10549 self.persist_commit_outcome(
10550 &commit.client_commit_id,
10551 CommitOutcomeStatus::Rejected,
10552 &results,
10553 Some(&commit.ops),
10554 )?;
10555 self.delete_outbox_persisted(&commit.client_commit_id)?;
10556 }
10557 self.prune_commit_outcomes()?;
10558 let doomed_ids = doomed
10559 .iter()
10560 .map(|commit| commit.client_commit_id.as_str())
10561 .collect::<BTreeSet<_>>();
10562 self.outbox
10563 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
10564 self.rejections.extend(rejections);
10565 Ok(true)
10566 }
10567
10568 pub fn upload_blob(
10575 &mut self,
10576 bytes: &[u8],
10577 media_type: Option<String>,
10578 name: Option<String>,
10579 ) -> Result<Value, String> {
10580 let blob_id = blob_id_for(bytes);
10581 let now = self.clock_now_ms();
10582 let tx = self
10583 .conn
10584 .transaction()
10585 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10586 tx.execute(
10587 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
10588 ON CONFLICT(blob_id) DO NOTHING",
10589 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
10590 )
10591 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10592 tx.execute(
10593 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
10594 rusqlite::params![blob_id, media_type, now],
10595 )
10596 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10597 tx.commit()
10598 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10599 self.enforce_blob_cache_cap();
10600 let mut obj = Map::new();
10601 obj.insert("blobId".to_owned(), Value::from(blob_id));
10602 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
10603 if let Some(mt) = media_type {
10604 obj.insert("mediaType".to_owned(), Value::from(mt));
10605 }
10606 if let Some(n) = name {
10607 obj.insert("name".to_owned(), Value::from(n));
10608 }
10609 Ok(Value::Object(obj))
10610 }
10611
10612 pub fn fetch_blob_bytes(
10616 &mut self,
10617 transport: &mut dyn Transport,
10618 blob_id_or_ref: &str,
10619 ) -> Result<FetchedBlob, (String, String)> {
10620 let simple = |m: String| ("client.failed".to_owned(), m);
10621 let (blob_id, byte_length) = if blob_id_or_ref.starts_with("sha256:") {
10624 (blob_id_or_ref.to_owned(), None)
10625 } else {
10626 let value: Value = serde_json::from_str(blob_id_or_ref)
10627 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
10628 (
10629 value
10630 .get("blobId")
10631 .and_then(Value::as_str)
10632 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
10633 .to_owned(),
10634 value.get("byteLength").and_then(Value::as_u64),
10635 )
10636 };
10637 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
10638 return Ok(cached);
10639 }
10640 if !self.transport_enabled {
10641 return Err(("sync.offline".into(), "transport gate is closed".into()));
10642 }
10643 #[cfg(feature = "bench-internals")]
10649 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
10650 let bytes = match transport
10651 .blob_download(&blob_id, byte_length)
10652 .map_err(|e| (e.code, e.message))?
10653 {
10654 BlobDownload::Bytes(bytes) => bytes,
10655 BlobDownload::Url {
10656 url,
10657 url_expires_at_ms,
10658 } => {
10659 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
10661 return Err((
10662 "sync.segment_expired".to_owned(),
10663 format!(
10664 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
10665 ),
10666 ));
10667 }
10668 transport
10669 .fetch_blob_url(&url, byte_length)
10670 .map_err(|e| (e.code, e.message))?
10671 }
10672 };
10673 #[cfg(feature = "bench-internals")]
10674 drop(download_phase);
10675 #[cfg(feature = "bench-internals")]
10676 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
10677 if byte_length.is_some_and(|cap| bytes.len() as u64 > cap) {
10680 return Err((
10681 "sync.invalid_request".to_owned(),
10682 "blob exceeds its byteLength".to_owned(),
10683 ));
10684 }
10685 if blob_id_for(&bytes) != blob_id {
10686 return Err(simple(format!(
10687 "blob content address mismatch for {blob_id}"
10688 )));
10689 }
10690 #[cfg(feature = "bench-internals")]
10691 drop(validate_phase);
10692 #[cfg(feature = "bench-internals")]
10693 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
10694 let now = self.clock_now_ms();
10695 self.conn
10696 .execute(
10697 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
10698 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
10699 )
10700 .map_err(|e| simple(e.to_string()))?;
10701 self.conn
10702 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
10703 .map_err(|e| simple(e.to_string()))?;
10704 self.enforce_blob_cache_cap();
10705 #[cfg(feature = "bench-internals")]
10706 drop(insert_phase);
10707 self.get_cached_blob_bytes(&blob_id)
10708 .map_err(simple)?
10709 .ok_or_else(|| simple("blob cache write failed".to_owned()))
10710 }
10711
10712 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
10713 #[cfg(feature = "bench-internals")]
10714 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
10715 let mut stmt = self
10716 .conn
10717 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
10718 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10719 let mut rows = stmt
10720 .query(rusqlite::params![blob_id])
10721 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10722 if let Some(row) = rows
10723 .next()
10724 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
10725 {
10726 let bytes: Vec<u8> = row
10727 .get(0)
10728 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10729 let byte_length: i64 = row
10730 .get(1)
10731 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10732 let media_type: Option<String> = row
10733 .get(2)
10734 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10735 return Ok(Some(FetchedBlob {
10736 blob_id: blob_id.to_owned(),
10737 byte_length,
10738 bytes,
10739 media_type,
10740 }));
10741 }
10742 Ok(None)
10743 }
10744
10745 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
10747 let pending: Vec<(String, Option<String>)> = {
10748 let mut stmt = self
10749 .conn
10750 .prepare(
10751 "SELECT blob_id, media_type FROM (
10752 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
10753 UNION ALL
10754 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
10755 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
10756 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
10757 ) ORDER BY created_at_ms, blob_id",
10758 )
10759 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10760 let rows = stmt
10761 .query_map([], |row| {
10762 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
10763 })
10764 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10765 rows.collect::<Result<_, _>>()
10766 .map_err(|error| match error {
10767 rusqlite::Error::InvalidColumnType(..)
10768 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
10769 "sync.local_corrupt",
10770 "Pending blob upload metadata is invalid",
10771 ),
10772 _ => TransportError::new("client.failed", error.to_string()),
10773 })?
10774 };
10775 let mut uploads = Vec::new();
10776 for (blob_id, media_type) in pending {
10777 let bytes = (|| {
10778 let stored: Option<(SqlValue, SqlValue)> = self
10779 .conn
10780 .query_row(
10781 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
10782 rusqlite::params![blob_id],
10783 |row| Ok((row.get(0)?, row.get(1)?)),
10784 )
10785 .optional()
10786 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
10787 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
10788 return Err(TransportError::new(
10789 "sync.local_corrupt",
10790 "Pending blob upload body is missing or corrupt",
10791 ));
10792 };
10793 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
10794 return Err(TransportError::new(
10795 "sync.local_corrupt",
10796 "Pending blob upload body is missing or corrupt",
10797 ));
10798 }
10799 Ok(bytes)
10800 })();
10801 let failed = bytes.is_err();
10802 uploads.push(crate::round::PendingUpload {
10803 id: blob_id,
10804 bytes,
10805 media_type,
10806 });
10807 if failed {
10808 break;
10809 }
10810 }
10811 Ok(uploads)
10812 }
10813
10814 fn visible_blob_ids_sql(&self) -> String {
10815 let mut selects = Vec::new();
10816 for table in &self.schema.tables {
10817 for column in table
10818 .columns
10819 .iter()
10820 .filter(|column| column.ty == ColumnType::BlobRef)
10821 {
10822 let identifier = quote_ident(&column.name);
10823 selects.push(format!(
10824 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
10825 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
10826 AND json_type({identifier}, '$.blobId') = 'text'",
10827 quote_ident(&table.name),
10828 ));
10829 }
10830 }
10831 if selects.is_empty() {
10832 "SELECT NULL AS blob_id WHERE 0".to_owned()
10833 } else {
10834 selects.join(" UNION ")
10835 }
10836 }
10837
10838 fn enforce_blob_cache_cap(&self) {
10841 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
10842 return;
10843 };
10844 let mut total: i64 = self
10845 .conn
10846 .query_row(
10847 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
10848 [],
10849 |row| row.get(0),
10850 )
10851 .unwrap_or(0);
10852 if total <= max_bytes {
10853 return;
10854 }
10855 let query = format!(
10856 "SELECT blob_id, byte_length FROM _syncular_blobs
10857 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10858 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10859 AND blob_id NOT IN ({})
10860 ORDER BY created_at_ms ASC, blob_id ASC",
10861 self.visible_blob_ids_sql(),
10862 );
10863 let candidates: Vec<(String, i64)> = {
10864 let Ok(mut stmt) = self.conn.prepare(&query) else {
10865 return;
10866 };
10867 let Ok(rows) = stmt.query_map([], |row| {
10868 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
10869 }) else {
10870 return;
10871 };
10872 rows.filter_map(Result::ok).collect()
10873 };
10874 for (blob_id, byte_length) in candidates {
10875 if total <= max_bytes {
10876 break;
10877 }
10878 let _ = self.conn.execute(
10879 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
10880 rusqlite::params![blob_id],
10881 );
10882 total -= byte_length;
10883 }
10884 }
10885
10886 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
10887 #[cfg(feature = "bench-internals")]
10888 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
10889 if !self.schema_has_blobs() {
10890 return Ok(());
10891 }
10892 self.rebuild_overlay_if_dirty()?;
10893 self.conn
10894 .execute(
10895 &format!(
10896 "DELETE FROM _syncular_blobs
10897 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
10898 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
10899 AND blob_id NOT IN ({})",
10900 self.visible_blob_ids_sql(),
10901 ),
10902 [],
10903 )
10904 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10905 Ok(())
10906 }
10907
10908 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
10913 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
10914 return sql.clone();
10915 }
10916 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
10917 columns.push(quote_ident("_syncular_version"));
10918 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
10919 let primary_key = quote_ident(&table.primary_key);
10920 let updates = columns
10921 .iter()
10922 .filter(|column| **column != primary_key)
10923 .map(|column| format!("{column}=excluded.{column}"))
10924 .collect::<Vec<_>>()
10925 .join(", ");
10926 let sql = format!(
10927 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
10928 columns.join(", "),
10929 placeholders.join(", ")
10930 );
10931 self.insert_sql
10932 .borrow_mut()
10933 .insert(full_table.to_owned(), sql.clone());
10934 sql
10935 }
10936
10937 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
10938 self.overlay_dirty.table(table_name);
10939 self.write_row(&base_table(table_name), table_name, row, version)
10940 }
10941
10942 fn write_row(
10943 &self,
10944 full_table: &str,
10945 table_name: &str,
10946 row: &Row,
10947 version: i64,
10948 ) -> Result<(), String> {
10949 #[cfg(feature = "bench-internals")]
10950 let _phase = self.benchmark_phases.start(Phase::RowWrite);
10951 let table = self
10952 .schema
10953 .table(table_name)
10954 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10955 let sql = self.insert_row_sql(full_table, table);
10956 let mut stmt = self
10957 .conn
10958 .prepare_cached(&sql)
10959 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10960 let params = row
10961 .iter()
10962 .map(RowParam::Cell)
10963 .chain(std::iter::once(RowParam::Version(version)));
10964 stmt.execute(rusqlite::params_from_iter(params))
10965 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10966 Ok(())
10967 }
10968
10969 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
10970 let table = self
10971 .schema
10972 .table(table_name)
10973 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
10974 self.overlay_dirty.table(table_name);
10975 let sql = format!(
10976 "DELETE FROM {} WHERE {}",
10977 base_table(table_name),
10978 row_id_predicate(table)
10979 );
10980 let mut stmt = self
10981 .conn
10982 .prepare_cached(&sql)
10983 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10984 stmt.execute(rusqlite::params![row_id])
10985 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
10986 Ok(())
10987 }
10988
10989 fn has_acknowledged_rows(&self) -> Result<bool, String> {
10990 self.conn
10991 .query_row(
10992 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
10993 [],
10994 |row| row.get(0),
10995 )
10996 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
10997 }
10998
10999 fn acknowledged_operations(
11000 &self,
11001 rows: Option<&HashSet<(&str, &str)>>,
11002 tables: Option<&BTreeSet<&str>>,
11003 ) -> Result<Vec<OutboxOp>, String> {
11004 let keys = rows.map(|rows| {
11005 serde_json::to_string(
11006 &rows
11007 .iter()
11008 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
11009 .collect::<Vec<_>>(),
11010 )
11011 .expect("row keys")
11012 });
11013 let mut statement = self
11014 .conn
11015 .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
11016 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11017 let rows = statement
11018 .query_map(
11019 rusqlite::params![
11020 keys,
11021 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
11022 ],
11023 |row| {
11024 #[cfg(test)]
11025 self.acknowledged_replay_count
11026 .set(self.acknowledged_replay_count.get() + 1);
11027 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11028 },
11029 )
11030 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11031 rows.map(|row| {
11032 let (op, intent) =
11033 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11034 let op: CommitOperation =
11035 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
11036 let table = self
11037 .schema
11038 .table(&op.table)
11039 .ok_or_else(|| "sync.unknown_table".to_owned())?;
11040 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
11041 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
11042 } else {
11043 op.values
11044 };
11045 Ok(OutboxOp {
11046 table: op.table,
11047 row_id: op.row_id,
11048 upsert: op.op == "upsert",
11049 base_version: op.base_version,
11050 values,
11051 insert: op.insert,
11052 })
11053 })
11054 .collect()
11055 }
11056
11057 fn drop_acknowledged_rows(
11058 &self,
11059 matches: impl Fn(&str, &Map<String, Value>) -> bool,
11060 ) -> Result<Vec<OutboxCommit>, String> {
11061 let mut statement = self
11062 .conn
11063 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
11064 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11065 let rows = statement
11066 .query_map([], |row| {
11067 Ok((
11068 row.get::<_, String>(0)?,
11069 row.get::<_, String>(1)?,
11070 row.get::<_, String>(2)?,
11071 row.get::<_, String>(3)?,
11072 ))
11073 })
11074 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11075 let mut doomed = HashSet::new();
11076 for row in rows {
11077 let (id, table, intent, row_id) =
11078 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11079 let values =
11080 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
11081 let base_matches = self
11082 .schema
11083 .table(&table)
11084 .map(|schema| self.stored_row(schema, &row_id, true))
11085 .transpose()?
11086 .flatten()
11087 .is_some_and(|(base, _)| {
11088 let schema = self.schema.table(&table).expect("acknowledged table");
11089 let values = schema
11090 .columns
11091 .iter()
11092 .zip(base.iter())
11093 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
11094 .collect();
11095 matches(&table, &values)
11096 });
11097 if matches(&table, &values) || base_matches {
11098 doomed.insert(id);
11099 }
11100 }
11101 let mut dropped = Vec::new();
11102 for id in doomed {
11103 let mut statement = self
11104 .conn
11105 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
11106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11107 let operations = statement
11108 .query_map([&id], |row| row.get::<_, String>(0))
11109 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11110 let mut ops = Vec::new();
11111 for operation in operations {
11112 let json = operation
11113 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11114 let op: CommitOperation =
11115 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
11116 self.overlay_dirty.table(&op.table);
11117 ops.push(OutboxOp {
11118 table: op.table,
11119 row_id: op.row_id,
11120 upsert: op.op == "upsert",
11121 base_version: op.base_version,
11122 values: op.values,
11123 insert: op.insert,
11124 });
11125 }
11126 dropped.push(OutboxCommit {
11127 client_commit_id: id.clone(),
11128 ops,
11129 });
11130 self.conn
11131 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
11132 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11133 }
11134 Ok(dropped)
11135 }
11136
11137 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
11140 if self.overlay_dirty.get() {
11141 self.rebuild_overlay()?;
11142 }
11143 Ok(())
11144 }
11145
11146 fn rebuild_overlay(&mut self) -> Result<(), String> {
11150 #[cfg(feature = "bench-internals")]
11151 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
11152 #[cfg(test)]
11153 self.overlay_rebuild_count
11154 .set(self.overlay_rebuild_count.get() + 1);
11155 let dirty = self.overlay_dirty.snapshot();
11156 let tables: Vec<_> = self
11157 .schema
11158 .tables
11159 .iter()
11160 .filter(|table| {
11161 dirty
11162 .as_ref()
11163 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
11164 })
11165 .cloned()
11166 .collect();
11167 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
11168 self.begin_observation("syncular_overlay")?;
11169 let rebuilt = (|| {
11170 for table in &tables {
11171 for index in &table.fts_indexes {
11172 self.drop_fts_triggers(index)?;
11173 }
11174 let visible = visible_table(&table.name);
11175 let base = base_table(&table.name);
11176 self.conn
11177 .execute_batch(&format!(
11178 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
11179 ))
11180 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11181 }
11182 self.replay_overlay(&names, None)?;
11183 for table in &tables {
11184 for index in &table.fts_indexes {
11185 self.rebuild_fts_projection(table, index)?;
11186 self.create_fts_triggers(table, index)?;
11187 }
11188 }
11189 self.conn
11190 .execute_batch("RELEASE syncular_overlay")
11191 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11192 Ok(())
11193 })();
11194 if rebuilt.is_err() {
11195 self.conn
11196 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
11197 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11198 } else {
11199 self.overlay_dirty.set(false);
11200 }
11201 rebuilt
11202 }
11203
11204 fn replay_overlay(
11208 &self,
11209 tables: &BTreeSet<&str>,
11210 rows: Option<&HashSet<(&str, &str)>>,
11211 ) -> Result<(), String> {
11212 let replays = |op: &&OutboxOp| {
11213 tables.contains(op.table.as_str())
11214 && rows.is_none_or(|rows| rows.contains(&(op.table.as_str(), op.row_id.as_str())))
11215 };
11216 self.apply_outbox_ops(&self.acknowledged_operations(rows, Some(tables))?)?;
11217 for (commit, initial) in self.failed_commits.iter() {
11218 for operation in commit.ops.iter().filter(replays) {
11219 let Some(table) = self.schema.table(&operation.table) else {
11220 continue;
11221 };
11222 if operation.upsert
11223 && self.visible_row(table, &operation.row_id)?.is_none()
11224 && operation.presented_values(table).is_some_and(|values| {
11225 table
11226 .columns
11227 .iter()
11228 .all(|column| values.contains_key(&column.name))
11229 })
11230 {
11231 let key = serde_json::to_string(&(&operation.table, &operation.row_id))
11232 .expect("row key serialization");
11233 if let Some(Value::Object(values)) = initial.get(&key) {
11234 self.apply_outbox_ops(&[OutboxOp {
11235 values: Some(values.clone()),
11236 ..operation.clone()
11237 }])?;
11238 }
11239 }
11240 }
11241 self.apply_outbox_ops(commit.ops.iter().filter(replays))?;
11242 }
11243 self.apply_outbox_ops(
11244 self.outbox
11245 .iter()
11246 .flat_map(|commit| &commit.ops)
11247 .filter(replays),
11248 )
11249 }
11250
11251 fn apply_outbox_ops<'a>(
11256 &self,
11257 ops: impl IntoIterator<Item = &'a OutboxOp>,
11258 ) -> Result<(), String> {
11259 #[cfg(feature = "bench-internals")]
11260 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
11261 for op in ops {
11262 #[cfg(feature = "bench-internals")]
11263 if let Some(phase) = &mut phase {
11264 phase.unit();
11265 }
11266 if op.upsert {
11267 if let Some((table, values)) = self
11268 .schema
11269 .table(&op.table)
11270 .filter(|table| table.indexes.iter().any(|index| index.unique))
11271 .and_then(|table| Some((table, op.presented_values(table)?)))
11272 {
11273 let mut intended: Map<String, Value> = self
11274 .visible_row(table, &op.row_id)?
11275 .map(|(row, _)| {
11276 table
11277 .columns
11278 .iter()
11279 .zip(&row)
11280 .map(|(column, value)| {
11281 (column.name.clone(), column_value_to_json(value))
11282 })
11283 .collect()
11284 })
11285 .unwrap_or_default();
11286 intended.extend(values.into_owned());
11287 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
11288 continue;
11289 }
11290 }
11291 }
11292 self.apply_outbox_op(op)?;
11293 }
11294 Ok(())
11295 }
11296
11297 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
11298 let Some(table) = self.schema.table(&op.table) else {
11299 return Ok(());
11300 };
11301 let visible = visible_table(&table.name);
11302 if !op.upsert {
11303 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
11304 self.conn
11305 .prepare_cached(&sql)
11306 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
11307 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11308 return Ok(());
11309 }
11310 let Some(values) = op.presented_values(table) else {
11314 return Ok(());
11315 };
11316 let full = table
11322 .columns
11323 .iter()
11324 .all(|column| values.contains_key(&column.name));
11325 let local = self.visible_row(table, &op.row_id)?;
11326 if local.is_none() && !full {
11327 return Ok(());
11328 }
11329 let mut row: Row = Vec::with_capacity(table.columns.len());
11330 for (index, column) in table.columns.iter().enumerate() {
11331 let incoming = match values.get(&column.name) {
11332 Some(raw) => json_to_column_value(
11333 if raw.get("$bytes").is_some()
11334 && table
11335 .encrypted_columns
11336 .iter()
11337 .any(|encrypted| encrypted.index == index)
11338 {
11339 &table.wire_columns[index]
11340 } else {
11341 column
11342 },
11343 Some(raw),
11344 ),
11345 None => Ok(local
11346 .as_ref()
11347 .and_then(|(values, _)| values.get(index).cloned())
11348 .flatten()),
11349 };
11350 row.push(incoming.map_err(|_| {
11351 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
11352 })?);
11353 }
11354 let version = local.as_ref().map_or(-1, |(_, version)| *version);
11355 self.write_row(&visible, &table.name, &row, version)
11356 .map_err(|error| {
11357 if error.starts_with("UNIQUE constraint failed:") {
11358 "sync.constraint_violation: local write violates a unique constraint".to_owned()
11359 } else {
11360 error
11361 }
11362 })
11363 }
11364
11365 fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
11369 self.conn
11370 .query_row(
11371 &format!(
11372 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
11373 visible_table(&table.name),
11374 row_id_predicate(table)
11375 ),
11376 rusqlite::params![row_id],
11377 |row| row.get(0),
11378 )
11379 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
11380 }
11381
11382 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
11385 self.stored_row(table, row_id, false)
11386 }
11387
11388 fn stored_row(
11389 &self,
11390 table: &TableSchema,
11391 row_id: &str,
11392 base: bool,
11393 ) -> Result<Option<(Row, i64)>, String> {
11394 let sql = format!(
11395 "SELECT * FROM {} WHERE {}",
11396 if base {
11397 base_table(&table.name)
11398 } else {
11399 visible_table(&table.name)
11400 },
11401 row_id_predicate(table)
11402 );
11403 let mut stmt = self
11404 .conn
11405 .prepare_cached(&sql)
11406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11407 let mut rows = stmt
11408 .query(rusqlite::params![row_id])
11409 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11410 let Some(row) = rows
11411 .next()
11412 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
11413 else {
11414 return Ok(None);
11415 };
11416 let mut values = Vec::with_capacity(table.columns.len());
11417 for (index, column) in table.columns.iter().enumerate() {
11418 let raw = row
11419 .get_ref(index)
11420 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11421 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
11422 if table
11423 .encrypted_columns
11424 .iter()
11425 .any(|encrypted| encrypted.index == index)
11426 {
11427 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
11428 continue;
11429 }
11430 }
11431 let json = sql_ref_to_json(column, raw);
11432 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
11433 "sync.local_corrupt: persisted local row value is invalid".to_owned()
11434 })?);
11435 }
11436 let version: i64 = row
11437 .get(table.columns.len())
11438 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
11439 Ok(Some((values, version)))
11440 }
11441
11442 #[must_use]
11446 pub fn realtime_policy(&self) -> RealtimePolicy {
11447 self.realtime_policy
11448 }
11449
11450 #[must_use]
11452 pub fn realtime_state(&self) -> RealtimeState {
11453 self.realtime_state
11454 }
11455
11456 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
11459 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
11460 return Err(
11461 "sync.invalid_request: disconnect realtime before changing the realtime policy"
11462 .to_owned(),
11463 );
11464 }
11465 self.realtime_policy = policy;
11466 match policy {
11467 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
11468 RealtimePolicy::Required | RealtimePolicy::Optional
11469 if self.realtime_state == RealtimeState::Disabled =>
11470 {
11471 self.set_realtime_state(RealtimeState::Disconnected, None);
11472 }
11473 RealtimePolicy::Required | RealtimePolicy::Optional => {}
11474 }
11475 Ok(())
11476 }
11477
11478 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
11481 self.realtime_state = state;
11482 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
11483 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
11484 self.realtime_reason_code = None;
11485 self.realtime_retry_delay_ms = None;
11486 }
11487 }
11488
11489 #[must_use]
11490 fn realtime_connected(&self) -> bool {
11491 self.realtime_state == RealtimeState::Connected
11492 }
11493
11494 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
11495 if !self.transport_enabled {
11496 return Err("sync.offline: transport gate is closed".into());
11497 }
11498 if self.realtime_policy == RealtimePolicy::Off {
11499 return Err(
11500 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
11501 );
11502 }
11503 if self.realtime_connected() {
11504 return Ok(());
11505 }
11506 self.realtime_state = RealtimeState::Connecting;
11507 match transport.realtime_connect_for_client(&self.client_id) {
11508 Ok(()) => {
11509 self.set_realtime_state(RealtimeState::Connected, None);
11510 Ok(())
11511 }
11512 Err(error) => {
11513 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
11514 Err(format!("{}: {}", error.code, error.message))
11515 }
11516 }
11517 }
11518
11519 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
11520 self.cancel_sync_round();
11521 if self.realtime_state == RealtimeState::Disconnected
11524 || self.realtime_state == RealtimeState::Disabled
11525 {
11526 return;
11527 }
11528 let _ = transport.realtime_close();
11529 self.set_realtime_state(
11530 if self.realtime_policy == RealtimePolicy::Off {
11531 RealtimeState::Disabled
11532 } else {
11533 RealtimeState::Disconnected
11534 },
11535 None,
11536 );
11537 self.presence.clear(); }
11539
11540 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
11546 if self.realtime_state != RealtimeState::Connected {
11547 return;
11548 }
11549 self.cancel_sync_round();
11550 let _ = transport.realtime_close();
11551 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
11552 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
11553 self.presence.clear();
11554 }
11555
11556 pub fn set_presence(
11562 &mut self,
11563 transport: &mut dyn Transport,
11564 scope_key: &str,
11565 doc: Option<&Value>,
11566 ) -> Result<(), String> {
11567 if !self.transport_enabled {
11568 return Err("sync.offline: transport gate is closed".into());
11569 }
11570 if !self.realtime_connected() {
11571 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
11572 }
11573 let text = encode_presence_publish(scope_key, doc);
11574 transport
11575 .realtime_send(&text)
11576 .map_err(|e| format!("{}: {}", e.code, e.message))
11577 }
11578
11579 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
11581 self.presence
11582 .get(scope_key)
11583 .map(|peers| peers.values().cloned().collect())
11584 .unwrap_or_default()
11585 }
11586
11587 fn apply_presence(
11589 &mut self,
11590 scope_key: String,
11591 kind: Option<PresenceKind>,
11592 actor_id: Option<String>,
11593 client_id: Option<String>,
11594 doc: Option<Value>,
11595 error: Option<String>,
11596 ) {
11597 if error.is_some() {
11600 return;
11601 }
11602 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
11603 return;
11604 };
11605 let peer_key = format!("{actor_id} {client_id}");
11606 match kind {
11607 PresenceKind::Leave => {
11608 if let Some(peers) = self.presence.get_mut(&scope_key) {
11609 peers.remove(&peer_key);
11610 if peers.is_empty() {
11611 self.presence.remove(&scope_key);
11612 }
11613 }
11614 }
11615 _ => {
11616 let doc = match doc {
11617 Some(Value::Object(_)) => doc.unwrap(),
11618 _ => return,
11619 };
11620 self.presence.entry(scope_key).or_default().insert(
11621 peer_key,
11622 PresencePeer {
11623 actor_id,
11624 client_id,
11625 doc,
11626 },
11627 );
11628 }
11629 }
11630 }
11631
11632 pub fn on_realtime_text(&mut self, text: &str) {
11634 if self.stopped || !self.transport_enabled || self.security_preflight {
11635 return;
11636 }
11637 match parse_control(text) {
11638 Ok(ControlMessage::Hello { requires_sync, .. }) => {
11639 if requires_sync {
11640 self.set_sync_needed(true, true);
11642 }
11643 }
11644 Ok(ControlMessage::Presence {
11645 scope_key,
11646 kind,
11647 actor_id,
11648 client_id,
11649 doc,
11650 error,
11651 ..
11652 }) => {
11653 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
11654 }
11655 Ok(ControlMessage::Wake { .. }) => {
11656 self.set_sync_needed(true, true);
11658 }
11659 _ => {}
11660 }
11661 }
11662
11663 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
11667 let mut transport = crate::round::DownloadResults::default();
11668 self.on_realtime_binary(&mut transport, bytes);
11669 transport.controls
11670 }
11671
11672 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
11673 if self.stopped || !self.transport_enabled || self.security_preflight {
11674 return;
11675 }
11676 #[cfg(feature = "bench-internals")]
11677 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
11678 let message = match decode_message(bytes) {
11679 Ok(m) if m.msg_kind == MsgKind::Response => m,
11680 _ => {
11681 self.set_sync_needed(true, true);
11682 return;
11683 }
11684 };
11685 #[cfg(feature = "bench-internals")]
11686 drop(decode_phase);
11687 let coalesced_deletes =
11688 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
11689 if status != SubStatus::Active {
11690 return false;
11691 }
11692 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
11693 return false;
11694 };
11695 sub.takes_delta() && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
11696 });
11697 let mut frames = message.frames.into_iter();
11698 let mut applied_cursor: Option<i64> = None;
11699 let mut any_covered = false;
11700 let mut dropped = self.begin_coalesced_apply().is_err();
11701 while let Some(frame) = frames.next() {
11702 let Frame::SubStart {
11703 id,
11704 status,
11705 effective_scopes,
11706 ..
11707 } = frame
11708 else {
11709 continue;
11710 };
11711 let mut body = Vec::new();
11712 let mut next_cursor: Option<i64> = None;
11713 for inner in frames.by_ref() {
11714 match inner {
11715 Frame::SubEnd {
11716 next_cursor: nc, ..
11717 } => {
11718 next_cursor = Some(nc);
11719 break;
11720 }
11721 Frame::Unknown { .. } => {}
11722 other => body.push(other),
11723 }
11724 }
11725 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
11726 dropped = true;
11727 continue;
11728 };
11729 let sub = &self.subs[sub_index];
11730 if status != SubStatus::Active || !sub.takes_delta() {
11732 dropped = true;
11733 continue;
11734 }
11735 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
11736 any_covered = true;
11738 continue;
11739 }
11740 let previous_effective = self.subs[sub_index].effective.clone();
11741 let previous_cursor = self.subs[sub_index].cursor;
11742 self.subs[sub_index].effective = Some(effective_scopes);
11743 let mut failed = false;
11744 for inner in body {
11745 match inner {
11746 Frame::Commit {
11747 tables,
11748 changes,
11749 commit_seq,
11750 ..
11751 } => {
11752 if self
11753 .apply_commit_frame_coalesced(
11754 &tables,
11755 changes,
11756 commit_seq,
11757 &coalesced_deletes,
11758 sub_index,
11759 AppliedServerCommitSource::Delta,
11760 )
11761 .is_err()
11762 {
11763 failed = true;
11764 break;
11765 }
11766 }
11767 _ => {
11768 failed = true;
11769 break;
11770 }
11771 }
11772 }
11773 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
11774 self.apply_sub_end(sub_index, cursor, None)
11775 .ok()
11776 .map(|()| cursor)
11777 });
11778 let Some(next_cursor) = committed else {
11779 self.subs[sub_index].effective = previous_effective;
11780 self.subs[sub_index].cursor = previous_cursor;
11781 dropped = true;
11782 continue;
11783 };
11784 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
11785 }
11786 if self.finish_coalesced_apply().is_err() {
11789 applied_cursor = None;
11790 dropped = true;
11791 }
11792 if let Some(cursor) = applied_cursor {
11793 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11795 let _ = transport.realtime_send(&ack);
11796 }
11797 if dropped || (applied_cursor.is_none() && !any_covered) {
11798 self.set_sync_needed(true, true);
11800 }
11801 }
11802
11803 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
11807 if !self.realtime_connected() {
11808 return;
11809 }
11810 let floor = self
11811 .subs
11812 .iter()
11813 .filter(|s| {
11814 s.state == SubState::Active
11815 && s.bootstrap_state.is_none()
11816 && s.synced_once
11817 && s.cursor >= 0
11818 })
11819 .map(|s| s.cursor)
11820 .min();
11821 if let Some(cursor) = floor {
11822 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
11823 let _ = transport.realtime_send(&ack);
11824 }
11825 }
11826}
11827
11828#[cfg(test)]
11829mod previous_version_wiring_tests;
11830
11831#[cfg(test)]
11832mod scope_move_delivery_tests;
11833
11834#[cfg(test)]
11835mod insert_intent_tests;
11836
11837#[cfg(test)]
11838mod robustness_tests;