1use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::{Connection, OpenFlags, OptionalExtension};
15use serde_json::{Map, Value};
16use sha2::{Digest, Sha256};
17use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
18use ssp2::primitives::RawJson;
19use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
20use ssp2::{
21 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
22 PresenceKind,
23};
24
25use crate::api::{
26 ClientChangeBatch, ClientLimits, CommandEffects, CommitOperation, CommitOperationOutcome,
27 CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution, CommitOutcomeStatus,
28 ConflictRecord, CoverageSnapshot, LeaseState, Mutation, PresencePeer, QuerySnapshot,
29 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
30 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
31 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
32};
33use crate::schema::{parse_schema_json, ClientSchema};
34use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
35use crate::values::{
36 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
37 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
38 scope_map_to_json, sort_scope_map,
39};
40
41const DEFAULT_ACCEPT: u8 = 0b0111;
46const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
47const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
48const ACCEPT_SQLITE: u8 = 1 << 2;
49const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
50
51const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
53const LOCAL_REVISION_KEY: &str = "localRevision";
54const CLIENT_ID_KEY: &str = "clientId";
55const LEASE_STATE_KEY: &str = "leaseState";
56const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
57const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62enum SubState {
63 Active,
64 Revoked,
65 Failed,
66}
67
68#[cfg(test)]
69mod observation_tests {
70 use super::*;
71 use serde_json::json;
72
73 fn client() -> SyncClient {
74 SyncClient::new(
75 "retry-test".to_owned(),
76 &json!({
77 "version": 1,
78 "tables": [{
79 "name": "tasks",
80 "primaryKey": "id",
81 "columns": [
82 { "name": "id", "type": "string", "nullable": false },
83 { "name": "project_id", "type": "string", "nullable": false }
84 ],
85 "scopes": [{ "pattern": "project:{project_id}" }]
86 }]
87 }),
88 ClientLimits::default(),
89 )
90 .expect("test client")
91 }
92
93 #[test]
94 fn background_retry_deadlines_back_off_and_reset() {
95 let mut client = client();
96 client.schedule_background_retry();
97 assert!(matches!(
98 client.drain_sync_intents().as_slice(),
99 [SyncIntent::Background { delay_ms: 250 }]
100 ));
101 client.schedule_background_retry();
102 assert!(matches!(
103 client.drain_sync_intents().as_slice(),
104 [SyncIntent::Background { delay_ms: 500 }]
105 ));
106 client.reset_background_retry();
107 client.schedule_background_retry();
108 assert!(matches!(
109 client.drain_sync_intents().as_slice(),
110 [SyncIntent::Background { delay_ms: 250 }]
111 ));
112 }
113
114 #[test]
115 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
116 let path = std::env::temp_dir().join(format!(
117 "syncular-startup-intent-{}.db",
118 uuid::Uuid::new_v4()
119 ));
120 let schema = json!({
121 "version": 1,
122 "tables": [{
123 "name": "tasks",
124 "primaryKey": "id",
125 "columns": [
126 { "name": "id", "type": "string", "nullable": false },
127 { "name": "project_id", "type": "string", "nullable": false }
128 ],
129 "scopes": [{ "pattern": "project:{project_id}" }]
130 }]
131 });
132
133 {
134 let mut first = SyncClient::open_path_with_identity(
135 None,
136 &schema,
137 ClientLimits::default(),
138 path.to_str().expect("UTF-8 temp path"),
139 )
140 .expect("first open");
141 first
142 .set_window(
143 &WindowBase {
144 table: "tasks".to_owned(),
145 variable: "project_id".to_owned(),
146 fixed_scopes: Vec::new(),
147 params: None,
148 },
149 &["persisted".to_owned()],
150 )
151 .expect("persist window");
152 }
153
154 let mut reopened = SyncClient::open_path_with_identity(
155 None,
156 &schema,
157 ClientLimits::default(),
158 path.to_str().expect("UTF-8 temp path"),
159 )
160 .expect("reopen");
161 assert!(reopened.sync_needed());
162 assert!(matches!(
163 reopened.drain_sync_intents().as_slice(),
164 [SyncIntent::Interactive]
165 ));
166 drop(reopened);
167 std::fs::remove_file(path).expect("remove temp database");
168 }
169
170 #[test]
171 fn durable_conflict_outcome_and_resolution_survive_reopen() {
172 let path = std::env::temp_dir().join(format!(
173 "syncular-durable-outcome-{}.db",
174 uuid::Uuid::new_v4()
175 ));
176 let schema = json!({
177 "version": 1,
178 "tables": [{
179 "name": "tasks",
180 "primaryKey": "id",
181 "columns": [
182 { "name": "id", "type": "string", "nullable": false },
183 { "name": "project_id", "type": "string", "nullable": false }
184 ],
185 "scopes": [{ "pattern": "project:{project_id}" }]
186 }]
187 });
188 let conflict = ConflictRecord {
189 client_commit_id: "losing-commit".to_owned(),
190 op_index: 0,
191 table: "tasks".to_owned(),
192 row_id: "t1".to_owned(),
193 code: "sync.version_conflict".to_owned(),
194 message: "stale base version".to_owned(),
195 server_version: 2,
196 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
197 operation: Some(CommitOperation {
198 table: "tasks".to_owned(),
199 row_id: "t1".to_owned(),
200 op: "upsert".to_owned(),
201 base_version: Some(1),
202 values: None,
203 changed_fields: None,
204 }),
205 };
206
207 {
208 let mut first = SyncClient::open_path(
209 "durable-native".to_owned(),
210 &schema,
211 ClientLimits::default(),
212 path.to_str().expect("UTF-8 temp path"),
213 )
214 .expect("first open");
215 first
216 .begin_observation("test_outcome")
217 .expect("begin outcome");
218 first
219 .persist_commit_outcome(
220 "losing-commit",
221 CommitOutcomeStatus::Conflict,
222 &[CommitOperationOutcome::Conflict {
223 conflict: conflict.clone(),
224 }],
225 )
226 .expect("persist outcome");
227 first.conflicts.push(conflict);
228 first
229 .finish_observation(
230 "test_outcome",
231 ChangeAccumulator {
232 conflicts: true,
233 outcomes: true,
234 ..ChangeAccumulator::default()
235 },
236 )
237 .expect("commit outcome");
238 }
239
240 {
241 let mut reopened = SyncClient::open_path(
242 "durable-native".to_owned(),
243 &schema,
244 ClientLimits::default(),
245 path.to_str().expect("UTF-8 temp path"),
246 )
247 .expect("reopen");
248 assert_eq!(reopened.conflicts().len(), 1);
249 assert_eq!(
250 reopened
251 .commit_outcome("losing-commit")
252 .expect("read outcome")
253 .expect("outcome")
254 .status,
255 CommitOutcomeStatus::Conflict
256 );
257 let resolved = reopened
258 .resolve_commit_outcome(ResolveCommitOutcomeInput {
259 client_commit_id: "losing-commit".to_owned(),
260 resolution: CommitOutcomeResolution::ResolvedKeepServer,
261 replacement_client_commit_id: None,
262 })
263 .expect("resolve");
264 assert_eq!(
265 resolved.resolution,
266 CommitOutcomeResolution::ResolvedKeepServer
267 );
268 assert!(reopened.conflicts().is_empty());
269 }
270
271 let reopened = SyncClient::open_path(
272 "durable-native".to_owned(),
273 &schema,
274 ClientLimits::default(),
275 path.to_str().expect("UTF-8 temp path"),
276 )
277 .expect("second reopen");
278 assert!(reopened.conflicts().is_empty());
279 assert_eq!(
280 reopened
281 .commit_outcome("losing-commit")
282 .expect("read outcome")
283 .expect("outcome")
284 .resolution,
285 CommitOutcomeResolution::ResolvedKeepServer
286 );
287 drop(reopened);
288 std::fs::remove_file(path).expect("remove temp database");
289 }
290
291 #[test]
292 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
293 let path =
294 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
295 let schema = json!({
296 "version": 1,
297 "tables": [{
298 "name": "tasks",
299 "primaryKey": "id",
300 "columns": [
301 { "name": "id", "type": "string", "nullable": false },
302 { "name": "project_id", "type": "string", "nullable": false }
303 ],
304 "scopes": [{ "pattern": "project:{project_id}" }]
305 }]
306 });
307 let mut client = SyncClient::open_path_with_identity(
308 Some("sidecar-client".to_owned()),
309 &schema,
310 ClientLimits::default(),
311 path.to_str().expect("UTF-8 temp path"),
312 )
313 .expect("open owner");
314 let base = WindowBase {
315 table: "tasks".to_owned(),
316 variable: "project_id".to_owned(),
317 fixed_scopes: Vec::new(),
318 params: None,
319 };
320 client
321 .set_window(&base, &["one".to_owned()])
322 .expect("set window");
323 client
324 .mutate(vec![Mutation::Upsert {
325 table: "tasks".to_owned(),
326 values: Map::from_iter([
327 ("id".to_owned(), Value::from("t1")),
328 ("project_id".to_owned(), Value::from("one")),
329 ]),
330 base_version: None,
331 }])
332 .expect("local mutate");
333
334 let coverage = [WindowCoverage {
335 base,
336 units: vec!["one".to_owned(), "missing".to_owned()],
337 }];
338 let owner = client
339 .query_snapshot(
340 "SELECT id, project_id FROM tasks ORDER BY id",
341 &[],
342 &coverage,
343 )
344 .expect("owner snapshot");
345 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
346 let sidecar = reader
347 .query_snapshot(
348 "SELECT id, project_id FROM tasks ORDER BY id",
349 &[],
350 &coverage,
351 )
352 .expect("sidecar snapshot");
353
354 assert_eq!(sidecar.revision, owner.revision);
355 assert_eq!(sidecar.rows, owner.rows);
356 assert_eq!(
357 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
358 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
359 );
360 assert_eq!(sidecar.revision, "2");
361 assert_eq!(sidecar.rows[0]["id"], "t1");
362 assert!(!sidecar.coverage.complete);
363 assert_eq!(sidecar.coverage.pending.len(), 1);
364 assert_eq!(sidecar.coverage.missing.len(), 1);
365
366 drop(reader);
367 drop(client);
368 std::fs::remove_file(path).expect("remove temp database");
369 }
370}
371
372impl SubState {
373 fn name(self) -> &'static str {
374 match self {
375 SubState::Active => "active",
376 SubState::Revoked => "revoked",
377 SubState::Failed => "failed",
378 }
379 }
380
381 fn parse(value: &str) -> Self {
382 match value {
383 "revoked" => Self::Revoked,
384 "failed" => Self::Failed,
385 _ => Self::Active,
386 }
387 }
388}
389
390#[derive(Debug, Clone)]
391struct Subscription {
392 id: String,
393 table: String,
394 requested: Vec<(String, Vec<String>)>,
395 params: Option<String>,
396 cursor: i64,
397 bootstrap_state: Option<String>,
399 state: SubState,
400 reason_code: Option<String>,
401 effective: Option<Vec<(String, Vec<String>)>>,
404 synced_once: bool,
405}
406
407#[derive(Debug, Clone)]
408struct OutboxOp {
409 upsert: bool,
410 table: String,
411 row_id: String,
412 base_version: Option<i64>,
413 values: Option<Map<String, Value>>,
416 changed_fields: Option<Vec<String>>,
418}
419
420impl From<&OutboxOp> for CommitOperation {
421 fn from(operation: &OutboxOp) -> Self {
422 Self {
423 table: operation.table.clone(),
424 row_id: operation.row_id.clone(),
425 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
426 base_version: operation.base_version,
427 values: operation.values.clone(),
428 changed_fields: operation.changed_fields.clone(),
429 }
430 }
431}
432
433#[derive(Debug, Clone)]
434struct OutboxCommit {
435 client_commit_id: String,
436 ops: Vec<OutboxOp>,
437}
438
439enum SectionError {
442 FailClosed,
443 Abort(String, String),
444}
445
446struct RequestMeta {
447 pushed_ids: Vec<String>,
448 fresh: Vec<(String, bool)>,
451 accept: u8,
452 deferred_commits: usize,
456}
457
458#[derive(Default)]
459struct ChangeAccumulator {
460 tables: BTreeMap<String, Option<BTreeSet<String>>>,
462 windows: BTreeMap<(String, String), BTreeSet<String>>,
463 status: bool,
464 conflicts: bool,
465 rejections: bool,
466 outcomes: bool,
467}
468
469impl ChangeAccumulator {
470 fn table(&mut self, table: &str) {
471 self.tables.insert(table.to_owned(), None);
472 }
473
474 fn scope(&mut self, table: &str, key: String) {
475 match self.tables.get_mut(table) {
476 Some(None) => {}
477 Some(Some(keys)) => {
478 keys.insert(key);
479 }
480 None => {
481 self.tables
482 .insert(table.to_owned(), Some(BTreeSet::from([key])));
483 }
484 }
485 }
486
487 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
488 self.windows
489 .entry((base_key.to_owned(), table.to_owned()))
490 .or_default()
491 .insert(unit.to_owned());
492 }
493
494 fn touched(&self) -> bool {
495 !self.tables.is_empty()
496 || !self.windows.is_empty()
497 || self.status
498 || self.conflicts
499 || self.rejections
500 || self.outcomes
501 }
502}
503
504const PUSH_OPS_PER_REQUEST: usize = 500;
510
511pub struct SyncClient {
512 conn: Connection,
513 schema: ClientSchema,
514 client_id: String,
515 limits: ClientLimits,
516 subs: Vec<Subscription>,
517 outbox: Vec<OutboxCommit>,
518 conflicts: Vec<ConflictRecord>,
519 rejections: Vec<RejectionRecord>,
520 schema_floor: Option<SchemaFloor>,
521 lease_state: Option<LeaseState>,
523 stopped: bool,
525 upgrading: bool,
527 sync_needed: bool,
529 realtime_connected: bool,
530 presence: HashMap<String, HashMap<String, PresencePeer>>,
532 now_ms: Option<i64>,
535 encryption: crate::values::EncryptionConfig,
539 insert_sql: RefCell<HashMap<String, String>>,
545 overlay_dirty: Cell<bool>,
549 change_queue: VecDeque<ClientChangeBatch>,
551 sync_intent_queue: VecDeque<SyncIntent>,
552 retry_delay_ms: u64,
554}
555
556fn quote_ident(name: &str) -> String {
557 format!("\"{}\"", name.replace('"', "\"\""))
558}
559
560fn base_table(name: &str) -> String {
561 quote_ident(&format!("_syncular_base_{name}"))
562}
563
564type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
569
570fn window_base_key(base: &WindowBase) -> String {
571 format!(
572 "{}\0{}\0{}",
573 base.table,
574 base.variable,
575 canonical_scope_json(&base.fixed_scopes)
576 )
577}
578
579fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
581 let mut scopes = base.fixed_scopes.clone();
582 scopes.retain(|(k, _)| k != &base.variable);
583 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
584 scopes
585}
586
587fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
591 let canonical = canonical_scope_json(&unit_scopes(base, unit));
592 let digest = Sha256::digest(canonical.as_bytes());
593 let hex = bytes_to_hex(&digest);
594 format!("w:{}:{}", base.table, &hex[..16])
595}
596
597fn visible_table(name: &str) -> String {
598 quote_ident(name)
599}
600
601fn is_synced_table_name(name: &str) -> bool {
604 if name.starts_with("sqlite_") {
605 return false;
606 }
607 if name.starts_with("_syncular_base_") {
608 return true; }
610 !name.starts_with("_syncular_")
612}
613
614fn blob_id_for(bytes: &[u8]) -> String {
616 let digest = Sha256::digest(bytes);
617 format!("sha256:{}", bytes_to_hex(&digest))
618}
619
620enum RowParam<'a> {
624 Cell(&'a Option<ColumnValue>),
625 Version(i64),
626}
627
628impl rusqlite::ToSql for RowParam<'_> {
629 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
630 Ok(match self {
631 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
632 RowParam::Cell(cell) => match cell {
633 None => ToSqlOutput::Owned(SqlValue::Null),
634 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
635 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
636 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
637 Some(ColumnValue::Boolean(b)) => {
638 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
639 }
640 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
641 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
642 }
643 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
645 ToSqlOutput::Borrowed(ValueRef::Blob(b))
646 }
647 },
648 })
649 }
650}
651
652fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
660 use ssp2::segment::ColumnType;
661 let mismatch = || {
662 Err(format!(
663 "image column {:?} holds a value of the wrong type",
664 column.name
665 ))
666 };
667 match value {
668 ValueRef::Null => {
669 if !column.nullable {
670 return Err(format!(
671 "image column {:?} is NULL but not nullable",
672 column.name
673 ));
674 }
675 Ok(ToSqlOutput::Owned(SqlValue::Null))
676 }
677 ValueRef::Integer(i) => match column.ty {
678 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
679 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
680 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
681 _ => mismatch(),
682 },
683 ValueRef::Real(_) => match column.ty {
684 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
685 _ => mismatch(),
686 },
687 ValueRef::Text(t) => {
688 std::str::from_utf8(t)
689 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
690 match column.ty {
691 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
692 Ok(ToSqlOutput::Borrowed(value))
693 }
694 _ => mismatch(),
695 }
696 }
697 ValueRef::Blob(_) => match column.ty {
698 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
700 _ => mismatch(),
701 },
702 }
703}
704
705fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
706 use rusqlite::types::ValueRef;
707 match value {
708 ValueRef::Null => Value::Null,
709 ValueRef::Integer(i) => match column.ty {
710 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
711 ssp2::segment::ColumnType::Float => {
712 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
713 }
714 _ => Value::from(i),
715 },
716 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
717 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
718 ValueRef::Blob(b) => {
719 let mut map = Map::new();
720 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
721 Value::Object(map)
722 }
723 }
724}
725
726fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
730 Ok(match value {
731 Value::Null => SqlValue::Null,
732 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
733 Value::Number(n) => {
734 if let Some(i) = n.as_i64() {
735 SqlValue::Integer(i)
736 } else if let Some(f) = n.as_f64() {
737 SqlValue::Real(f)
738 } else {
739 return Err(format!("query param number {n} is out of range"));
740 }
741 }
742 Value::String(s) => SqlValue::Text(s.clone()),
743 Value::Object(_) => {
744 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
745 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
746 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
747 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
748 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
749 })?)
750 } else {
751 return Err(
752 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
753 .to_owned(),
754 );
755 }
756 }
757 Value::Array(_) => return Err("query array params are not supported".to_owned()),
758 })
759}
760
761fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
766 use rusqlite::types::ValueRef;
767 match value {
768 ValueRef::Null => Value::Null,
769 ValueRef::Integer(i) => {
770 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
774 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
775 Value::from(i)
776 } else {
777 let mut map = Map::new();
778 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
779 Value::Object(map)
780 }
781 }
782 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
783 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
784 ValueRef::Blob(b) => {
785 let mut map = Map::new();
786 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
787 Value::Object(map)
788 }
789 }
790}
791
792fn query_connection(
793 conn: &Connection,
794 sql: &str,
795 params: &[Value],
796) -> Result<Vec<Map<String, Value>>, String> {
797 crate::query_guard::assert_read_only_query(sql)?;
798 let bound: Vec<SqlValue> = params
799 .iter()
800 .map(json_param_to_sql)
801 .collect::<Result<_, _>>()?;
802 let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
803 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
804 let bound_refs: Vec<&dyn rusqlite::ToSql> =
805 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
806 let mut sql_rows = stmt
807 .query(bound_refs.as_slice())
808 .map_err(|e| e.to_string())?;
809 let mut out = Vec::new();
810 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
811 let mut record = Map::new();
812 for (i, name) in column_names.iter().enumerate() {
813 let value = row.get_ref(i).map_err(|e| e.to_string())?;
814 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
815 }
816 out.push(record);
817 }
818 Ok(out)
819}
820
821fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
822 let mut stmt = conn
823 .prepare(
824 "SELECT windows.unit, subscriptions.state_json
825 FROM _syncular_windows AS windows
826 JOIN _syncular_subscriptions AS subscriptions
827 ON subscriptions.id = windows.sub_id
828 WHERE windows.base = ?1
829 ORDER BY windows.unit ASC",
830 )
831 .map_err(|error| error.to_string())?;
832 let rows = stmt
833 .query_map(rusqlite::params![window_base_key(base)], |row| {
834 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
835 })
836 .map_err(|error| error.to_string())?;
837 let mut units = Vec::new();
838 let mut pending = Vec::new();
839 for row in rows {
840 let (unit, raw) = row.map_err(|error| error.to_string())?;
841 let state: Value = serde_json::from_str(&raw)
842 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
843 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
844 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
845 || state
846 .get("bootstrapState")
847 .is_some_and(|value| !value.is_null());
848 if is_pending {
849 pending.push(unit.clone());
850 }
851 units.push(unit);
852 }
853 Ok(WindowState { units, pending })
854}
855
856fn snapshot_connection(
857 conn: &Connection,
858 sql: &str,
859 params: &[Value],
860 coverage: &[WindowCoverage],
861) -> Result<QuerySnapshot, String> {
862 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
863 .map_err(|error| error.to_string())?;
864 let result = (|| {
865 let revision = conn
866 .query_row(
867 "SELECT value FROM _syncular_meta WHERE key = ?1",
868 rusqlite::params![LOCAL_REVISION_KEY],
869 |row| row.get::<_, String>(0),
870 )
871 .ok()
872 .and_then(|value| value.parse::<u64>().ok())
873 .unwrap_or(0);
874 let rows = query_connection(conn, sql, params)?;
875 let mut pending = Vec::new();
876 let mut missing = Vec::new();
877 for requested in coverage {
878 let base_key = window_base_key(&requested.base);
879 let state = persisted_window_state(conn, &requested.base)?;
880 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
881 let reference = WindowUnitRef {
882 base_key: base_key.clone(),
883 unit: unit.clone(),
884 };
885 if !state.units.iter().any(|held| held == &unit) {
886 missing.push(reference);
887 } else if state.pending.iter().any(|held| held == &unit) {
888 pending.push(reference);
889 }
890 }
891 }
892 Ok(QuerySnapshot {
893 revision: revision.to_string(),
894 rows,
895 coverage: CoverageSnapshot {
896 complete: pending.is_empty() && missing.is_empty(),
897 pending,
898 missing,
899 },
900 })
901 })();
902 match result {
903 Ok(snapshot) => {
904 conn.execute_batch("RELEASE syncular_snapshot_read")
905 .map_err(|error| error.to_string())?;
906 Ok(snapshot)
907 }
908 Err(error) => {
909 let _ = conn.execute_batch(
910 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
911 );
912 Err(error)
913 }
914 }
915}
916
917pub struct FileQuerySnapshotReader {
922 path: String,
923 conn: Option<Connection>,
924}
925
926impl FileQuerySnapshotReader {
927 #[must_use]
928 pub fn new(path: impl Into<String>) -> Self {
929 Self {
930 path: path.into(),
931 conn: None,
932 }
933 }
934
935 fn connection(&mut self) -> Result<&Connection, String> {
936 if self.conn.is_none() {
937 let conn = Connection::open_with_flags(
938 &self.path,
939 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
940 )
941 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
942 conn.busy_timeout(std::time::Duration::from_millis(250))
943 .map_err(|error| error.to_string())?;
944 self.conn = Some(conn);
945 }
946 self.conn
947 .as_ref()
948 .ok_or_else(|| "read sidecar connection missing".to_owned())
949 }
950
951 pub fn query_snapshot(
952 &mut self,
953 sql: &str,
954 params: &[Value],
955 coverage: &[WindowCoverage],
956 ) -> Result<QuerySnapshot, String> {
957 snapshot_connection(self.connection()?, sql, params, coverage)
958 }
959}
960
961impl SyncClient {
962 pub fn new_with_identity(
963 client_id: Option<String>,
964 schema_json: &Value,
965 limits: ClientLimits,
966 ) -> Result<Self, String> {
967 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
968 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
969 Self::with_connection(resolved, schema_json, limits, conn)
970 }
971
972 pub fn new(
973 client_id: String,
974 schema_json: &Value,
975 limits: ClientLimits,
976 ) -> Result<Self, String> {
977 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
978 Self::with_connection(client_id, schema_json, limits, conn)
979 }
980
981 pub fn open_path(
987 client_id: String,
988 schema_json: &Value,
989 limits: ClientLimits,
990 path: &str,
991 ) -> Result<Self, String> {
992 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
993 Self::with_connection(client_id, schema_json, limits, conn)
994 }
995
996 pub fn open_path_with_identity(
997 client_id: Option<String>,
998 schema_json: &Value,
999 limits: ClientLimits,
1000 path: &str,
1001 ) -> Result<Self, String> {
1002 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
1003 conn.busy_timeout(std::time::Duration::from_millis(250))
1009 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
1010 conn.pragma_update(None, "journal_mode", "WAL")
1011 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
1012 let persisted = conn
1013 .query_row(
1014 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
1015 [],
1016 |row| row.get::<_, String>(0),
1017 )
1018 .ok();
1019 let resolved = persisted
1020 .clone()
1021 .or(client_id.clone())
1022 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1023 if let (Some(existing), Some(requested)) = (persisted, client_id) {
1024 if existing != requested {
1025 return Err(format!(
1026 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
1027 ));
1028 }
1029 }
1030 Self::with_connection(resolved, schema_json, limits, conn)
1031 }
1032
1033 #[must_use]
1034 pub fn client_id(&self) -> &str {
1035 &self.client_id
1036 }
1037
1038 pub fn with_connection(
1045 client_id: String,
1046 schema_json: &Value,
1047 limits: ClientLimits,
1048 conn: Connection,
1049 ) -> Result<Self, String> {
1050 if limits.outcome_retention_max_entries == Some(0) {
1051 return Err(
1052 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
1053 );
1054 }
1055 let schema = parse_schema_json(schema_json)?;
1056 let mut client = SyncClient {
1057 conn,
1058 schema,
1059 client_id,
1060 limits,
1061 subs: Vec::new(),
1062 outbox: Vec::new(),
1063 conflicts: Vec::new(),
1064 rejections: Vec::new(),
1065 schema_floor: None,
1066 lease_state: None,
1067 stopped: false,
1068 upgrading: false,
1069 sync_needed: false,
1070 realtime_connected: false,
1071 presence: HashMap::new(),
1072 now_ms: None,
1073 encryption: crate::values::EncryptionConfig::default(),
1074 insert_sql: RefCell::new(HashMap::new()),
1075 overlay_dirty: Cell::new(false),
1076 change_queue: VecDeque::new(),
1077 sync_intent_queue: VecDeque::new(),
1078 retry_delay_ms: 250,
1079 };
1080 client
1084 .conn
1085 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
1086 client.create_tables()?;
1087 match client.get_meta(CLIENT_ID_KEY) {
1088 Some(existing) if existing != client.client_id => {
1089 return Err(format!(
1090 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
1091 client.client_id
1092 ));
1093 }
1094 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
1095 _ => {}
1096 }
1097 client.restore_persisted_state()?;
1098 let marker = client
1099 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
1100 .and_then(|value| value.parse::<i32>().ok());
1101 if marker != Some(client.schema.version) {
1102 client.run_schema_reset()?;
1103 } else if !client.outbox.is_empty() {
1104 client.overlay_dirty.set(true);
1107 client.rebuild_overlay();
1108 }
1109 client.enqueue_startup_sync_if_needed();
1115 Ok(client)
1116 }
1117
1118 pub fn set_now_ms(&mut self, now_ms: i64) {
1121 self.now_ms = Some(now_ms);
1122 }
1123
1124 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
1128 self.encryption = encryption;
1129 }
1130
1131 fn clock_now_ms(&self) -> i64 {
1132 self.now_ms.unwrap_or_else(|| {
1133 std::time::SystemTime::now()
1134 .duration_since(std::time::UNIX_EPOCH)
1135 .map(|d| d.as_millis() as i64)
1136 .unwrap_or(0)
1137 })
1138 }
1139
1140 fn create_tables(&self) -> Result<(), String> {
1141 self.create_synced_tables()?;
1142 self.conn
1144 .execute_batch(
1145 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
1146 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1147 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
1148 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
1149 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1150 client_commit_id TEXT NOT NULL UNIQUE,
1151 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
1152 recorded_at_ms INTEGER NOT NULL,
1153 results_json TEXT NOT NULL,
1154 resolution TEXT NOT NULL DEFAULT 'active'
1155 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
1156 resolved_at_ms INTEGER,
1157 replacement_client_commit_id TEXT);
1158 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
1159 ON _syncular_commit_outcomes(resolution, seq);
1160 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
1161 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
1162 CREATE TABLE IF NOT EXISTS _syncular_meta (
1163 key TEXT PRIMARY KEY, value TEXT NOT NULL);
1164 CREATE TABLE IF NOT EXISTS _syncular_windows (
1165 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
1166 PRIMARY KEY (base, unit));
1167 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
1168 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
1169 effective_scopes TEXT NOT NULL);",
1170 )
1171 .map_err(|e| e.to_string())?;
1172 if self.get_meta(LOCAL_SCHEMA_VERSION_KEY).is_none() {
1175 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1176 }
1177 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
1178 self.set_meta(LOCAL_REVISION_KEY, "0");
1179 }
1180 if self.schema_has_blobs() {
1185 self.conn
1186 .execute_batch(
1187 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
1188 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
1189 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
1190 created_at_ms INTEGER NOT NULL,
1191 last_used_ms INTEGER NOT NULL DEFAULT 0);
1192 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
1193 media_type TEXT, created_at_ms INTEGER NOT NULL);",
1194 )
1195 .map_err(|e| e.to_string())?;
1196 let _ = self.conn.execute_batch(
1200 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
1201 );
1202 }
1203 Ok(())
1204 }
1205
1206 fn schema_has_blobs(&self) -> bool {
1208 self.schema
1209 .tables
1210 .iter()
1211 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
1212 }
1213
1214 fn get_meta(&self, key: &str) -> Option<String> {
1217 self.conn
1218 .query_row(
1219 "SELECT value FROM _syncular_meta WHERE key = ?1",
1220 rusqlite::params![key],
1221 |row| row.get::<_, String>(0),
1222 )
1223 .ok()
1224 }
1225
1226 fn set_meta(&self, key: &str, value: &str) {
1227 let _ = self.conn.execute(
1228 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
1229 rusqlite::params![key, value],
1230 );
1231 }
1232
1233 fn delete_meta(&self, key: &str) {
1234 let _ = self.conn.execute(
1235 "DELETE FROM _syncular_meta WHERE key = ?1",
1236 rusqlite::params![key],
1237 );
1238 }
1239
1240 fn restore_persisted_state(&mut self) -> Result<(), String> {
1241 self.subs = {
1242 let mut stmt = self
1243 .conn
1244 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
1245 .map_err(|error| error.to_string())?;
1246 let rows = stmt
1247 .query_map([], |row| {
1248 Ok((
1249 row.get::<_, String>(0)?,
1250 row.get::<_, String>(1)?,
1251 row.get::<_, String>(2)?,
1252 ))
1253 })
1254 .map_err(|error| error.to_string())?;
1255 let mut subscriptions = Vec::new();
1256 for row in rows {
1257 let (id, table, raw) = row.map_err(|error| error.to_string())?;
1258 let state: Value = serde_json::from_str(&raw)
1259 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
1260 let requested = json_to_scope_map(
1261 state.get("requested").unwrap_or(&Value::Object(Map::new())),
1262 )?;
1263 let effective = state
1264 .get("effectiveScopes")
1265 .filter(|value| !value.is_null())
1266 .map(json_to_scope_map)
1267 .transpose()?;
1268 subscriptions.push(Subscription {
1269 id,
1270 table,
1271 requested,
1272 params: state
1273 .get("params")
1274 .and_then(Value::as_str)
1275 .map(str::to_owned),
1276 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
1277 bootstrap_state: state
1278 .get("bootstrapState")
1279 .and_then(Value::as_str)
1280 .map(str::to_owned),
1281 state: SubState::parse(
1282 state
1283 .get("status")
1284 .and_then(Value::as_str)
1285 .unwrap_or("active"),
1286 ),
1287 reason_code: state
1288 .get("reasonCode")
1289 .and_then(Value::as_str)
1290 .map(str::to_owned),
1291 effective,
1292 synced_once: state
1293 .get("syncedOnce")
1294 .and_then(Value::as_bool)
1295 .unwrap_or(false),
1296 });
1297 }
1298 subscriptions
1299 };
1300
1301 self.outbox = {
1302 let mut stmt = self
1303 .conn
1304 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
1305 .map_err(|error| error.to_string())?;
1306 let rows = stmt
1307 .query_map([], |row| {
1308 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1309 })
1310 .map_err(|error| error.to_string())?;
1311 let mut commits = Vec::new();
1312 for row in rows {
1313 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
1314 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
1315 format!("invalid persisted outbox {client_commit_id:?}: {error}")
1316 })?;
1317 let mut ops = Vec::with_capacity(entries.len());
1318 for entry in entries {
1319 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
1320 ops.push(OutboxOp {
1321 upsert: op == "upsert",
1322 table: entry
1323 .get("table")
1324 .and_then(Value::as_str)
1325 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
1326 .to_owned(),
1327 row_id: entry
1328 .get("rowId")
1329 .and_then(Value::as_str)
1330 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
1331 .to_owned(),
1332 base_version: entry.get("baseVersion").and_then(Value::as_i64),
1333 values: entry.get("values").and_then(Value::as_object).cloned(),
1334 changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
1335 |values| {
1336 values
1337 .iter()
1338 .filter_map(Value::as_str)
1339 .map(str::to_owned)
1340 .collect()
1341 },
1342 ),
1343 });
1344 }
1345 commits.push(OutboxCommit {
1346 client_commit_id,
1347 ops,
1348 });
1349 }
1350 commits
1351 };
1352
1353 self.prune_commit_outcomes()?;
1354 let active = self.commit_outcomes(CommitOutcomeQuery {
1355 active_only: true,
1356 ..CommitOutcomeQuery::default()
1357 })?;
1358 self.conflicts = active
1359 .iter()
1360 .flat_map(|outcome| outcome.results.iter())
1361 .filter_map(|result| match result {
1362 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
1363 _ => None,
1364 })
1365 .collect();
1366 self.rejections = active
1367 .iter()
1368 .flat_map(|outcome| outcome.results.iter())
1369 .filter_map(|result| match result {
1370 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
1371 _ => None,
1372 })
1373 .collect();
1374
1375 self.lease_state = self
1376 .get_meta(LEASE_STATE_KEY)
1377 .map(|raw| serde_json::from_str(&raw))
1378 .transpose()
1379 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
1380 self.schema_floor = self
1381 .get_meta(SCHEMA_FLOOR_KEY)
1382 .map(|raw| serde_json::from_str(&raw))
1383 .transpose()
1384 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
1385 self.stopped = self.schema_floor.is_some();
1386 Ok(())
1387 }
1388
1389 #[must_use]
1390 pub fn local_revision(&self) -> u64 {
1391 self.get_meta(LOCAL_REVISION_KEY)
1392 .and_then(|value| value.parse().ok())
1393 .unwrap_or(0)
1394 }
1395
1396 #[must_use]
1397 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
1398 SyncStatusSnapshot {
1399 outbox: self.outbox.len(),
1400 upgrading: self.upgrading,
1401 lease_state: self.lease_state.clone(),
1402 schema_floor: self.schema_floor.clone(),
1403 sync_needed: self.sync_needed,
1404 }
1405 }
1406
1407 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
1408 self.change_queue.drain(..).collect()
1409 }
1410
1411 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
1412 self.sync_intent_queue.drain(..).collect()
1413 }
1414
1415 fn schedule_background_retry(&mut self) {
1416 self.sync_intent_queue.push_back(SyncIntent::Background {
1417 delay_ms: self.retry_delay_ms,
1418 });
1419 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
1420 }
1421
1422 fn reset_background_retry(&mut self) {
1423 self.retry_delay_ms = 250;
1424 }
1425
1426 fn retryable_transport_code(code: &str) -> bool {
1427 code == "transport.failed" || code == "sync.transport_failed"
1428 }
1429
1430 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
1431 if self.sync_needed != value {
1432 if self.begin_observation("syncular_status").is_ok() {
1433 self.sync_needed = value;
1434 let batch = ChangeAccumulator {
1435 status: true,
1436 ..ChangeAccumulator::default()
1437 };
1438 if self.finish_observation("syncular_status", batch).is_err() {
1439 self.rollback_observation("syncular_status");
1440 }
1441 } else {
1442 self.sync_needed = value;
1443 }
1444 }
1445 if value && interactive {
1446 self.sync_intent_queue.push_back(SyncIntent::Interactive);
1447 }
1448 }
1449
1450 fn begin_observation(&self, name: &str) -> Result<(), String> {
1451 self.conn
1452 .execute_batch(&format!("SAVEPOINT {name}"))
1453 .map_err(|error| error.to_string())
1454 }
1455
1456 fn rollback_observation(&self, name: &str) {
1457 let _ = self
1458 .conn
1459 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
1460 }
1461
1462 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
1463 if !batch.touched() {
1464 self.conn
1465 .execute_batch(&format!("RELEASE {name}"))
1466 .map_err(|error| error.to_string())?;
1467 return Ok(());
1468 }
1469 let revision = self
1470 .local_revision()
1471 .checked_add(1)
1472 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
1473 self.conn
1474 .execute(
1475 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
1476 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
1477 )
1478 .map_err(|error| error.to_string())?;
1479 let status = batch.status.then(|| self.status_snapshot());
1480 let event = ClientChangeBatch {
1481 revision: revision.to_string(),
1482 tables: batch
1483 .tables
1484 .into_iter()
1485 .map(|(table, scope_keys)| TableChange {
1486 table,
1487 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
1488 })
1489 .collect(),
1490 windows: batch
1491 .windows
1492 .into_iter()
1493 .map(|((base_key, table), units)| WindowChange {
1494 base_key,
1495 table,
1496 units: units.into_iter().collect(),
1497 })
1498 .collect(),
1499 status,
1500 conflicts_changed: batch.conflicts,
1501 rejections_changed: batch.rejections,
1502 outcomes_changed: batch.outcomes,
1503 };
1504 self.conn
1505 .execute_batch(&format!("RELEASE {name}"))
1506 .map_err(|error| error.to_string())?;
1507 self.change_queue.push_back(event);
1508 Ok(())
1509 }
1510
1511 fn record_scope_map(
1512 &self,
1513 batch: &mut ChangeAccumulator,
1514 table_name: &str,
1515 scopes: &[(String, Vec<String>)],
1516 ) {
1517 let Some(table) = self.schema.table(table_name) else {
1518 return;
1519 };
1520 for (variable, values) in scopes {
1521 let Some(scope) = table
1522 .scope_variables
1523 .iter()
1524 .find(|scope| &scope.variable == variable)
1525 else {
1526 continue;
1527 };
1528 for value in values {
1529 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1530 }
1531 }
1532 }
1533
1534 fn record_row_scopes(
1536 &self,
1537 batch: &mut ChangeAccumulator,
1538 table_name: &str,
1539 row_id: &str,
1540 base: bool,
1541 ) -> bool {
1542 let Some(table) = self.schema.table(table_name) else {
1543 return false;
1544 };
1545 if table.scope_variables.is_empty() {
1546 return false;
1547 }
1548 let columns = table
1549 .scope_variables
1550 .iter()
1551 .map(|scope| quote_ident(&scope.column))
1552 .collect::<Vec<_>>()
1553 .join(", ");
1554 let full_table = if base {
1555 base_table(table_name)
1556 } else {
1557 visible_table(table_name)
1558 };
1559 let sql = format!(
1560 "SELECT {columns} FROM {full_table} WHERE CAST({} AS TEXT) = ?1 LIMIT 1",
1561 quote_ident(&table.primary_key)
1562 );
1563 let Ok(mut stmt) = self.conn.prepare(&sql) else {
1564 return false;
1565 };
1566 let values = stmt.query_row(rusqlite::params![row_id], |row| {
1567 let mut values = Vec::with_capacity(table.scope_variables.len());
1568 for index in 0..table.scope_variables.len() {
1569 values.push(row.get::<_, Option<String>>(index)?);
1570 }
1571 Ok(values)
1572 });
1573 let Ok(values) = values else {
1574 return false;
1575 };
1576 let mut recorded = false;
1577 for (scope, value) in table.scope_variables.iter().zip(values) {
1578 if let Some(value) = value {
1579 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1580 recorded = true;
1581 }
1582 }
1583 recorded
1584 }
1585
1586 fn record_commit_changes(
1587 &self,
1588 batch: &mut ChangeAccumulator,
1589 tables: &[String],
1590 changes: &[ssp2::model::Change],
1591 ) {
1592 for change in changes {
1593 let Some(table_name) = tables.get(change.table_index as usize) else {
1594 continue;
1595 };
1596 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
1597 if let Some(table) = self.schema.table(table_name) {
1598 for (variable, value) in &change.scopes {
1599 if let Some(scope) = table
1600 .scope_variables
1601 .iter()
1602 .find(|scope| &scope.variable == variable)
1603 {
1604 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1605 precise = true;
1606 }
1607 }
1608 }
1609 if !precise {
1610 batch.table(table_name);
1611 }
1612 }
1613 }
1614
1615 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
1616 if effective.is_empty() {
1617 return false;
1618 }
1619 let Some(table) = self.schema.table(table_name) else {
1620 return false;
1621 };
1622 let mut clauses = Vec::new();
1623 let mut params = Vec::new();
1624 for (variable, values) in effective {
1625 let Some(column) = table.scope_column(variable) else {
1626 return false;
1627 };
1628 if values.is_empty() {
1629 return false;
1630 }
1631 let placeholders = values
1632 .iter()
1633 .map(|value| {
1634 params.push(SqlValue::Text(value.clone()));
1635 "?"
1636 })
1637 .collect::<Vec<_>>()
1638 .join(", ");
1639 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
1640 }
1641 let sql = format!(
1642 "SELECT 1 FROM {} WHERE {} LIMIT 1",
1643 base_table(table_name),
1644 clauses.join(" AND ")
1645 );
1646 self.conn
1647 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
1648 .is_ok()
1649 }
1650
1651 pub fn upgrading(&self) -> bool {
1653 self.upgrading
1654 }
1655
1656 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
1662 let new_schema = parse_schema_json(schema_json)?;
1663 let marker: Option<i32> = self
1664 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
1665 .and_then(|v| v.parse().ok());
1666 self.schema = new_schema;
1667 if marker != Some(self.schema.version) {
1668 self.run_schema_reset()?;
1669 }
1670 self.enqueue_startup_sync_if_needed();
1674 Ok(())
1675 }
1676
1677 fn enqueue_startup_sync_if_needed(&mut self) {
1678 let startup_work = !self.stopped
1679 && (!self.outbox.is_empty()
1680 || self.subs.iter().any(|sub| sub.state == SubState::Active));
1681 if startup_work {
1682 self.sync_needed = true;
1683 self.sync_intent_queue.push_back(SyncIntent::Interactive);
1684 }
1685 }
1686
1687 fn run_schema_reset(&mut self) -> Result<(), String> {
1693 self.begin_observation("syncular_schema_reset")?;
1694 let mut batch = ChangeAccumulator::default();
1695 let result = self.run_schema_reset_observed(&mut batch);
1696 if let Err(error) = result {
1697 self.rollback_observation("syncular_schema_reset");
1698 return Err(error);
1699 }
1700 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
1701 self.rollback_observation("syncular_schema_reset");
1702 return Err(error);
1703 }
1704 Ok(())
1705 }
1706
1707 fn run_schema_reset_observed(&mut self, batch: &mut ChangeAccumulator) -> Result<(), String> {
1708 self.upgrading = true;
1709 batch.status = true;
1710 for table in &self.schema.tables {
1711 batch.table(&table.name);
1712 }
1713 for (base_key, unit, table) in self.load_registered_window_units() {
1714 batch.window(&base_key, &table, &unit);
1715 }
1716 self.insert_sql.borrow_mut().clear();
1718 self.overlay_dirty.set(true);
1719 let existing: Vec<String> = {
1725 let mut stmt = self
1726 .conn
1727 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
1728 .map_err(|e| e.to_string())?;
1729 let rows = stmt
1730 .query_map([], |row| row.get::<_, String>(0))
1731 .map_err(|e| e.to_string())?;
1732 rows.filter_map(Result::ok)
1733 .filter(|name| is_synced_table_name(name))
1734 .collect()
1735 };
1736 for name in &existing {
1737 if let Some(table) = name.strip_prefix("_syncular_base_") {
1738 batch.table(table);
1739 } else if !name.starts_with("_syncular_") {
1740 batch.table(name);
1741 }
1742 }
1743 for name in existing {
1744 let _ = self
1745 .conn
1746 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), []);
1747 }
1748 self.create_synced_tables()?;
1750 for sub in &mut self.subs {
1752 sub.cursor = -1;
1753 sub.bootstrap_state = None;
1754 sub.effective = None;
1755 sub.state = SubState::Active;
1756 sub.reason_code = None;
1757 sub.synced_once = false;
1758 }
1759 let subs = self.subs.clone();
1760 for sub in &subs {
1761 self.persist_sub(sub);
1762 }
1763 self.stopped = false;
1765 self.schema_floor = None;
1766 self.delete_meta(SCHEMA_FLOOR_KEY);
1767 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1769 if self.drop_incompatible_outbox()? {
1773 batch.rejections = true;
1774 batch.status = true;
1775 batch.outcomes = true;
1776 }
1777 self.rebuild_overlay();
1779 Ok(())
1780 }
1781
1782 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
1786 let schema = &self.schema;
1787 let incompatible = self
1788 .outbox
1789 .iter()
1790 .filter(|commit| {
1791 commit.ops.iter().any(|op| {
1792 if !op.upsert {
1793 return false;
1794 }
1795 match schema.table(&op.table) {
1796 None => true,
1797 Some(table) => op.values.as_ref().is_some_and(|values| {
1798 values
1799 .keys()
1800 .any(|key| !table.columns.iter().any(|c| &c.name == key))
1801 }),
1802 }
1803 })
1804 })
1805 .cloned()
1806 .collect::<Vec<_>>();
1807 if incompatible.is_empty() {
1808 return Ok(false);
1809 }
1810 let mut rejections = Vec::new();
1811 for commit in &incompatible {
1812 let results = commit
1813 .ops
1814 .iter()
1815 .enumerate()
1816 .map(|(op_index, operation)| {
1817 let rejection = RejectionRecord {
1818 client_commit_id: commit.client_commit_id.clone(),
1819 op_index: op_index as i32,
1820 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
1821 message: "the persisted commit cannot encode under the current schema"
1822 .to_owned(),
1823 retryable: false,
1824 details: None,
1825 operation: Some(CommitOperation::from(operation)),
1826 };
1827 rejections.push(rejection.clone());
1828 CommitOperationOutcome::Error { rejection }
1829 })
1830 .collect::<Vec<_>>();
1831 self.persist_commit_outcome(
1832 &commit.client_commit_id,
1833 CommitOutcomeStatus::Rejected,
1834 &results,
1835 )?;
1836 self.delete_outbox_persisted(&commit.client_commit_id)?;
1837 }
1838 self.prune_commit_outcomes()?;
1839 let incompatible_ids = incompatible
1840 .iter()
1841 .map(|commit| commit.client_commit_id.as_str())
1842 .collect::<BTreeSet<_>>();
1843 self.outbox
1844 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
1845 self.rejections.extend(rejections);
1846 Ok(true)
1847 }
1848
1849 fn create_synced_tables(&self) -> Result<(), String> {
1852 for table in &self.schema.tables {
1853 for (full, index_prefix) in [
1857 (base_table(&table.name), "_syncular_base_"),
1858 (visible_table(&table.name), ""),
1859 ] {
1860 let mut cols: Vec<String> =
1861 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
1862 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
1863 let sql = format!(
1864 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
1865 cols.join(", "),
1866 quote_ident(&table.primary_key)
1867 );
1868 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
1869 for index in &table.indexes {
1873 let unique = if index.unique { "UNIQUE " } else { "" };
1874 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
1875 let cols_sql = index
1876 .columns
1877 .iter()
1878 .map(|c| quote_ident(c))
1879 .collect::<Vec<_>>()
1880 .join(", ");
1881 let index_sql = format!(
1882 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
1883 );
1884 self.conn
1885 .execute(&index_sql, [])
1886 .map_err(|e| e.to_string())?;
1887 }
1888 }
1889 }
1890 Ok(())
1891 }
1892
1893 fn persist_sub(&self, sub: &Subscription) {
1896 let state = serde_json::json!({
1897 "requested": scope_map_to_json(&sub.requested),
1898 "params": sub.params,
1899 "cursor": sub.cursor,
1900 "bootstrapState": sub.bootstrap_state,
1901 "status": sub.state.name(),
1902 "reasonCode": sub.reason_code,
1903 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1904 "syncedOnce": sub.synced_once,
1905 });
1906 let _ = self.conn.execute(
1907 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
1908 rusqlite::params![sub.id, sub.table, state.to_string()],
1909 );
1910 }
1911
1912 fn persist_outbox_insert(&self, commit: &OutboxCommit) {
1913 let ops: Vec<Value> = commit
1914 .ops
1915 .iter()
1916 .map(|op| {
1917 serde_json::json!({
1918 "op": if op.upsert { "upsert" } else { "delete" },
1919 "table": op.table,
1920 "rowId": op.row_id,
1921 "baseVersion": op.base_version,
1922 "values": op.values.clone().map(Value::Object),
1923 "changedFields": op.changed_fields,
1924 })
1925 })
1926 .collect();
1927 let _ = self.conn.execute(
1928 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
1929 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
1930 );
1931 }
1932
1933 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
1934 self.conn
1935 .execute(
1936 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
1937 rusqlite::params![client_commit_id],
1938 )
1939 .map(|_| ())
1940 .map_err(|error| error.to_string())
1941 }
1942
1943 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
1944 match status {
1945 CommitOutcomeStatus::Applied => "applied",
1946 CommitOutcomeStatus::Cached => "cached",
1947 CommitOutcomeStatus::Conflict => "conflict",
1948 CommitOutcomeStatus::Rejected => "rejected",
1949 }
1950 }
1951
1952 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
1953 match resolution {
1954 CommitOutcomeResolution::Active => "active",
1955 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
1956 CommitOutcomeResolution::Superseded => "superseded",
1957 CommitOutcomeResolution::Dismissed => "dismissed",
1958 }
1959 }
1960
1961 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
1962 match value {
1963 "applied" => Ok(CommitOutcomeStatus::Applied),
1964 "cached" => Ok(CommitOutcomeStatus::Cached),
1965 "conflict" => Ok(CommitOutcomeStatus::Conflict),
1966 "rejected" => Ok(CommitOutcomeStatus::Rejected),
1967 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
1968 }
1969 }
1970
1971 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
1972 match value {
1973 "active" => Ok(CommitOutcomeResolution::Active),
1974 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
1975 "superseded" => Ok(CommitOutcomeResolution::Superseded),
1976 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
1977 _ => Err(format!(
1978 "invalid persisted commit outcome resolution {value:?}"
1979 )),
1980 }
1981 }
1982
1983 fn persist_commit_outcome(
1984 &self,
1985 client_commit_id: &str,
1986 status: CommitOutcomeStatus,
1987 results: &[CommitOperationOutcome],
1988 ) -> Result<(), String> {
1989 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
1990 self.conn
1991 .execute(
1992 "INSERT INTO _syncular_commit_outcomes (
1993 client_commit_id, status, recorded_at_ms, results_json, resolution
1994 ) VALUES (?1, ?2, ?3, ?4, 'active')",
1995 rusqlite::params![
1996 client_commit_id,
1997 Self::outcome_status_name(status),
1998 self.clock_now_ms(),
1999 results_json
2000 ],
2001 )
2002 .map(|_| ())
2003 .map_err(|error| error.to_string())
2004 }
2005
2006 fn outcome_from_tuple(
2007 row: (
2008 i64,
2009 String,
2010 String,
2011 i64,
2012 String,
2013 String,
2014 Option<i64>,
2015 Option<String>,
2016 ),
2017 ) -> Result<CommitOutcome, String> {
2018 let (
2019 sequence,
2020 client_commit_id,
2021 status,
2022 recorded_at_ms,
2023 results_json,
2024 resolution,
2025 resolved_at_ms,
2026 replacement_client_commit_id,
2027 ) = row;
2028 Ok(CommitOutcome {
2029 sequence,
2030 client_commit_id,
2031 status: Self::parse_outcome_status(&status)?,
2032 recorded_at_ms,
2033 results: serde_json::from_str(&results_json)
2034 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
2035 resolution: Self::parse_outcome_resolution(&resolution)?,
2036 resolved_at_ms,
2037 replacement_client_commit_id,
2038 })
2039 }
2040
2041 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
2042 let row = self
2043 .conn
2044 .query_row(
2045 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json,
2046 resolution, resolved_at_ms, replacement_client_commit_id
2047 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
2048 rusqlite::params![client_commit_id],
2049 |row| {
2050 Ok((
2051 row.get(0)?,
2052 row.get(1)?,
2053 row.get(2)?,
2054 row.get(3)?,
2055 row.get(4)?,
2056 row.get(5)?,
2057 row.get(6)?,
2058 row.get(7)?,
2059 ))
2060 },
2061 )
2062 .optional()
2063 .map_err(|error| error.to_string())?;
2064 row.map(Self::outcome_from_tuple).transpose()
2065 }
2066
2067 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
2068 if query.limit == Some(0) {
2069 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
2070 }
2071 let mut sql = String::from(
2072 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json,
2073 resolution, resolved_at_ms, replacement_client_commit_id
2074 FROM _syncular_commit_outcomes",
2075 );
2076 if query.active_only {
2077 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
2078 }
2079 sql.push_str(" ORDER BY seq DESC");
2080 if let Some(limit) = query.limit {
2081 sql.push_str(&format!(" LIMIT {limit}"));
2082 }
2083 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
2084 let rows = stmt
2085 .query_map([], |row| {
2086 Ok((
2087 row.get(0)?,
2088 row.get(1)?,
2089 row.get(2)?,
2090 row.get(3)?,
2091 row.get(4)?,
2092 row.get(5)?,
2093 row.get(6)?,
2094 row.get(7)?,
2095 ))
2096 })
2097 .map_err(|error| error.to_string())?;
2098 let mut outcomes = Vec::new();
2099 for row in rows {
2100 outcomes.push(Self::outcome_from_tuple(
2101 row.map_err(|error| error.to_string())?,
2102 )?);
2103 }
2104 Ok(outcomes)
2105 }
2106
2107 fn prune_commit_outcomes(&self) -> Result<(), String> {
2108 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
2109 let count = self
2110 .conn
2111 .query_row(
2112 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
2113 [],
2114 |row| row.get::<_, i64>(0),
2115 )
2116 .map_err(|error| error.to_string())? as usize;
2117 let excess = count.saturating_sub(max_entries);
2118 if excess == 0 {
2119 return Ok(());
2120 }
2121 let mut stmt = self
2122 .conn
2123 .prepare(
2124 "SELECT seq FROM _syncular_commit_outcomes
2125 WHERE status IN ('applied', 'cached') OR resolution != 'active'
2126 ORDER BY seq ASC LIMIT ?1",
2127 )
2128 .map_err(|error| error.to_string())?;
2129 let rows = stmt
2130 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
2131 .map_err(|error| error.to_string())?;
2132 let sequences = rows
2133 .collect::<Result<Vec<_>, _>>()
2134 .map_err(|error| error.to_string())?;
2135 drop(stmt);
2136 for sequence in sequences {
2137 self.conn
2138 .execute(
2139 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
2140 rusqlite::params![sequence],
2141 )
2142 .map_err(|error| error.to_string())?;
2143 }
2144 Ok(())
2145 }
2146
2147 pub fn subscribe(
2150 &mut self,
2151 id: String,
2152 table: String,
2153 scopes: Vec<(String, Vec<String>)>,
2154 params: Option<String>,
2155 ) -> Result<(), String> {
2156 if self.schema.table(&table).is_none() {
2157 return Err(format!("unknown table {table:?}"));
2158 }
2159 let sub = Subscription {
2160 id: id.clone(),
2161 table,
2162 requested: scopes,
2163 params,
2164 cursor: -1,
2165 bootstrap_state: None,
2166 state: SubState::Active,
2167 reason_code: None,
2168 effective: None,
2169 synced_once: false,
2170 };
2171 self.persist_sub(&sub);
2172 if let Some(existing) = self.subs.iter_mut().find(|s| s.id == id) {
2173 *existing = sub;
2174 } else {
2175 self.subs.push(sub);
2176 }
2177 Ok(())
2178 }
2179
2180 pub fn unsubscribe(&mut self, id: &str) {
2181 self.subs.retain(|s| s.id != id);
2182 let _ = self.conn.execute(
2183 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
2184 rusqlite::params![id],
2185 );
2186 }
2187
2188 pub fn set_window(
2196 &mut self,
2197 base: &WindowBase,
2198 units: &[String],
2199 ) -> Result<CommandEffects, String> {
2200 let table = self
2201 .schema
2202 .table(&base.table)
2203 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
2204 if table.scope_column(&base.variable).is_none() {
2205 return Err(format!(
2206 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
2207 base.table, base.variable
2208 ));
2209 }
2210 let base_key = window_base_key(base);
2211 let wanted: std::collections::HashSet<&String> = units.iter().collect();
2212 let live = self.load_window_units(&base_key);
2213 self.begin_observation("syncular_window")?;
2214 let mut batch = ChangeAccumulator::default();
2215 let mut changed = false;
2216
2217 for unit in units {
2219 if live.iter().any(|(u, _)| u == unit) {
2220 continue;
2221 }
2222 let sub_id = derive_sub_id(base, unit);
2223 self.delete_pending_evict(&sub_id);
2224 self.insert_window_unit(&base_key, unit, &sub_id);
2225 self.subscribe(
2226 sub_id,
2227 base.table.clone(),
2228 unit_scopes(base, unit),
2229 base.params.clone(),
2230 )?;
2231 batch.window(&base_key, &base.table, unit);
2232 changed = true;
2233 }
2234
2235 for (unit, sub_id) in live {
2237 if wanted.contains(&unit) {
2238 continue;
2239 }
2240 let effective = self
2241 .subs
2242 .iter()
2243 .find(|sub| sub.id == sub_id)
2244 .and_then(|sub| sub.effective.clone())
2245 .unwrap_or_else(|| unit_scopes(base, &unit));
2246 self.record_scope_map(&mut batch, &base.table, &effective);
2247 batch.window(&base_key, &base.table, &unit);
2248 self.evict_unit(&base_key, base, &unit, &sub_id);
2249 changed = true;
2250 }
2251 if let Err(error) = self.finish_observation("syncular_window", batch) {
2252 self.rollback_observation("syncular_window");
2253 return Err(error);
2254 }
2255 Ok(if changed {
2256 CommandEffects::interactive()
2257 } else {
2258 CommandEffects::none()
2259 })
2260 }
2261
2262 pub fn window_state(&self, base: &WindowBase) -> WindowState {
2267 let mut units = Vec::new();
2268 let mut pending = Vec::new();
2269 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
2270 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
2271 Some(sub) => {
2272 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
2273 }
2274 None => true,
2275 };
2276 if is_pending {
2277 pending.push(unit.clone());
2278 }
2279 units.push(unit);
2280 }
2281 WindowState { units, pending }
2282 }
2283
2284 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
2285 let mut stmt = match self
2286 .conn
2287 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
2288 {
2289 Ok(stmt) => stmt,
2290 Err(_) => return Vec::new(),
2291 };
2292 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
2293 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2294 });
2295 match rows {
2296 Ok(rows) => rows.filter_map(Result::ok).collect(),
2297 Err(_) => Vec::new(),
2298 }
2299 }
2300
2301 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
2302 let mut stmt = match self.conn.prepare(
2303 "SELECT windows.base, windows.unit, subscriptions.tbl
2304 FROM _syncular_windows AS windows
2305 JOIN _syncular_subscriptions AS subscriptions
2306 ON subscriptions.id = windows.sub_id
2307 ORDER BY windows.base, windows.unit",
2308 ) {
2309 Ok(stmt) => stmt,
2310 Err(_) => return Vec::new(),
2311 };
2312 let rows = stmt.query_map([], |row| {
2313 Ok((
2314 row.get::<_, String>(0)?,
2315 row.get::<_, String>(1)?,
2316 row.get::<_, String>(2)?,
2317 ))
2318 });
2319 match rows {
2320 Ok(rows) => rows.filter_map(Result::ok).collect(),
2321 Err(_) => Vec::new(),
2322 }
2323 }
2324
2325 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
2326 self.conn
2327 .query_row(
2328 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
2329 rusqlite::params![sub_id],
2330 |row| Ok((row.get(0)?, row.get(1)?)),
2331 )
2332 .ok()
2333 }
2334
2335 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
2336 let _ = self.conn.execute(
2337 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
2338 rusqlite::params![base_key, unit, sub_id],
2339 );
2340 }
2341
2342 fn delete_window_unit(&self, base_key: &str, unit: &str) {
2343 let _ = self.conn.execute(
2344 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
2345 rusqlite::params![base_key, unit],
2346 );
2347 }
2348
2349 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
2356 let effective = self
2357 .subs
2358 .iter()
2359 .find(|s| s.id == sub_id)
2360 .and_then(|s| s.effective.clone())
2361 .unwrap_or_else(|| unit_scopes(base, unit));
2362 let pinned = self.pinned_row_ids(&base.table);
2363 let deferred = self
2364 .evict_scope_rows(&base.table, &effective, &pinned)
2365 .unwrap_or(false);
2366 self.delete_window_unit(base_key, unit);
2367 self.unsubscribe(sub_id);
2368 if deferred {
2369 self.save_pending_evict(sub_id, &base.table, &effective);
2370 } else {
2371 self.delete_pending_evict(sub_id);
2372 }
2373 self.rebuild_overlay();
2374 }
2375
2376 fn evict_scope_rows(
2380 &mut self,
2381 table_name: &str,
2382 effective: &[(String, Vec<String>)],
2383 pinned: &std::collections::HashSet<String>,
2384 ) -> Result<bool, ()> {
2385 if effective.is_empty() {
2386 return Ok(false);
2387 }
2388 let table = self.schema.table(table_name).ok_or(())?.clone();
2389 let mut clauses = Vec::new();
2390 let mut params: Vec<SqlValue> = Vec::new();
2391 for (variable, values) in effective {
2392 let column = table.scope_column(variable).ok_or(())?;
2393 let placeholders: Vec<String> = values
2394 .iter()
2395 .map(|v| {
2396 params.push(SqlValue::Text(v.clone()));
2397 "?".to_owned()
2398 })
2399 .collect();
2400 clauses.push(format!(
2401 "{} IN ({})",
2402 quote_ident(column),
2403 placeholders.join(", ")
2404 ));
2405 }
2406 let mut sql = format!(
2407 "DELETE FROM {} WHERE {}",
2408 base_table(table_name),
2409 clauses.join(" AND ")
2410 );
2411 if !pinned.is_empty() {
2412 let pk = quote_ident(&table.primary_key);
2413 let holes: Vec<String> = pinned
2414 .iter()
2415 .map(|id| {
2416 params.push(SqlValue::Text(id.clone()));
2417 "?".to_owned()
2418 })
2419 .collect();
2420 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
2421 }
2422 self.overlay_dirty.set(true);
2423 self.conn
2424 .execute(&sql, rusqlite::params_from_iter(params))
2425 .map_err(|_| ())?;
2426 if pinned.is_empty() {
2427 return Ok(false);
2428 }
2429 let mut where_params: Vec<SqlValue> = Vec::new();
2432 let mut where_clauses = Vec::new();
2433 for (variable, values) in effective {
2434 let column = table.scope_column(variable).ok_or(())?;
2435 let placeholders: Vec<String> = values
2436 .iter()
2437 .map(|v| {
2438 where_params.push(SqlValue::Text(v.clone()));
2439 "?".to_owned()
2440 })
2441 .collect();
2442 where_clauses.push(format!(
2443 "{} IN ({})",
2444 quote_ident(column),
2445 placeholders.join(", ")
2446 ));
2447 }
2448 let pk = quote_ident(&table.primary_key);
2449 let select = format!(
2450 "SELECT {} FROM {} WHERE {}",
2451 pk,
2452 base_table(table_name),
2453 where_clauses.join(" AND ")
2454 );
2455 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
2456 let survivors: Vec<String> = stmt
2457 .query_map(rusqlite::params_from_iter(where_params), |row| {
2458 row.get::<_, String>(0)
2459 })
2460 .map_err(|_| ())?
2461 .filter_map(Result::ok)
2462 .collect();
2463 Ok(survivors.iter().any(|id| pinned.contains(id)))
2464 }
2465
2466 fn drain_pending_evictions(&mut self) {
2470 let pending = self.load_pending_evictions();
2471 if pending.is_empty() {
2472 return;
2473 }
2474 for (sub_id, table_name, effective) in pending {
2475 if self.schema.table(&table_name).is_none() {
2476 self.delete_pending_evict(&sub_id);
2477 continue;
2478 }
2479 let pinned = self.pinned_row_ids(&table_name);
2480 let deferred = self
2481 .evict_scope_rows(&table_name, &effective, &pinned)
2482 .unwrap_or(false);
2483 if !deferred {
2484 self.delete_pending_evict(&sub_id);
2485 }
2486 }
2487 self.rebuild_overlay_if_dirty();
2488 }
2489
2490 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
2493 let mut pinned = std::collections::HashSet::new();
2494 for commit in &self.outbox {
2495 for op in &commit.ops {
2496 if op.table == table {
2497 pinned.insert(op.row_id.clone());
2498 }
2499 }
2500 }
2501 pinned
2502 }
2503
2504 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
2505 let _ = self.conn.execute(
2506 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
2507 VALUES (?1, ?2, ?3)",
2508 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
2509 );
2510 }
2511
2512 fn delete_pending_evict(&self, sub_id: &str) {
2513 let _ = self.conn.execute(
2514 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2515 rusqlite::params![sub_id],
2516 );
2517 }
2518
2519 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
2520 let mut stmt = match self
2521 .conn
2522 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
2523 {
2524 Ok(stmt) => stmt,
2525 Err(_) => return Vec::new(),
2526 };
2527 let rows = stmt.query_map([], |row| {
2528 Ok((
2529 row.get::<_, String>(0)?,
2530 row.get::<_, String>(1)?,
2531 row.get::<_, String>(2)?,
2532 ))
2533 });
2534 let mut out = Vec::new();
2535 if let Ok(rows) = rows {
2536 for entry in rows.filter_map(Result::ok) {
2537 let (sub_id, table, json) = entry;
2538 if let Ok(value) = serde_json::from_str::<Value>(&json) {
2539 if let Ok(effective) = json_to_scope_map(&value) {
2540 out.push((sub_id, table, effective));
2541 }
2542 }
2543 }
2544 }
2545 out
2546 }
2547
2548 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
2550 if mutations.is_empty() {
2551 return Err("a commit must contain at least one operation (§6.1)".to_owned());
2552 }
2553 let mut ops = Vec::with_capacity(mutations.len());
2554 for mutation in mutations {
2555 match mutation {
2556 Mutation::Upsert {
2557 table,
2558 values,
2559 base_version,
2560 } => {
2561 let schema_table = self
2562 .schema
2563 .table(&table)
2564 .ok_or_else(|| format!("unknown table {table:?}"))?;
2565 let values = normalize_values_casing(schema_table, values)?;
2569 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
2570 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
2573 ops.push(OutboxOp {
2574 upsert: true,
2575 table,
2576 row_id,
2577 base_version,
2578 values: Some(values),
2579 changed_fields: None,
2580 });
2581 }
2582 Mutation::Delete {
2583 table,
2584 row_id,
2585 base_version,
2586 } => {
2587 if self.schema.table(&table).is_none() {
2588 return Err(format!("unknown table {table:?}"));
2589 }
2590 ops.push(OutboxOp {
2591 upsert: false,
2592 table,
2593 row_id,
2594 base_version,
2595 values: None,
2596 changed_fields: None,
2597 });
2598 }
2599 }
2600 }
2601 self.record_outbox_commit(ops)
2602 }
2603
2604 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
2605 let commit = OutboxCommit {
2606 client_commit_id: uuid::Uuid::new_v4().to_string(),
2607 ops,
2608 };
2609 self.begin_observation("syncular_mutation")?;
2610 let mut batch = ChangeAccumulator::default();
2611 for op in &commit.ops {
2612 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
2613 if let Some(values) = &op.values {
2614 if let Some(table) = self.schema.table(&op.table) {
2615 for scope in &table.scope_variables {
2616 if let Some(Value::String(value)) = values.get(&scope.column) {
2617 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
2618 precise = true;
2619 }
2620 }
2621 }
2622 }
2623 if !precise {
2624 batch.table(&op.table);
2625 }
2626 }
2627 self.persist_outbox_insert(&commit);
2628 let id = commit.client_commit_id.clone();
2629 self.outbox.push(commit);
2630 self.overlay_dirty.set(true);
2631 self.rebuild_overlay();
2632 batch.status = true;
2633 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
2634 self.rollback_observation("syncular_mutation");
2635 return Err(error);
2636 }
2637 Ok(id)
2638 }
2639
2640 pub fn patch(
2645 &mut self,
2646 table: &str,
2647 row_id: &str,
2648 partial: Map<String, Value>,
2649 base_version: Option<i64>,
2650 ) -> Result<String, String> {
2651 let schema_table = self
2652 .schema
2653 .table(table)
2654 .ok_or_else(|| format!("unknown table {table:?}"))?;
2655 let partial = normalize_values_casing(schema_table, partial)?;
2656 let mut values = self
2657 .read_rows(table)?
2658 .into_iter()
2659 .find(|row| row.row_id == row_id)
2660 .map(|row| row.values)
2661 .ok_or_else(|| {
2662 format!(
2663 "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
2664 )
2665 })?;
2666 let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
2667 changed_fields.sort();
2668 values.extend(partial);
2669 let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
2670 if row_id_from_values != row_id {
2671 return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
2672 }
2673 encode_row_json(schema_table, row_id, &values, &self.encryption)?;
2674 self.record_outbox_commit(vec![OutboxOp {
2675 upsert: true,
2676 table: table.to_owned(),
2677 row_id: row_id.to_owned(),
2678 base_version,
2679 values: Some(values),
2680 changed_fields: Some(changed_fields),
2681 }])
2682 }
2683
2684 pub fn pending_commit_ids(&self) -> Vec<String> {
2685 self.outbox
2686 .iter()
2687 .map(|c| c.client_commit_id.clone())
2688 .collect()
2689 }
2690
2691 pub fn conflicts(&self) -> &[ConflictRecord] {
2692 &self.conflicts
2693 }
2694
2695 pub fn rejections(&self) -> &[RejectionRecord] {
2696 &self.rejections
2697 }
2698
2699 pub fn resolve_commit_outcome(
2700 &mut self,
2701 input: ResolveCommitOutcomeInput,
2702 ) -> Result<CommitOutcome, String> {
2703 let current = self
2704 .commit_outcome(&input.client_commit_id)?
2705 .ok_or_else(|| {
2706 format!(
2707 "sync.outcome_not_found: no durable outcome exists for {:?}",
2708 input.client_commit_id
2709 )
2710 })?;
2711 if current.resolution != CommitOutcomeResolution::Active {
2712 return Ok(current);
2713 }
2714 if input.resolution == CommitOutcomeResolution::Active {
2715 return Err("sync.invalid_request: resolution must leave active state".to_owned());
2716 }
2717 match input.resolution {
2718 CommitOutcomeResolution::Superseded => {
2719 let replacement = input
2720 .replacement_client_commit_id
2721 .as_deref()
2722 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
2723 .ok_or_else(|| {
2724 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
2725 .to_owned()
2726 })?;
2727 let _ = replacement;
2728 }
2729 _ if input.replacement_client_commit_id.is_some() => {
2730 return Err(
2731 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
2732 .to_owned(),
2733 );
2734 }
2735 _ => {}
2736 }
2737 let allowed = match current.status {
2738 CommitOutcomeStatus::Conflict => matches!(
2739 input.resolution,
2740 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
2741 ),
2742 CommitOutcomeStatus::Rejected => {
2743 input.resolution == CommitOutcomeResolution::Superseded
2744 }
2745 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
2746 input.resolution == CommitOutcomeResolution::Dismissed
2747 }
2748 };
2749 if !allowed {
2750 return Err(format!(
2751 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
2752 input.resolution, current.status
2753 ));
2754 }
2755
2756 self.begin_observation("syncular_outcome_resolution")?;
2757 let result = (|| {
2758 self.conn
2759 .execute(
2760 "UPDATE _syncular_commit_outcomes
2761 SET resolution = ?1, resolved_at_ms = ?2,
2762 replacement_client_commit_id = ?3
2763 WHERE client_commit_id = ?4 AND resolution = 'active'",
2764 rusqlite::params![
2765 Self::outcome_resolution_name(input.resolution),
2766 self.clock_now_ms(),
2767 input.replacement_client_commit_id,
2768 input.client_commit_id
2769 ],
2770 )
2771 .map_err(|error| error.to_string())?;
2772 let resolved = self
2773 .commit_outcome(¤t.client_commit_id)?
2774 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
2775 self.prune_commit_outcomes()?;
2776 Ok(resolved)
2777 })();
2778 let resolved = match result {
2779 Ok(outcome) => outcome,
2780 Err(error) => {
2781 self.rollback_observation("syncular_outcome_resolution");
2782 return Err(error);
2783 }
2784 };
2785 self.conflicts
2786 .retain(|record| record.client_commit_id != current.client_commit_id);
2787 self.rejections
2788 .retain(|record| record.client_commit_id != current.client_commit_id);
2789 let batch = ChangeAccumulator {
2790 conflicts: current.status == CommitOutcomeStatus::Conflict,
2791 rejections: current.status == CommitOutcomeStatus::Rejected,
2792 outcomes: true,
2793 ..ChangeAccumulator::default()
2794 };
2795 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
2796 self.rollback_observation("syncular_outcome_resolution");
2797 return Err(error);
2798 }
2799 Ok(resolved)
2800 }
2801
2802 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
2803 self.schema_floor.as_ref()
2804 }
2805
2806 pub fn lease_state(&self) -> Option<&LeaseState> {
2808 self.lease_state.as_ref()
2809 }
2810
2811 fn record_lease_error(&mut self, code: &str) {
2814 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
2815 return;
2816 }
2817 let mut next = self.lease_state.clone().unwrap_or_default();
2818 next.error_code = Some(code.to_owned());
2819 self.set_lease_state(Some(next));
2820 }
2821
2822 fn set_lease_state(&mut self, next: Option<LeaseState>) {
2823 if self.lease_state == next {
2824 return;
2825 }
2826 if self.begin_observation("syncular_lease").is_err() {
2827 return;
2828 }
2829 self.lease_state = next;
2830 if let Some(lease) = &self.lease_state {
2831 if let Ok(json) = serde_json::to_string(lease) {
2832 self.set_meta(LEASE_STATE_KEY, &json);
2833 }
2834 } else {
2835 self.delete_meta(LEASE_STATE_KEY);
2836 }
2837 let batch = ChangeAccumulator {
2838 status: true,
2839 ..ChangeAccumulator::default()
2840 };
2841 if self.finish_observation("syncular_lease", batch).is_err() {
2842 self.rollback_observation("syncular_lease");
2843 }
2844 }
2845
2846 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
2847 if self.schema_floor == next {
2848 return;
2849 }
2850 if self.begin_observation("syncular_schema_floor").is_err() {
2851 return;
2852 }
2853 self.schema_floor = next;
2854 self.stopped = self.schema_floor.is_some();
2855 if let Some(floor) = &self.schema_floor {
2856 if let Ok(json) = serde_json::to_string(floor) {
2857 self.set_meta(SCHEMA_FLOOR_KEY, &json);
2858 }
2859 } else {
2860 self.delete_meta(SCHEMA_FLOOR_KEY);
2861 }
2862 let batch = ChangeAccumulator {
2863 status: true,
2864 ..ChangeAccumulator::default()
2865 };
2866 if self
2867 .finish_observation("syncular_schema_floor", batch)
2868 .is_err()
2869 {
2870 self.rollback_observation("syncular_schema_floor");
2871 }
2872 }
2873
2874 fn set_upgrading(&mut self, value: bool) {
2875 if self.upgrading == value {
2876 return;
2877 }
2878 if self.begin_observation("syncular_upgrading").is_err() {
2879 return;
2880 }
2881 self.upgrading = value;
2882 let batch = ChangeAccumulator {
2883 status: true,
2884 ..ChangeAccumulator::default()
2885 };
2886 if self
2887 .finish_observation("syncular_upgrading", batch)
2888 .is_err()
2889 {
2890 self.rollback_observation("syncular_upgrading");
2891 }
2892 }
2893
2894 pub fn sync_needed(&self) -> bool {
2895 self.sync_needed
2896 }
2897
2898 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
2899 let sub = self.subs.iter().find(|s| s.id == id)?;
2900 Some(SubscriptionStateView {
2901 id: sub.id.clone(),
2902 table: sub.table.clone(),
2903 status: sub.state.name().to_owned(),
2904 cursor: sub.cursor,
2905 has_resume_token: sub.bootstrap_state.is_some(),
2906 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
2907 reason_code: sub.reason_code.clone(),
2908 })
2909 }
2910
2911 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
2912 let schema_table = self
2913 .schema
2914 .table(table)
2915 .ok_or_else(|| format!("unknown table {table:?}"))?;
2916 let sql = format!(
2917 "SELECT * FROM {} ORDER BY {} ASC",
2918 visible_table(table),
2919 quote_ident(&schema_table.primary_key)
2920 );
2921 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
2922 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
2923 let mut out = Vec::new();
2924 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
2925 let mut values = Map::new();
2926 for (i, column) in schema_table.columns.iter().enumerate() {
2927 let value = row.get_ref(i).map_err(|e| e.to_string())?;
2928 values.insert(column.name.clone(), sql_ref_to_json(column, value));
2929 }
2930 let version: i64 = row
2931 .get(schema_table.columns.len())
2932 .map_err(|e| e.to_string())?;
2933 let row_id = match values.get(&schema_table.primary_key) {
2934 Some(Value::String(s)) => s.clone(),
2935 Some(Value::Number(n)) => n.to_string(),
2936 Some(Value::Bool(b)) => b.to_string(),
2937 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
2938 };
2939 out.push(RowState {
2940 row_id,
2941 version,
2942 values,
2943 });
2944 }
2945 Ok(out)
2946 }
2947
2948 #[cfg(feature = "crdt-yjs")]
2964 fn crdt_column_bytes(
2965 &self,
2966 table: &str,
2967 row_id: &str,
2968 column: &str,
2969 ) -> Result<Option<Vec<u8>>, String> {
2970 let schema_table = self
2971 .schema
2972 .table(table)
2973 .ok_or_else(|| format!("unknown table {table:?}"))?;
2974 let col = schema_table
2975 .columns
2976 .iter()
2977 .find(|c| c.name == column)
2978 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
2979 if col.ty != ColumnType::Crdt {
2980 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
2981 }
2982 let sql = format!(
2983 "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
2984 quote_ident(column),
2985 visible_table(table),
2986 quote_ident(&schema_table.primary_key)
2987 );
2988 let bytes: Option<Vec<u8>> = self
2989 .conn
2990 .query_row(&sql, rusqlite::params![row_id], |row| {
2991 row.get::<_, Option<Vec<u8>>>(0)
2992 })
2993 .map_err(|e| match e {
2994 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
2995 other => other.to_string(),
2996 })?;
2997 Ok(bytes)
2998 }
2999
3000 #[cfg(feature = "crdt-yjs")]
3004 pub fn crdt_text(
3005 &self,
3006 table: &str,
3007 row_id: &str,
3008 column: &str,
3009 name: &str,
3010 ) -> Result<String, String> {
3011 let bytes = self
3012 .crdt_column_bytes(table, row_id, column)?
3013 .unwrap_or_default();
3014 crate::crdt::text(&bytes, name)
3015 }
3016
3017 #[cfg(feature = "crdt-yjs")]
3021 pub fn crdt_insert_text(
3022 &mut self,
3023 table: &str,
3024 row_id: &str,
3025 column: &str,
3026 name: &str,
3027 index: u32,
3028 value: &str,
3029 ) -> Result<String, String> {
3030 let current = self
3031 .crdt_column_bytes(table, row_id, column)?
3032 .unwrap_or_default();
3033 let update = crate::crdt::insert_text(¤t, name, index, value)?;
3034 self.crdt_push_update(table, row_id, column, &update)
3035 }
3036
3037 #[cfg(feature = "crdt-yjs")]
3040 pub fn crdt_delete_text(
3041 &mut self,
3042 table: &str,
3043 row_id: &str,
3044 column: &str,
3045 name: &str,
3046 index: u32,
3047 len: u32,
3048 ) -> Result<String, String> {
3049 let current = self
3050 .crdt_column_bytes(table, row_id, column)?
3051 .unwrap_or_default();
3052 let update = crate::crdt::delete_text(¤t, name, index, len)?;
3053 self.crdt_push_update(table, row_id, column, &update)
3054 }
3055
3056 #[cfg(feature = "crdt-yjs")]
3061 pub fn crdt_apply_update(
3062 &mut self,
3063 table: &str,
3064 row_id: &str,
3065 column: &str,
3066 update: &[u8],
3067 ) -> Result<String, String> {
3068 let current = self
3069 .crdt_column_bytes(table, row_id, column)?
3070 .unwrap_or_default();
3071 let next = crate::crdt::apply_update(¤t, update)?;
3072 self.crdt_push_update(table, row_id, column, &next)
3073 }
3074
3075 #[cfg(feature = "crdt-yjs")]
3081 fn crdt_push_update(
3082 &mut self,
3083 table: &str,
3084 row_id: &str,
3085 column: &str,
3086 crdt_bytes: &[u8],
3087 ) -> Result<String, String> {
3088 let schema_table = self
3089 .schema
3090 .table(table)
3091 .ok_or_else(|| format!("unknown table {table:?}"))?
3092 .clone();
3093 let mut values: Map<String, Value> = self
3096 .read_rows(table)?
3097 .into_iter()
3098 .find(|r| r.row_id == row_id)
3099 .map(|r| r.values)
3100 .unwrap_or_else(|| {
3101 let mut map = Map::new();
3102 map.insert(
3103 schema_table.primary_key.clone(),
3104 Value::from(row_id.to_owned()),
3105 );
3106 map
3107 });
3108 let mut bytes_obj = Map::new();
3110 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
3111 values.insert(column.to_owned(), Value::Object(bytes_obj));
3112 self.mutate(vec![Mutation::Upsert {
3113 table: table.to_owned(),
3114 values,
3115 base_version: None,
3116 }])
3117 }
3118
3119 pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
3134 query_connection(&self.conn, sql, params)
3135 }
3136
3137 pub fn query_snapshot(
3139 &mut self,
3140 sql: &str,
3141 params: &[Value],
3142 coverage: &[WindowCoverage],
3143 ) -> Result<QuerySnapshot, String> {
3144 snapshot_connection(&self.conn, sql, params, coverage)
3145 }
3146
3147 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
3150 let mut frames = vec![Frame::ReqHeader {
3151 client_id: self.client_id.clone(),
3152 schema_version: self.schema.version,
3153 }];
3154 let mut pushed_ids = Vec::new();
3155 let mut ops_in_request = 0usize;
3156 let mut deferred_commits = 0usize;
3157 for (index, commit) in self.outbox.iter().enumerate() {
3158 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
3163 deferred_commits = self.outbox.len() - index;
3164 break;
3165 }
3166 ops_in_request += commit.ops.len();
3167 let operations = commit
3168 .ops
3169 .iter()
3170 .map(|op| {
3171 let payload = op.values.as_ref().and_then(|values| {
3172 let table = self.schema.table(&op.table)?;
3173 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
3177 });
3178 ssp2::model::Operation {
3179 table: op.table.clone(),
3180 row_id: op.row_id.clone(),
3181 op: if op.upsert { Op::Upsert } else { Op::Delete },
3182 base_version: op.base_version,
3183 payload,
3184 }
3185 })
3186 .collect();
3187 frames.push(Frame::PushCommit {
3188 client_commit_id: commit.client_commit_id.clone(),
3189 operations,
3190 });
3191 pushed_ids.push(commit.client_commit_id.clone());
3192 }
3193 let accept = self.limits.accept.unwrap_or(if url_capable {
3196 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
3197 } else {
3198 DEFAULT_ACCEPT
3199 });
3200 frames.push(Frame::PullHeader {
3201 limit_commits: self.limits.limit_commits.unwrap_or(0),
3202 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
3203 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
3204 accept,
3205 });
3206 let mut fresh = Vec::new();
3207 for sub in &self.subs {
3208 if sub.state != SubState::Active {
3209 continue;
3210 }
3211 let mut scopes = sub.requested.clone();
3212 sort_scope_map(&mut scopes);
3213 frames.push(Frame::Subscription {
3214 id: sub.id.clone(),
3215 table: sub.table.clone(),
3216 scopes,
3217 params: sub.params.clone().map(RawJson),
3218 cursor: sub.cursor,
3219 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
3220 });
3221 fresh.push((
3222 sub.id.clone(),
3223 sub.cursor < 0 && sub.bootstrap_state.is_none(),
3224 ));
3225 }
3226 let message = Message {
3227 msg_kind: MsgKind::Request,
3228 frames,
3229 };
3230 (
3231 message,
3232 RequestMeta {
3233 pushed_ids,
3234 fresh,
3235 accept,
3236 deferred_commits,
3237 },
3238 )
3239 }
3240
3241 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
3244 if self.stopped {
3245 return SyncOutcome::Ok(SyncReport {
3248 schema_floor: self.schema_floor.clone(),
3249 ..SyncReport::default()
3250 });
3251 }
3252 self.set_sync_needed(false, false);
3255 if self.schema_has_blobs() {
3258 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
3259 if Self::retryable_transport_code(&code) {
3260 self.schedule_background_retry();
3261 }
3262 return SyncOutcome::Failed {
3263 error_code: code,
3264 message,
3265 };
3266 }
3267 }
3268 let (message, meta) = self.build_request(transport.supports_url_fetch());
3269 let request_bytes = encode_message(&message);
3270 let round = if self.realtime_connected {
3274 transport.realtime_sync(&request_bytes)
3275 } else {
3276 transport.sync(&request_bytes)
3277 };
3278 let response_bytes = match round {
3279 Ok(bytes) => bytes,
3280 Err(TransportError { code, message }) => {
3281 self.record_lease_error(&code);
3284 if Self::retryable_transport_code(&code) {
3285 self.schedule_background_retry();
3286 }
3287 return SyncOutcome::Failed {
3288 error_code: code,
3289 message,
3290 };
3291 }
3292 };
3293 let response = match decode_message(&response_bytes) {
3294 Ok(message) => message,
3295 Err(error) => {
3296 return SyncOutcome::Failed {
3299 error_code: error.code.as_str().to_owned(),
3300 message: error.detail,
3301 };
3302 }
3303 };
3304 if response.msg_kind != MsgKind::Response {
3305 return SyncOutcome::Failed {
3306 error_code: "sync.invalid_request".to_owned(),
3307 message: "expected a response message".to_owned(),
3308 };
3309 }
3310 let outcome = self.process_response(transport, response, &meta);
3311 match &outcome {
3312 SyncOutcome::Ok(_) => self.reset_background_retry(),
3313 SyncOutcome::Failed { error_code, .. }
3314 if Self::retryable_transport_code(error_code) =>
3315 {
3316 self.schedule_background_retry();
3317 }
3318 SyncOutcome::Failed { .. } => {}
3319 }
3320 if meta.deferred_commits > 0 {
3321 self.set_sync_needed(true, true);
3324 }
3325 outcome
3326 }
3327
3328 pub fn sync_until_idle(
3329 &mut self,
3330 transport: &mut dyn Transport,
3331 max_rounds: Option<u32>,
3332 ) -> SyncOutcome {
3333 let rounds = max_rounds.unwrap_or(12).max(1);
3334 let mut aggregate = SyncReport::default();
3335 for _ in 0..rounds {
3336 match self.sync(transport) {
3337 SyncOutcome::Failed {
3338 error_code,
3339 message,
3340 } => {
3341 return SyncOutcome::Failed {
3342 error_code,
3343 message,
3344 };
3345 }
3346 SyncOutcome::Ok(report) => {
3347 aggregate.pushed += report.pushed;
3348 aggregate.applied.extend(report.applied.iter().cloned());
3349 aggregate.rejected.extend(report.rejected.iter().cloned());
3350 aggregate.retryable.extend(report.retryable.iter().cloned());
3351 aggregate.conflicts += report.conflicts;
3352 aggregate.commits_applied += report.commits_applied;
3353 aggregate.segment_rows_applied += report.segment_rows_applied;
3354 aggregate.bootstrapping = report.bootstrapping.clone();
3355 aggregate.resets.extend(report.resets.iter().cloned());
3356 aggregate.revoked.extend(report.revoked.iter().cloned());
3357 aggregate.failed.extend(report.failed.iter().cloned());
3358 if report.schema_floor.is_some() {
3359 aggregate.schema_floor = report.schema_floor.clone();
3360 }
3361 let more = !report.bootstrapping.is_empty()
3367 || report.commits_applied > 0
3368 || report.segment_rows_applied > 0
3369 || !report.resets.is_empty()
3370 || self.sync_needed;
3371 if !more {
3372 break;
3373 }
3374 }
3375 }
3376 }
3377 SyncOutcome::Ok(aggregate)
3378 }
3379
3380 fn process_response(
3381 &mut self,
3382 transport: &mut dyn Transport,
3383 response: Message,
3384 meta: &RequestMeta,
3385 ) -> SyncOutcome {
3386 let mut report = SyncReport {
3387 pushed: meta.pushed_ids.len() as u32,
3388 ..SyncReport::default()
3389 };
3390 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
3391 HashMap::new();
3392 for frame in &response.frames {
3393 if let Frame::PushResultDetails {
3394 client_commit_id,
3395 entries,
3396 } = frame
3397 {
3398 let details = rejection_details_by_commit
3399 .entry(client_commit_id.clone())
3400 .or_default();
3401 for entry in entries {
3402 let parsed = match RejectionDetails::parse(&entry.details.0) {
3403 Ok(value) => value,
3404 Err(message) => {
3405 return SyncOutcome::Failed {
3406 error_code: "sync.invalid_request".to_owned(),
3407 message,
3408 };
3409 }
3410 };
3411 details.insert(entry.op_index, parsed);
3412 }
3413 }
3414 }
3415 let mut frames = response.frames.into_iter();
3416 match frames.next() {
3417 Some(Frame::RespHeader {
3418 required_schema_version,
3419 latest_schema_version,
3420 }) => {
3421 if let Some(required) = required_schema_version {
3422 let floor = SchemaFloor {
3425 required_schema_version: Some(required),
3426 latest_schema_version,
3427 };
3428 self.set_schema_floor(Some(floor.clone()));
3429 report.schema_floor = Some(floor);
3430 return SyncOutcome::Ok(report);
3431 }
3432 }
3433 _ => {
3434 return SyncOutcome::Failed {
3435 error_code: "sync.invalid_request".to_owned(),
3436 message: "response does not start with RESP_HEADER".to_owned(),
3437 };
3438 }
3439 }
3440
3441 let mut failure: Option<(String, String)> = None;
3442 while let Some(frame) = frames.next() {
3443 match frame {
3444 Frame::PushResult {
3445 client_commit_id,
3446 status,
3447 commit_seq: _,
3448 results,
3449 } => {
3450 self.handle_push_result(
3451 &client_commit_id,
3452 status,
3453 &results,
3454 rejection_details_by_commit.get(&client_commit_id),
3455 &mut report,
3456 );
3457 }
3458 Frame::PushResultDetails { .. } => {}
3459 Frame::SubStart {
3460 id,
3461 status,
3462 reason_code,
3463 effective_scopes,
3464 bootstrap: _,
3465 } => {
3466 let mut body = Vec::new();
3467 let mut sub_end: Option<(i64, Option<String>)> = None;
3468 for inner in frames.by_ref() {
3469 match inner {
3470 Frame::SubEnd {
3471 next_cursor,
3472 bootstrap_state,
3473 } => {
3474 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
3475 break;
3476 }
3477 Frame::Unknown { .. } => {}
3478 other => body.push(other),
3479 }
3480 }
3481 let Some((next_cursor, bootstrap_state)) = sub_end else {
3482 failure = Some((
3483 "sync.invalid_request".to_owned(),
3484 "subscription section without SUB_END".to_owned(),
3485 ));
3486 break;
3487 };
3488 if let Err(SectionError::Abort(code, message)) = self.process_section(
3489 transport,
3490 &id,
3491 status,
3492 &reason_code,
3493 effective_scopes,
3494 body,
3495 next_cursor,
3496 bootstrap_state,
3497 meta,
3498 &mut report,
3499 ) {
3500 failure = Some((code, message));
3501 break;
3502 }
3503 }
3504 Frame::Lease {
3505 lease_id,
3506 expires_at_ms,
3507 } => {
3508 self.set_lease_state(Some(LeaseState {
3511 lease_id: Some(lease_id),
3512 expires_at_ms: Some(expires_at_ms),
3513 error_code: None,
3514 }));
3515 }
3516 Frame::Error { code, message, .. } => {
3517 failure = Some((code, message));
3520 break;
3521 }
3522 Frame::Unknown { .. } => {}
3523 _ => {
3524 failure = Some((
3525 "sync.invalid_request".to_owned(),
3526 "unexpected frame in response".to_owned(),
3527 ));
3528 break;
3529 }
3530 }
3531 }
3532
3533 if self.overlay_dirty.get() {
3536 self.rebuild_overlay();
3537 }
3538 self.reconcile_blob_refcounts(false);
3545
3546 if let Some((error_code, message)) = failure {
3547 return SyncOutcome::Failed {
3548 error_code,
3549 message,
3550 };
3551 }
3552 self.drain_pending_evictions();
3555 self.ack_after_pull(transport);
3556 if self.upgrading && report.bootstrapping.is_empty() {
3559 self.set_upgrading(false);
3560 }
3561 SyncOutcome::Ok(report)
3562 }
3563
3564 fn handle_push_result(
3567 &mut self,
3568 client_commit_id: &str,
3569 status: PushStatus,
3570 results: &[OpResult],
3571 rejection_details: Option<&BTreeMap<i32, RejectionDetails>>,
3572 report: &mut SyncReport,
3573 ) {
3574 let Some(index) = self
3575 .outbox
3576 .iter()
3577 .position(|c| c.client_commit_id == client_commit_id)
3578 else {
3579 return;
3580 };
3581 if self.begin_observation("syncular_push_result").is_err() {
3582 return;
3583 }
3584 let mut batch = ChangeAccumulator::default();
3585 let operations = self.outbox[index].ops.clone();
3586 match status {
3587 PushStatus::Applied | PushStatus::Cached => {
3588 let journal_results = results
3591 .iter()
3592 .map(|result| {
3593 let op_index = match result {
3594 OpResult::Applied { op_index }
3595 | OpResult::Conflict { op_index, .. }
3596 | OpResult::Error { op_index, .. } => *op_index,
3597 };
3598 CommitOperationOutcome::Applied { op_index }
3599 })
3600 .collect::<Vec<_>>();
3601 let outcome_status = if status == PushStatus::Applied {
3602 CommitOutcomeStatus::Applied
3603 } else {
3604 CommitOutcomeStatus::Cached
3605 };
3606 if self
3607 .persist_commit_outcome(client_commit_id, outcome_status, &journal_results)
3608 .and_then(|()| self.delete_outbox_persisted(client_commit_id))
3609 .and_then(|()| self.prune_commit_outcomes())
3610 .is_err()
3611 {
3612 self.rollback_observation("syncular_push_result");
3613 return;
3614 }
3615 report.applied.push(client_commit_id.to_owned());
3616 self.outbox.remove(index);
3617 self.overlay_dirty.set(true);
3618 batch.status = true;
3619 batch.outcomes = true;
3620 }
3621 PushStatus::Rejected => {
3622 if results.iter().any(|result| {
3623 matches!(
3624 result,
3625 OpResult::Error {
3626 code,
3627 retryable: true,
3628 ..
3629 } if code == "sync.idempotency_cache_miss"
3630 )
3631 }) {
3632 report.retryable.push(client_commit_id.to_owned());
3635 if self
3636 .finish_observation("syncular_push_result", batch)
3637 .is_err()
3638 {
3639 self.rollback_observation("syncular_push_result");
3640 }
3641 return;
3642 }
3643
3644 let mut journal_results = Vec::with_capacity(results.len());
3645 let mut conflicts = Vec::new();
3646 let mut rejections = Vec::new();
3647 for result in results {
3648 match result {
3649 OpResult::Applied { op_index } => {
3650 journal_results.push(CommitOperationOutcome::Applied {
3651 op_index: *op_index,
3652 });
3653 }
3654 OpResult::Conflict {
3655 op_index,
3656 code,
3657 message,
3658 server_version,
3659 server_row,
3660 } => {
3661 let operation = operations
3662 .get(*op_index as usize)
3663 .map(CommitOperation::from);
3664 let (table, row_id) = operation
3665 .as_ref()
3666 .map(|op| (op.table.clone(), op.row_id.clone()))
3667 .unwrap_or_default();
3668 let server_row_json = self
3669 .schema
3670 .table(&table)
3671 .and_then(|t| {
3672 decode_row_bytes(t, server_row, &self.encryption)
3673 .ok()
3674 .map(|row| (t, row))
3675 })
3676 .map(|(t, row)| {
3677 let mut map = Map::new();
3678 for (i, column) in t.columns.iter().enumerate() {
3679 map.insert(
3680 column.name.clone(),
3681 column_value_to_json(row.get(i).unwrap_or(&None)),
3682 );
3683 }
3684 map
3685 })
3686 .unwrap_or_default();
3687 let conflict = ConflictRecord {
3688 client_commit_id: client_commit_id.to_owned(),
3689 op_index: *op_index,
3690 table,
3691 row_id,
3692 code: code.clone(),
3693 message: message.clone(),
3694 server_version: *server_version,
3695 server_row: server_row_json,
3696 operation,
3697 };
3698 journal_results.push(CommitOperationOutcome::Conflict {
3699 conflict: conflict.clone(),
3700 });
3701 conflicts.push(conflict);
3702 }
3703 OpResult::Error {
3704 op_index,
3705 code,
3706 message,
3707 retryable,
3708 } => {
3709 let rejection = RejectionRecord {
3710 client_commit_id: client_commit_id.to_owned(),
3711 op_index: *op_index,
3712 code: code.clone(),
3713 message: message.clone(),
3714 retryable: *retryable,
3715 details: rejection_details
3716 .and_then(|details| details.get(op_index))
3717 .cloned(),
3718 operation: operations
3719 .get(*op_index as usize)
3720 .map(CommitOperation::from),
3721 };
3722 journal_results.push(CommitOperationOutcome::Error {
3723 rejection: rejection.clone(),
3724 });
3725 rejections.push(rejection);
3726 }
3727 }
3728 }
3729 let outcome_status = if conflicts.is_empty() {
3730 CommitOutcomeStatus::Rejected
3731 } else {
3732 CommitOutcomeStatus::Conflict
3733 };
3734 if self
3735 .persist_commit_outcome(client_commit_id, outcome_status, &journal_results)
3736 .and_then(|()| self.delete_outbox_persisted(client_commit_id))
3737 .and_then(|()| self.prune_commit_outcomes())
3738 .is_err()
3739 {
3740 self.rollback_observation("syncular_push_result");
3741 return;
3742 }
3743 report.conflicts += conflicts.len() as u32;
3744 report.rejected.push(client_commit_id.to_owned());
3745 batch.conflicts = !conflicts.is_empty();
3746 batch.rejections = !rejections.is_empty();
3747 batch.status = true;
3748 batch.outcomes = true;
3749 self.conflicts.extend(conflicts);
3750 self.rejections.extend(rejections);
3751 self.outbox.remove(index);
3752 self.overlay_dirty.set(true);
3753 }
3754 }
3755 if batch.status {
3756 for operation in &operations {
3757 if !self.record_row_scopes(&mut batch, &operation.table, &operation.row_id, false) {
3758 batch.table(&operation.table);
3759 }
3760 }
3761 self.rebuild_overlay_if_dirty();
3762 }
3763 if self
3764 .finish_observation("syncular_push_result", batch)
3765 .is_err()
3766 {
3767 self.rollback_observation("syncular_push_result");
3768 }
3769 }
3770
3771 #[allow(clippy::too_many_arguments)]
3774 fn process_section(
3775 &mut self,
3776 transport: &mut dyn Transport,
3777 id: &str,
3778 status: SubStatus,
3779 reason_code: &str,
3780 effective_scopes: Vec<(String, Vec<String>)>,
3781 body: Vec<Frame>,
3782 next_cursor: i64,
3783 bootstrap_state: Option<String>,
3784 meta: &RequestMeta,
3785 report: &mut SyncReport,
3786 ) -> Result<(), SectionError> {
3787 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3788 return Ok(()); };
3790 match status {
3791 SubStatus::Revoked => {
3792 self.begin_observation("syncular_revocation")
3793 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3794 let mut batch = ChangeAccumulator::default();
3795 let registered = self.window_unit_by_sub_id(id);
3796 let (table, effective) = {
3798 let sub = &self.subs[sub_index];
3799 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3800 };
3801 let purged = self.purge_scope_rows(&table, &effective);
3802 match purged {
3803 Ok(()) => {
3804 self.record_scope_map(&mut batch, &table, &effective);
3805 let sub = &mut self.subs[sub_index];
3806 sub.state = SubState::Revoked;
3807 sub.reason_code = Some(if reason_code.is_empty() {
3808 "sync.scope_revoked".to_owned()
3809 } else {
3810 reason_code.to_owned()
3811 });
3812 report.revoked.push(id.to_owned());
3813 let doomed_effective = effective;
3814 let sub_table = table;
3815 self.persist_sub(&self.subs[sub_index].clone());
3816 let dropped = self
3817 .drop_doomed_outbox(&sub_table, &doomed_effective)
3818 .map_err(|message| {
3819 SectionError::Abort("storage.failed".to_owned(), message)
3820 })?;
3821 if dropped {
3822 batch.status = true;
3823 batch.rejections = true;
3824 batch.outcomes = true;
3825 }
3826 self.reconcile_blob_refcounts(true);
3829 }
3830 Err(()) => {
3831 let sub = &mut self.subs[sub_index];
3834 sub.state = SubState::Failed;
3835 sub.reason_code = Some("sync.scope_revoked".to_owned());
3836 report.failed.push(id.to_owned());
3837 self.persist_sub(&self.subs[sub_index].clone());
3838 }
3839 }
3840 if let Some((base_key, unit)) = registered {
3841 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3842 }
3843 self.rebuild_overlay_if_dirty();
3844 self.finish_observation("syncular_revocation", batch)
3845 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3846 Ok(())
3847 }
3848 SubStatus::Reset => {
3849 self.begin_observation("syncular_reset")
3850 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3851 let mut batch = ChangeAccumulator::default();
3852 let registered = self.window_unit_by_sub_id(id);
3853 let sub = &mut self.subs[sub_index];
3856 sub.cursor = -1;
3857 sub.bootstrap_state = None;
3858 report.resets.push(id.to_owned());
3859 self.persist_sub(&self.subs[sub_index].clone());
3860 if let Some((base_key, unit)) = registered {
3861 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3862 }
3863 self.finish_observation("syncular_reset", batch)
3864 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3865 Ok(())
3866 }
3867 SubStatus::Active => {
3868 let fresh = meta
3869 .fresh
3870 .iter()
3871 .find(|(fid, _)| fid == id)
3872 .map(|(_, f)| *f)
3873 .unwrap_or(false);
3874 let was_pending = self.subs[sub_index].cursor < 0
3875 || self.subs[sub_index].bootstrap_state.is_some();
3876 let registered = self.window_unit_by_sub_id(id);
3877 self.subs[sub_index].effective = Some(effective_scopes);
3879 self.begin_observation("syncular_section")
3880 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3881 let mut batch = ChangeAccumulator::default();
3882 let outcome = self.apply_section_body(
3883 transport, sub_index, body, fresh, meta, report, &mut batch,
3884 );
3885 match outcome {
3886 Ok(()) => {
3887 let sub = &mut self.subs[sub_index];
3888 sub.cursor = next_cursor;
3891 sub.bootstrap_state = bootstrap_state;
3892 sub.synced_once = true;
3893 if sub.bootstrap_state.is_some() {
3894 report.bootstrapping.push(id.to_owned());
3895 }
3896 let completed =
3897 was_pending && sub.cursor >= 0 && sub.bootstrap_state.is_none();
3898 self.persist_sub(&self.subs[sub_index].clone());
3899 if completed {
3900 if let Some((base_key, unit)) = registered.clone() {
3901 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3902 }
3903 }
3904 self.rebuild_overlay_if_dirty();
3905 self.finish_observation("syncular_section", batch)
3906 .map_err(|message| {
3907 SectionError::Abort("storage.failed".to_owned(), message)
3908 })?;
3909 Ok(())
3910 }
3911 Err(SectionError::FailClosed) => {
3912 self.rollback_observation("syncular_section");
3915 self.begin_observation("syncular_section_failure")
3916 .map_err(|message| {
3917 SectionError::Abort("storage.failed".to_owned(), message)
3918 })?;
3919 let mut failure_batch = ChangeAccumulator::default();
3920 let sub = &mut self.subs[sub_index];
3921 sub.state = SubState::Failed;
3922 sub.reason_code = Some("sync.scope_revoked".to_owned());
3923 report.failed.push(id.to_owned());
3924 self.persist_sub(&self.subs[sub_index].clone());
3925 if let Some((base_key, unit)) = registered {
3926 failure_batch.window(&base_key, &self.subs[sub_index].table, &unit);
3927 }
3928 self.finish_observation("syncular_section_failure", failure_batch)
3929 .map_err(|message| {
3930 SectionError::Abort("storage.failed".to_owned(), message)
3931 })?;
3932 Ok(())
3933 }
3934 Err(SectionError::Abort(code, message)) => {
3935 self.rollback_observation("syncular_section");
3938 Err(SectionError::Abort(code, message))
3939 }
3940 }
3941 }
3942 }
3943 }
3944
3945 #[allow(clippy::too_many_arguments)]
3949 fn apply_section_body(
3950 &mut self,
3951 transport: &mut dyn Transport,
3952 sub_index: usize,
3953 body: Vec<Frame>,
3954 fresh: bool,
3955 meta: &RequestMeta,
3956 report: &mut SyncReport,
3957 batch: &mut ChangeAccumulator,
3958 ) -> Result<(), SectionError> {
3959 let mut saw_segment = false;
3960 for frame in body {
3961 match frame {
3962 Frame::Commit {
3963 tables, changes, ..
3964 } => {
3965 self.record_commit_changes(batch, &tables, &changes);
3966 self.apply_commit_changes(&tables, &changes)
3967 .map_err(|(c, m)| SectionError::Abort(c, m))?;
3968 report.commits_applied += 1;
3969 }
3970 Frame::SegmentInline { payload } => {
3971 let segment = decode_rows_segment(&payload)
3972 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
3973 let first = !saw_segment;
3974 saw_segment = true;
3975 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3976 let cleared =
3977 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
3978 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
3979 if applied > 0 || cleared {
3980 batch.table(&segment.table);
3981 }
3982 report.segment_rows_applied += applied;
3983 }
3984 Frame::SegmentRef {
3985 segment_id,
3986 media_type,
3987 table,
3988 row_count,
3989 as_of_commit_seq,
3990 scope_digest,
3991 row_cursor,
3992 next_row_cursor,
3993 url,
3994 url_expires_at_ms,
3995 ..
3996 } => {
3997 let advertised = match media_type {
4000 MediaType::Rows => {
4001 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
4002 || meta.accept & ACCEPT_INLINE_ROWS != 0
4003 }
4004 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
4005 };
4006 if !advertised {
4007 return Err(SectionError::Abort(
4008 "sync.invalid_request".to_owned(),
4009 format!(
4010 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
4011 media_type.name()
4012 ),
4013 ));
4014 }
4015 let bytes = if let Some(url) = url {
4016 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
4021 return Err(SectionError::Abort(
4022 "sync.invalid_request".to_owned(),
4023 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
4024 .to_owned(),
4025 ));
4026 }
4027 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
4029 return Err(SectionError::Abort(
4030 "sync.segment_expired".to_owned(),
4031 format!(
4032 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
4033 ),
4034 ));
4035 }
4036 transport
4037 .fetch_url(&url)
4038 .map_err(|e| SectionError::Abort(e.code, e.message))?
4039 } else {
4040 let requested_scopes_json =
4041 canonical_scope_json(&self.subs[sub_index].requested);
4042 transport
4043 .download_segment(&SegmentRequest {
4044 segment_id: segment_id.clone(),
4045 table,
4046 requested_scopes_json,
4047 })
4048 .map_err(|e| SectionError::Abort(e.code, e.message))?
4049 };
4050 let digest = Sha256::digest(&bytes);
4052 let expected = segment_id
4053 .strip_prefix("sha256:")
4054 .unwrap_or(segment_id.as_str());
4055 if bytes_to_hex(&digest) != expected {
4056 return Err(SectionError::Abort(
4057 "sync.invalid_request".to_owned(),
4058 "segment bytes do not match the content address (§5.1)".to_owned(),
4059 ));
4060 }
4061 if media_type == MediaType::Sqlite {
4062 if row_cursor.is_some() || next_row_cursor.is_some() {
4065 return Err(SectionError::Abort(
4066 "sync.invalid_request".to_owned(),
4067 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
4068 .to_owned(),
4069 ));
4070 }
4071 let first = !saw_segment;
4072 saw_segment = true;
4073 let sub_table = self.subs[sub_index].table.clone();
4074 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
4075 let cleared =
4076 fresh && first && self.scoped_rows_exist(&sub_table, &effective);
4077 let applied = self.apply_sqlite_segment(
4078 sub_index,
4079 &bytes,
4080 fresh && first,
4081 row_count,
4082 as_of_commit_seq,
4083 &scope_digest,
4084 )?;
4085 if applied > 0 || cleared {
4086 batch.table(&sub_table);
4087 }
4088 report.segment_rows_applied += applied;
4089 } else {
4090 let segment = decode_rows_segment(&bytes).map_err(|e| {
4091 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
4092 })?;
4093 let first = row_cursor.is_none();
4094 saw_segment = true;
4095 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
4096 let cleared =
4097 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
4098 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
4099 if applied > 0 || cleared {
4100 batch.table(&segment.table);
4101 }
4102 report.segment_rows_applied += applied;
4103 }
4104 }
4105 Frame::Unknown { .. } => {}
4106 _ => {
4107 return Err(SectionError::Abort(
4108 "sync.invalid_request".to_owned(),
4109 "unexpected frame inside a subscription section".to_owned(),
4110 ));
4111 }
4112 }
4113 }
4114 Ok(())
4115 }
4116
4117 fn apply_commit_changes(
4118 &mut self,
4119 tables: &[String],
4120 changes: &[ssp2::model::Change],
4121 ) -> Result<(), (String, String)> {
4122 for change in changes {
4123 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
4124 (
4125 "sync.invalid_request".to_owned(),
4126 "change tableIndex out of range".to_owned(),
4127 )
4128 })?;
4129 let table = self.schema.table(table_name).ok_or_else(|| {
4130 (
4131 "sync.schema_mismatch".to_owned(),
4132 format!("change targets unknown table {table_name:?}"),
4133 )
4134 })?;
4135 match change.op {
4136 Op::Upsert => {
4137 let payload = change.row.as_ref().ok_or_else(|| {
4138 (
4139 "sync.invalid_request".to_owned(),
4140 "upsert change without row payload".to_owned(),
4141 )
4142 })?;
4143 let row = decode_row_bytes(table, payload, &self.encryption)
4145 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
4146 let version = change.row_version.unwrap_or(0);
4147 let table_name = table.name.clone();
4148 self.write_base_row(&table_name, &row, version)
4149 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
4150 }
4151 Op::Delete => {
4152 self.delete_base_row(table_name, &change.row_id)
4153 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
4154 }
4155 }
4156 }
4157 Ok(())
4158 }
4159
4160 fn apply_segment(
4165 &mut self,
4166 sub_index: usize,
4167 segment: &RowsSegment,
4168 first_fresh_page: bool,
4169 ) -> Result<u32, SectionError> {
4170 let (sub_table, effective) = {
4171 let sub = &self.subs[sub_index];
4172 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
4173 };
4174 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
4175 SectionError::Abort(
4176 "sync.schema_mismatch".to_owned(),
4177 format!("subscription table {sub_table:?} is not in the client schema"),
4178 )
4179 })?;
4180 let matches = segment.table == table.name
4185 && segment.schema_version == self.schema.version
4186 && segment.columns.len() == table.wire_columns.len()
4187 && segment
4188 .columns
4189 .iter()
4190 .zip(table.wire_columns.iter())
4191 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
4192 if !matches {
4193 return Err(SectionError::Abort(
4194 "sync.schema_mismatch".to_owned(),
4195 "segment column table does not match the generated schema (§5.2)".to_owned(),
4196 ));
4197 }
4198 if first_fresh_page {
4199 self.purge_scope_rows(&table.name, &effective)
4203 .map_err(|()| SectionError::FailClosed)?;
4204 }
4205 let mut applied = 0u32;
4206 for block in &segment.blocks {
4207 for row in block {
4208 let decrypted;
4213 let values = if table.has_encrypted_columns() {
4214 let mut values = row.values.clone();
4215 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
4216 .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
4217 decrypted = values;
4218 &decrypted
4219 } else {
4220 &row.values
4221 };
4222 self.write_base_row(&table.name, values, row.server_version)
4225 .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
4226 applied += 1;
4227 }
4228 }
4229 Ok(applied)
4230 }
4231
4232 fn apply_sqlite_segment(
4240 &mut self,
4241 sub_index: usize,
4242 bytes: &[u8],
4243 first_fresh_page: bool,
4244 row_count: i64,
4245 as_of_commit_seq: i64,
4246 scope_digest: &str,
4247 ) -> Result<u32, SectionError> {
4248 let invalid = |detail: &str| {
4249 SectionError::Abort(
4250 "sync.invalid_request".to_owned(),
4251 format!("sqlite segment rejected: {detail} (§5.3)"),
4252 )
4253 };
4254 let (sub_table, effective) = {
4255 let sub = &self.subs[sub_index];
4256 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
4257 };
4258 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
4259 SectionError::Abort(
4260 "sync.schema_mismatch".to_owned(),
4261 format!("subscription table {sub_table:?} is not in the client schema"),
4262 )
4263 })?;
4264
4265 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
4266 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
4267 let img = match rusqlite::Connection::open_with_flags(
4268 &path,
4269 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
4270 ) {
4271 Ok(conn) => conn,
4272 Err(_) => {
4273 let _ = std::fs::remove_file(&path);
4274 return Err(invalid("bytes do not open as a SQLite database"));
4275 }
4276 };
4277 let outcome = self.apply_sqlite_image(
4278 &img,
4279 &table,
4280 first_fresh_page,
4281 &effective,
4282 row_count,
4283 as_of_commit_seq,
4284 scope_digest,
4285 );
4286 drop(img);
4287 let _ = std::fs::remove_file(&path);
4288 outcome
4289 }
4290
4291 #[allow(clippy::too_many_arguments)]
4292 fn apply_sqlite_image(
4293 &mut self,
4294 img: &rusqlite::Connection,
4295 table: &crate::schema::TableSchema,
4296 first_fresh_page: bool,
4297 effective: &[(String, Vec<String>)],
4298 row_count: i64,
4299 as_of_commit_seq: i64,
4300 scope_digest: &str,
4301 ) -> Result<u32, SectionError> {
4302 let invalid = |detail: String| {
4303 SectionError::Abort(
4304 "sync.invalid_request".to_owned(),
4305 format!("sqlite segment rejected: {detail} (§5.3)"),
4306 )
4307 };
4308
4309 type MetaRow = (i64, String, i64, i64, String, i64, i64);
4312 let meta: MetaRow = img
4313 .query_row(
4314 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
4315 \"scopeDigest\", \"rowCount\",
4316 (SELECT count(*) FROM _syncular_segment)
4317 FROM _syncular_segment",
4318 [],
4319 |row| {
4320 Ok((
4321 row.get(0)?,
4322 row.get(1)?,
4323 row.get(2)?,
4324 row.get(3)?,
4325 row.get(4)?,
4326 row.get(5)?,
4327 row.get(6)?,
4328 ))
4329 },
4330 )
4331 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
4332 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
4333 if meta_count != 1 {
4334 return Err(invalid(format!(
4335 "_syncular_segment must contain exactly one row, found {meta_count}"
4336 )));
4337 }
4338 if format != 1 {
4339 return Err(invalid(format!("format {format}")));
4340 }
4341 if meta_table != table.name {
4342 return Err(invalid(format!("image table {meta_table:?}")));
4343 }
4344 if schema_version != i64::from(self.schema.version) {
4345 return Err(invalid(format!("schemaVersion {schema_version}")));
4346 }
4347 if pin != as_of_commit_seq {
4348 return Err(invalid(format!("asOfCommitSeq {pin}")));
4349 }
4350 if digest != scope_digest {
4351 return Err(invalid("scopeDigest mismatch".to_owned()));
4352 }
4353 if meta_rows != row_count {
4354 return Err(invalid(format!("rowCount {meta_rows}")));
4355 }
4356
4357 let mut names: Vec<String> = Vec::new();
4359 {
4360 let mut stmt = img
4361 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
4362 .map_err(|_| invalid("image data table missing".to_owned()))?;
4363 let mut rows = stmt
4364 .query([])
4365 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
4366 while let Some(row) = rows
4367 .next()
4368 .map_err(|_| invalid("image data table unreadable".to_owned()))?
4369 {
4370 names.push(
4371 row.get::<_, String>(1)
4372 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
4373 );
4374 }
4375 }
4376 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
4377 expected.push("_syncular_version");
4378 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
4379 return Err(SectionError::Abort(
4380 "sync.schema_mismatch".to_owned(),
4381 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
4382 ));
4383 }
4384
4385 if first_fresh_page {
4388 self.purge_scope_rows(&table.name, effective)
4389 .map_err(|()| SectionError::FailClosed)?;
4390 }
4391 self.overlay_dirty.set(true);
4397 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
4404 table.indexes.iter().filter(|i| !i.unique).collect()
4405 } else {
4406 Vec::new()
4407 };
4408 for index in &bulk_indexes {
4409 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
4410 self.conn
4411 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
4412 .map_err(|e| invalid(e.to_string()))?;
4413 }
4414 let insert = self.insert_row_sql(&base_table(&table.name), table);
4415 let applied = {
4416 let mut ins = self
4417 .conn
4418 .prepare_cached(&insert)
4419 .map_err(|e| invalid(e.to_string()))?;
4420 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
4421 let mut stmt = img
4422 .prepare(&format!(
4423 "SELECT {} FROM {}",
4424 column_list.join(", "),
4425 quote_ident(&table.name)
4426 ))
4427 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
4428 let mut rows = stmt
4429 .query([])
4430 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
4431 let version_index = table.columns.len();
4432 let mut applied = 0u32;
4433 while let Some(row) = rows
4434 .next()
4435 .map_err(|_| invalid("image row unreadable".to_owned()))?
4436 {
4437 for (i, column) in table.columns.iter().enumerate() {
4438 let cell = row
4439 .get_ref(i)
4440 .map_err(|_| invalid("image row unreadable".to_owned()))?;
4441 let param = image_cell_param(column, cell).map_err(&invalid)?;
4442 ins.raw_bind_parameter(i + 1, param)
4443 .map_err(|e| invalid(e.to_string()))?;
4444 }
4445 let version: i64 = row
4446 .get(version_index)
4447 .map_err(|_| invalid("image row unreadable".to_owned()))?;
4448 if version < 1 {
4449 return Err(invalid(format!(
4450 "row _syncular_version must be >= 1, got {version}"
4451 )));
4452 }
4453 ins.raw_bind_parameter(version_index + 1, version)
4454 .map_err(|e| invalid(e.to_string()))?;
4455 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
4456 applied += 1;
4457 }
4458 applied
4459 };
4460 for index in &bulk_indexes {
4461 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
4462 let cols_sql = index
4463 .columns
4464 .iter()
4465 .map(|c| quote_ident(c))
4466 .collect::<Vec<_>>()
4467 .join(", ");
4468 self.conn
4469 .execute(
4470 &format!(
4471 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
4472 base_table(&table.name)
4473 ),
4474 [],
4475 )
4476 .map_err(|e| invalid(e.to_string()))?;
4477 }
4478 if i64::from(applied) != row_count {
4479 return Err(invalid(format!(
4480 "image holds {applied} rows, descriptor says {row_count}"
4481 )));
4482 }
4483 Ok(applied)
4484 }
4485
4486 fn purge_scope_rows(
4491 &mut self,
4492 table_name: &str,
4493 effective: &[(String, Vec<String>)],
4494 ) -> Result<(), ()> {
4495 if effective.is_empty() {
4496 return Ok(());
4497 }
4498 let table = self.schema.table(table_name).ok_or(())?.clone();
4499 let mut clauses = Vec::new();
4500 let mut params: Vec<SqlValue> = Vec::new();
4501 for (variable, values) in effective {
4502 let column = table.scope_column(variable).ok_or(())?;
4503 let placeholders: Vec<String> = values
4504 .iter()
4505 .map(|v| {
4506 params.push(SqlValue::Text(v.clone()));
4507 "?".to_owned()
4508 })
4509 .collect();
4510 clauses.push(format!(
4511 "{} IN ({})",
4512 quote_ident(column),
4513 placeholders.join(", ")
4514 ));
4515 }
4516 let sql = format!(
4517 "DELETE FROM {} WHERE {}",
4518 base_table(table_name),
4519 clauses.join(" AND ")
4520 );
4521 self.overlay_dirty.set(true);
4522 self.conn
4523 .execute(&sql, rusqlite::params_from_iter(params))
4524 .map_err(|_| ())?;
4525 Ok(())
4526 }
4527
4528 fn drop_doomed_outbox(
4531 &mut self,
4532 table_name: &str,
4533 effective: &[(String, Vec<String>)],
4534 ) -> Result<bool, String> {
4535 if effective.is_empty() {
4536 return Ok(false);
4537 }
4538 let Some(table) = self.schema.table(table_name).cloned() else {
4539 return Ok(false);
4540 };
4541 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
4542 for (variable, values) in effective {
4543 match table.scope_column(variable) {
4544 Some(column) => mappings.push((column, values)),
4545 None => return Ok(false), }
4547 }
4548 let doomed: Vec<OutboxCommit> = self
4549 .outbox
4550 .iter()
4551 .filter(|commit| {
4552 commit.ops.iter().any(|op| {
4553 op.upsert
4554 && op.table == table_name
4555 && op.values.as_ref().is_some_and(|values| {
4556 mappings
4557 .iter()
4558 .all(|(column, allowed)| match values.get(*column) {
4559 Some(Value::String(s)) => allowed.contains(s),
4560 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
4561 _ => false,
4562 })
4563 })
4564 })
4565 })
4566 .cloned()
4567 .collect();
4568 if doomed.is_empty() {
4569 return Ok(false);
4570 }
4571 let mut rejections = Vec::new();
4572 for commit in &doomed {
4573 let results = commit
4574 .ops
4575 .iter()
4576 .enumerate()
4577 .map(|(op_index, operation)| {
4578 let rejection = RejectionRecord {
4579 client_commit_id: commit.client_commit_id.clone(),
4580 op_index: op_index as i32,
4581 code: "sync.scope_revoked".to_owned(),
4582 message: "the commit was dropped because its effective scope was revoked"
4583 .to_owned(),
4584 retryable: false,
4585 details: None,
4586 operation: Some(CommitOperation::from(operation)),
4587 };
4588 rejections.push(rejection.clone());
4589 CommitOperationOutcome::Error { rejection }
4590 })
4591 .collect::<Vec<_>>();
4592 self.persist_commit_outcome(
4593 &commit.client_commit_id,
4594 CommitOutcomeStatus::Rejected,
4595 &results,
4596 )?;
4597 self.delete_outbox_persisted(&commit.client_commit_id)?;
4598 }
4599 self.prune_commit_outcomes()?;
4600 let doomed_ids = doomed
4601 .iter()
4602 .map(|commit| commit.client_commit_id.as_str())
4603 .collect::<BTreeSet<_>>();
4604 self.outbox
4605 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
4606 self.rejections.extend(rejections);
4607 self.overlay_dirty.set(true);
4608 Ok(true)
4609 }
4610
4611 pub fn upload_blob(
4618 &mut self,
4619 bytes: &[u8],
4620 media_type: Option<String>,
4621 name: Option<String>,
4622 ) -> Result<Value, String> {
4623 let blob_id = blob_id_for(bytes);
4624 let now = self.clock_now_ms();
4625 self.conn
4626 .execute(
4627 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
4628 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
4629 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
4630 )
4631 .map_err(|e| e.to_string())?;
4632 self.conn
4633 .execute(
4634 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
4635 rusqlite::params![blob_id, media_type, now],
4636 )
4637 .map_err(|e| e.to_string())?;
4638 self.enforce_blob_cache_cap();
4641 let mut obj = Map::new();
4642 obj.insert("blobId".to_owned(), Value::from(blob_id));
4643 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
4644 if let Some(mt) = media_type {
4645 obj.insert("mediaType".to_owned(), Value::from(mt));
4646 }
4647 if let Some(n) = name {
4648 obj.insert("name".to_owned(), Value::from(n));
4649 }
4650 Ok(Value::Object(obj))
4651 }
4652
4653 pub fn fetch_blob(
4657 &mut self,
4658 transport: &mut dyn Transport,
4659 blob_id_or_ref: &str,
4660 ) -> Result<Value, (String, String)> {
4661 let simple = |m: String| ("client.failed".to_owned(), m);
4662 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
4663 blob_id_or_ref.to_owned()
4664 } else {
4665 let value: Value = serde_json::from_str(blob_id_or_ref)
4666 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
4667 value
4668 .get("blobId")
4669 .and_then(Value::as_str)
4670 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
4671 .to_owned()
4672 };
4673 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
4674 return Ok(cached);
4675 }
4676 let bytes = match transport
4682 .blob_download(&blob_id)
4683 .map_err(|e| (e.code, e.message))?
4684 {
4685 BlobDownload::Bytes(bytes) => bytes,
4686 BlobDownload::Url {
4687 url,
4688 url_expires_at_ms,
4689 } => {
4690 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
4692 return Err((
4693 "sync.segment_expired".to_owned(),
4694 format!(
4695 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
4696 ),
4697 ));
4698 }
4699 transport
4700 .fetch_blob_url(&url)
4701 .map_err(|e| (e.code, e.message))?
4702 }
4703 };
4704 if blob_id_for(&bytes) != blob_id {
4706 return Err(simple(format!(
4707 "blob content address mismatch for {blob_id}"
4708 )));
4709 }
4710 let now = self.clock_now_ms();
4711 self.conn
4712 .execute(
4713 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
4714 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
4715 )
4716 .map_err(|e| simple(e.to_string()))?;
4717 self.enforce_blob_cache_cap();
4718 self.get_cached_blob(&blob_id)
4719 .map_err(simple)?
4720 .ok_or_else(|| simple("blob cache write failed".to_owned()))
4721 }
4722
4723 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
4724 let _ = self.conn.execute(
4727 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
4728 rusqlite::params![self.clock_now_ms(), blob_id],
4729 );
4730 let mut stmt = self
4731 .conn
4732 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
4733 .map_err(|e| e.to_string())?;
4734 let mut rows = stmt
4735 .query(rusqlite::params![blob_id])
4736 .map_err(|e| e.to_string())?;
4737 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
4738 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
4739 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
4740 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
4741 let mut obj = Map::new();
4742 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
4743 obj.insert("byteLength".to_owned(), Value::from(byte_length));
4744 let mut bytes_obj = Map::new();
4745 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
4746 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
4747 if let Some(mt) = media_type {
4748 obj.insert("mediaType".to_owned(), Value::from(mt));
4749 }
4750 return Ok(Some(Value::Object(obj)));
4751 }
4752 Ok(None)
4753 }
4754
4755 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
4757 let pending: Vec<(String, Option<String>)> = {
4758 let mut stmt = self
4759 .conn
4760 .prepare(
4761 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
4762 )
4763 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4764 let rows = stmt
4765 .query_map([], |row| {
4766 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
4767 })
4768 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4769 rows.filter_map(Result::ok).collect()
4770 };
4771 for (blob_id, media_type) in pending {
4772 let bytes: Option<Vec<u8>> = self
4773 .conn
4774 .query_row(
4775 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
4776 rusqlite::params![blob_id],
4777 |row| row.get(0),
4778 )
4779 .ok();
4780 if let Some(bytes) = bytes {
4781 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
4782 }
4783 let _ = self.conn.execute(
4784 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
4785 rusqlite::params![blob_id],
4786 );
4787 }
4788 Ok(())
4789 }
4790
4791 fn upload_one(
4798 &self,
4799 transport: &mut dyn Transport,
4800 blob_id: &str,
4801 bytes: &[u8],
4802 media_type: Option<&str>,
4803 ) -> Result<(), TransportError> {
4804 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
4805 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
4807 url,
4808 url_expires_at_ms,
4809 } => {
4810 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
4811 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
4812 return Ok(());
4813 }
4814 }
4816 BlobUploadGrant::None => {
4817 }
4819 }
4820 transport.blob_upload(blob_id, bytes, media_type)
4821 }
4822
4823 fn enforce_blob_cache_cap(&self) {
4832 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
4833 return;
4834 };
4835 let mut total: i64 = self
4836 .conn
4837 .query_row(
4838 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
4839 [],
4840 |row| row.get(0),
4841 )
4842 .unwrap_or(0);
4843 if total <= max_bytes {
4844 return;
4845 }
4846 let candidates: Vec<(String, i64)> = {
4847 let Ok(mut stmt) = self.conn.prepare(
4848 "SELECT blob_id, byte_length FROM _syncular_blobs
4849 WHERE refcount = 0
4850 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
4851 ORDER BY last_used_ms ASC, created_at_ms ASC",
4852 ) else {
4853 return;
4854 };
4855 let Ok(rows) = stmt.query_map([], |row| {
4856 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
4857 }) else {
4858 return;
4859 };
4860 rows.filter_map(Result::ok).collect()
4861 };
4862 for (blob_id, byte_length) in candidates {
4863 if total <= max_bytes {
4864 break;
4865 }
4866 let _ = self.conn.execute(
4867 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
4868 rusqlite::params![blob_id],
4869 );
4870 total -= byte_length;
4871 }
4872 }
4873
4874 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
4878 if !self.schema_has_blobs() {
4879 return;
4880 }
4881 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
4882 for table in self.schema.tables.clone() {
4883 let blob_cols: Vec<String> = table
4884 .columns
4885 .iter()
4886 .filter(|c| c.ty == ColumnType::BlobRef)
4887 .map(|c| c.name.clone())
4888 .collect();
4889 for column in blob_cols {
4890 let sql = format!(
4891 "SELECT {} FROM {} WHERE {} IS NOT NULL",
4892 quote_ident(&column),
4893 base_table(&table.name),
4894 quote_ident(&column)
4895 );
4896 let Ok(mut stmt) = self.conn.prepare(&sql) else {
4897 continue;
4898 };
4899 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
4900 continue;
4901 };
4902 for raw in rows.flatten().flatten() {
4903 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
4904 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
4905 *counts.entry(id.to_owned()).or_insert(0) += 1;
4906 }
4907 }
4908 }
4909 }
4910 }
4911 let _ = self
4912 .conn
4913 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
4914 for (blob_id, count) in &counts {
4915 let _ = self.conn.execute(
4916 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
4917 rusqlite::params![count, blob_id],
4918 );
4919 }
4920 if delete_orphans {
4921 let _ = self.conn.execute(
4922 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
4923 [],
4924 );
4925 }
4926 }
4927
4928 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
4933 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
4934 return sql.clone();
4935 }
4936 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
4937 columns.push(quote_ident("_syncular_version"));
4938 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
4939 let sql = format!(
4940 "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
4941 columns.join(", "),
4942 placeholders.join(", ")
4943 );
4944 self.insert_sql
4945 .borrow_mut()
4946 .insert(full_table.to_owned(), sql.clone());
4947 sql
4948 }
4949
4950 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
4951 self.overlay_dirty.set(true);
4952 self.write_row(&base_table(table_name), table_name, row, version)
4953 }
4954
4955 fn write_row(
4956 &self,
4957 full_table: &str,
4958 table_name: &str,
4959 row: &Row,
4960 version: i64,
4961 ) -> Result<(), String> {
4962 let table = self
4963 .schema
4964 .table(table_name)
4965 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4966 let sql = self.insert_row_sql(full_table, table);
4967 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4968 let params = row
4969 .iter()
4970 .map(RowParam::Cell)
4971 .chain(std::iter::once(RowParam::Version(version)));
4972 stmt.execute(rusqlite::params_from_iter(params))
4973 .map_err(|e| e.to_string())?;
4974 Ok(())
4975 }
4976
4977 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
4978 let table = self
4979 .schema
4980 .table(table_name)
4981 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4982 self.overlay_dirty.set(true);
4983 let sql = format!(
4984 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
4985 base_table(table_name),
4986 quote_ident(&table.primary_key)
4987 );
4988 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4989 stmt.execute(rusqlite::params![row_id])
4990 .map_err(|e| e.to_string())?;
4991 Ok(())
4992 }
4993
4994 fn rebuild_overlay_if_dirty(&mut self) {
4997 if self.overlay_dirty.get() {
4998 self.rebuild_overlay();
4999 }
5000 }
5001
5002 fn rebuild_overlay(&mut self) {
5006 self.exec("SAVEPOINT syncular_overlay");
5007 for table in self.schema.tables.clone() {
5008 let visible = visible_table(&table.name);
5009 let base = base_table(&table.name);
5010 self.exec(&format!("DELETE FROM {visible}"));
5011 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
5012 }
5013 for commit in self.outbox.clone() {
5014 for op in &commit.ops {
5015 let Some(table) = self.schema.table(&op.table).cloned() else {
5016 continue;
5017 };
5018 if op.upsert {
5019 let Some(values) = op.values.as_ref() else {
5020 continue;
5021 };
5022 let mut row: Row = Vec::with_capacity(table.columns.len());
5023 let mut ok = true;
5024 for column in &table.columns {
5025 match json_to_column_value(column, values.get(&column.name)) {
5026 Ok(v) => row.push(v),
5027 Err(_) => {
5028 ok = false;
5029 break;
5030 }
5031 }
5032 }
5033 if ok {
5034 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
5035 }
5036 } else {
5037 let sql = format!(
5038 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
5039 visible_table(&table.name),
5040 quote_ident(&table.primary_key)
5041 );
5042 let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
5043 }
5044 }
5045 }
5046 self.exec("RELEASE syncular_overlay");
5047 self.overlay_dirty.set(false);
5048 }
5049
5050 fn exec(&self, sql: &str) {
5051 let _ = self.conn.execute_batch(sql);
5052 }
5053
5054 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
5057 transport
5058 .realtime_connect_for_client(&self.client_id)
5059 .map_err(|e| format!("{}: {}", e.code, e.message))?;
5060 self.realtime_connected = true;
5061 Ok(())
5062 }
5063
5064 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
5065 let _ = transport.realtime_close();
5066 self.realtime_connected = false;
5067 self.presence.clear(); }
5069
5070 pub fn set_presence(
5076 &mut self,
5077 transport: &mut dyn Transport,
5078 scope_key: &str,
5079 doc: Option<&Value>,
5080 ) -> Result<(), String> {
5081 if !self.realtime_connected {
5082 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
5083 }
5084 let text = encode_presence_publish(scope_key, doc);
5085 transport
5086 .realtime_send(&text)
5087 .map_err(|e| format!("{}: {}", e.code, e.message))
5088 }
5089
5090 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
5092 self.presence
5093 .get(scope_key)
5094 .map(|peers| peers.values().cloned().collect())
5095 .unwrap_or_default()
5096 }
5097
5098 fn apply_presence(
5100 &mut self,
5101 scope_key: String,
5102 kind: Option<PresenceKind>,
5103 actor_id: Option<String>,
5104 client_id: Option<String>,
5105 doc: Option<Value>,
5106 error: Option<String>,
5107 ) {
5108 if error.is_some() {
5111 return;
5112 }
5113 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
5114 return;
5115 };
5116 let peer_key = format!("{actor_id} {client_id}");
5117 match kind {
5118 PresenceKind::Leave => {
5119 if let Some(peers) = self.presence.get_mut(&scope_key) {
5120 peers.remove(&peer_key);
5121 if peers.is_empty() {
5122 self.presence.remove(&scope_key);
5123 }
5124 }
5125 }
5126 _ => {
5127 let doc = match doc {
5128 Some(Value::Object(_)) => doc.unwrap(),
5129 _ => return,
5130 };
5131 self.presence.entry(scope_key).or_default().insert(
5132 peer_key,
5133 PresencePeer {
5134 actor_id,
5135 client_id,
5136 doc,
5137 },
5138 );
5139 }
5140 }
5141 }
5142
5143 pub fn on_realtime_text(&mut self, text: &str) {
5145 match parse_control(text) {
5146 Ok(ControlMessage::Hello { requires_sync, .. }) => {
5147 if requires_sync {
5148 self.set_sync_needed(true, true);
5150 }
5151 }
5152 Ok(ControlMessage::Presence {
5153 scope_key,
5154 kind,
5155 actor_id,
5156 client_id,
5157 doc,
5158 error,
5159 ..
5160 }) => {
5161 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
5162 }
5163 Ok(ControlMessage::Wake { .. }) => {
5164 self.set_sync_needed(true, true);
5166 }
5167 _ => {}
5168 }
5169 }
5170
5171 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
5174 if self.stopped {
5175 return;
5176 }
5177 let message = match decode_message(bytes) {
5178 Ok(m) if m.msg_kind == MsgKind::Response => m,
5179 _ => {
5180 self.set_sync_needed(true, true);
5181 return;
5182 }
5183 };
5184 let mut frames = message.frames.into_iter();
5185 let mut applied_cursor: Option<i64> = None;
5186 let mut any_covered = false;
5187 let mut dropped = false;
5188 while let Some(frame) = frames.next() {
5189 let Frame::SubStart {
5190 id,
5191 status,
5192 effective_scopes,
5193 ..
5194 } = frame
5195 else {
5196 continue;
5197 };
5198 let mut body = Vec::new();
5199 let mut next_cursor: Option<i64> = None;
5200 for inner in frames.by_ref() {
5201 match inner {
5202 Frame::SubEnd {
5203 next_cursor: nc, ..
5204 } => {
5205 next_cursor = Some(nc);
5206 break;
5207 }
5208 Frame::Unknown { .. } => {}
5209 other => body.push(other),
5210 }
5211 }
5212 let Some(next_cursor) = next_cursor else {
5213 dropped = true;
5214 break;
5215 };
5216 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
5217 dropped = true;
5218 continue;
5219 };
5220 let sub = &self.subs[sub_index];
5221 if status != SubStatus::Active
5224 || sub.state != SubState::Active
5225 || sub.bootstrap_state.is_some()
5226 || !sub.synced_once
5227 {
5228 dropped = true;
5229 continue;
5230 }
5231 if next_cursor <= sub.cursor {
5232 any_covered = true;
5234 continue;
5235 }
5236 let previous_effective = self.subs[sub_index].effective.clone();
5237 let previous_cursor = self.subs[sub_index].cursor;
5238 if self.begin_observation("syncular_delta").is_err() {
5239 dropped = true;
5240 continue;
5241 }
5242 self.subs[sub_index].effective = Some(effective_scopes);
5243 let mut batch = ChangeAccumulator::default();
5244 let mut failed = false;
5245 for inner in body {
5246 if let Frame::Commit {
5247 tables, changes, ..
5248 } = inner
5249 {
5250 self.record_commit_changes(&mut batch, &tables, &changes);
5251 if self.apply_commit_changes(&tables, &changes).is_err() {
5252 failed = true;
5253 break;
5254 }
5255 }
5256 }
5257 if failed {
5258 self.rollback_observation("syncular_delta");
5259 self.subs[sub_index].effective = previous_effective;
5260 self.subs[sub_index].cursor = previous_cursor;
5261 self.overlay_dirty.set(true);
5262 self.rebuild_overlay();
5263 dropped = true;
5264 continue;
5265 }
5266 let sub = &mut self.subs[sub_index];
5267 sub.cursor = next_cursor;
5268 self.persist_sub(&self.subs[sub_index].clone());
5269 self.rebuild_overlay_if_dirty();
5270 if self.finish_observation("syncular_delta", batch).is_err() {
5271 self.rollback_observation("syncular_delta");
5272 self.subs[sub_index].effective = previous_effective;
5273 self.subs[sub_index].cursor = previous_cursor;
5274 self.overlay_dirty.set(true);
5275 self.rebuild_overlay();
5276 dropped = true;
5277 continue;
5278 }
5279 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
5280 }
5281 if let Some(cursor) = applied_cursor {
5282 self.reconcile_blob_refcounts(false);
5283 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
5285 let _ = transport.realtime_send(&ack);
5286 } else if !any_covered || dropped {
5287 self.set_sync_needed(true, true);
5289 }
5290 }
5291
5292 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
5296 if !self.realtime_connected {
5297 return;
5298 }
5299 let floor = self
5300 .subs
5301 .iter()
5302 .filter(|s| {
5303 s.state == SubState::Active
5304 && s.bootstrap_state.is_none()
5305 && s.synced_once
5306 && s.cursor >= 0
5307 })
5308 .map(|s| s.cursor)
5309 .min();
5310 if let Some(cursor) = floor {
5311 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
5312 let _ = transport.realtime_send(&ack);
5313 }
5314 }
5315}