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};
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, ConflictRecord, CoverageSnapshot, LeaseState,
27 Mutation, PresencePeer, QuerySnapshot, RejectionRecord, RowState, SchemaFloor,
28 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
29 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
30};
31use crate::schema::{parse_schema_json, ClientSchema};
32use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
33use crate::values::{
34 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
35 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
36 scope_map_to_json, sort_scope_map,
37};
38
39const DEFAULT_ACCEPT: u8 = 0b0111;
44const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
45const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
46const ACCEPT_SQLITE: u8 = 1 << 2;
47const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
48
49const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
51const LOCAL_REVISION_KEY: &str = "localRevision";
52const CLIENT_ID_KEY: &str = "clientId";
53const LEASE_STATE_KEY: &str = "leaseState";
54const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
55const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60enum SubState {
61 Active,
62 Revoked,
63 Failed,
64}
65
66#[cfg(test)]
67mod observation_tests {
68 use super::*;
69 use serde_json::json;
70
71 fn client() -> SyncClient {
72 SyncClient::new(
73 "retry-test".to_owned(),
74 &json!({
75 "version": 1,
76 "tables": [{
77 "name": "tasks",
78 "primaryKey": "id",
79 "columns": [
80 { "name": "id", "type": "string", "nullable": false },
81 { "name": "project_id", "type": "string", "nullable": false }
82 ],
83 "scopes": [{ "pattern": "project:{project_id}" }]
84 }]
85 }),
86 ClientLimits::default(),
87 )
88 .expect("test client")
89 }
90
91 #[test]
92 fn background_retry_deadlines_back_off_and_reset() {
93 let mut client = client();
94 client.schedule_background_retry();
95 assert!(matches!(
96 client.drain_sync_intents().as_slice(),
97 [SyncIntent::Background { delay_ms: 250 }]
98 ));
99 client.schedule_background_retry();
100 assert!(matches!(
101 client.drain_sync_intents().as_slice(),
102 [SyncIntent::Background { delay_ms: 500 }]
103 ));
104 client.reset_background_retry();
105 client.schedule_background_retry();
106 assert!(matches!(
107 client.drain_sync_intents().as_slice(),
108 [SyncIntent::Background { delay_ms: 250 }]
109 ));
110 }
111
112 #[test]
113 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
114 let path = std::env::temp_dir().join(format!(
115 "syncular-startup-intent-{}.db",
116 uuid::Uuid::new_v4()
117 ));
118 let schema = json!({
119 "version": 1,
120 "tables": [{
121 "name": "tasks",
122 "primaryKey": "id",
123 "columns": [
124 { "name": "id", "type": "string", "nullable": false },
125 { "name": "project_id", "type": "string", "nullable": false }
126 ],
127 "scopes": [{ "pattern": "project:{project_id}" }]
128 }]
129 });
130
131 {
132 let mut first = SyncClient::open_path_with_identity(
133 None,
134 &schema,
135 ClientLimits::default(),
136 path.to_str().expect("UTF-8 temp path"),
137 )
138 .expect("first open");
139 first
140 .set_window(
141 &WindowBase {
142 table: "tasks".to_owned(),
143 variable: "project_id".to_owned(),
144 fixed_scopes: Vec::new(),
145 params: None,
146 },
147 &["persisted".to_owned()],
148 )
149 .expect("persist window");
150 }
151
152 let mut reopened = SyncClient::open_path_with_identity(
153 None,
154 &schema,
155 ClientLimits::default(),
156 path.to_str().expect("UTF-8 temp path"),
157 )
158 .expect("reopen");
159 assert!(reopened.sync_needed());
160 assert!(matches!(
161 reopened.drain_sync_intents().as_slice(),
162 [SyncIntent::Interactive]
163 ));
164 drop(reopened);
165 std::fs::remove_file(path).expect("remove temp database");
166 }
167
168 #[test]
169 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
170 let path =
171 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
172 let schema = json!({
173 "version": 1,
174 "tables": [{
175 "name": "tasks",
176 "primaryKey": "id",
177 "columns": [
178 { "name": "id", "type": "string", "nullable": false },
179 { "name": "project_id", "type": "string", "nullable": false }
180 ],
181 "scopes": [{ "pattern": "project:{project_id}" }]
182 }]
183 });
184 let mut client = SyncClient::open_path_with_identity(
185 Some("sidecar-client".to_owned()),
186 &schema,
187 ClientLimits::default(),
188 path.to_str().expect("UTF-8 temp path"),
189 )
190 .expect("open owner");
191 let base = WindowBase {
192 table: "tasks".to_owned(),
193 variable: "project_id".to_owned(),
194 fixed_scopes: Vec::new(),
195 params: None,
196 };
197 client
198 .set_window(&base, &["one".to_owned()])
199 .expect("set window");
200 client
201 .mutate(vec![Mutation::Upsert {
202 table: "tasks".to_owned(),
203 values: Map::from_iter([
204 ("id".to_owned(), Value::from("t1")),
205 ("project_id".to_owned(), Value::from("one")),
206 ]),
207 base_version: None,
208 }])
209 .expect("local mutate");
210
211 let coverage = [WindowCoverage {
212 base,
213 units: vec!["one".to_owned(), "missing".to_owned()],
214 }];
215 let owner = client
216 .query_snapshot(
217 "SELECT id, project_id FROM tasks ORDER BY id",
218 &[],
219 &coverage,
220 )
221 .expect("owner snapshot");
222 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
223 let sidecar = reader
224 .query_snapshot(
225 "SELECT id, project_id FROM tasks ORDER BY id",
226 &[],
227 &coverage,
228 )
229 .expect("sidecar snapshot");
230
231 assert_eq!(sidecar.revision, owner.revision);
232 assert_eq!(sidecar.rows, owner.rows);
233 assert_eq!(
234 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
235 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
236 );
237 assert_eq!(sidecar.revision, "2");
238 assert_eq!(sidecar.rows[0]["id"], "t1");
239 assert!(!sidecar.coverage.complete);
240 assert_eq!(sidecar.coverage.pending.len(), 1);
241 assert_eq!(sidecar.coverage.missing.len(), 1);
242
243 drop(reader);
244 drop(client);
245 std::fs::remove_file(path).expect("remove temp database");
246 }
247}
248
249impl SubState {
250 fn name(self) -> &'static str {
251 match self {
252 SubState::Active => "active",
253 SubState::Revoked => "revoked",
254 SubState::Failed => "failed",
255 }
256 }
257
258 fn parse(value: &str) -> Self {
259 match value {
260 "revoked" => Self::Revoked,
261 "failed" => Self::Failed,
262 _ => Self::Active,
263 }
264 }
265}
266
267#[derive(Debug, Clone)]
268struct Subscription {
269 id: String,
270 table: String,
271 requested: Vec<(String, Vec<String>)>,
272 params: Option<String>,
273 cursor: i64,
274 bootstrap_state: Option<String>,
276 state: SubState,
277 reason_code: Option<String>,
278 effective: Option<Vec<(String, Vec<String>)>>,
281 synced_once: bool,
282}
283
284#[derive(Debug, Clone)]
285struct OutboxOp {
286 upsert: bool,
287 table: String,
288 row_id: String,
289 base_version: Option<i64>,
290 values: Option<Map<String, Value>>,
293}
294
295#[derive(Debug, Clone)]
296struct OutboxCommit {
297 client_commit_id: String,
298 ops: Vec<OutboxOp>,
299}
300
301enum SectionError {
304 FailClosed,
305 Abort(String, String),
306}
307
308struct RequestMeta {
309 pushed_ids: Vec<String>,
310 fresh: Vec<(String, bool)>,
313 accept: u8,
314 deferred_commits: usize,
318}
319
320#[derive(Default)]
321struct ChangeAccumulator {
322 tables: BTreeMap<String, Option<BTreeSet<String>>>,
324 windows: BTreeMap<(String, String), BTreeSet<String>>,
325 status: bool,
326 conflicts: bool,
327 rejections: bool,
328}
329
330impl ChangeAccumulator {
331 fn table(&mut self, table: &str) {
332 self.tables.insert(table.to_owned(), None);
333 }
334
335 fn scope(&mut self, table: &str, key: String) {
336 match self.tables.get_mut(table) {
337 Some(None) => {}
338 Some(Some(keys)) => {
339 keys.insert(key);
340 }
341 None => {
342 self.tables
343 .insert(table.to_owned(), Some(BTreeSet::from([key])));
344 }
345 }
346 }
347
348 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
349 self.windows
350 .entry((base_key.to_owned(), table.to_owned()))
351 .or_default()
352 .insert(unit.to_owned());
353 }
354
355 fn touched(&self) -> bool {
356 !self.tables.is_empty()
357 || !self.windows.is_empty()
358 || self.status
359 || self.conflicts
360 || self.rejections
361 }
362}
363
364const PUSH_OPS_PER_REQUEST: usize = 500;
370
371pub struct SyncClient {
372 conn: Connection,
373 schema: ClientSchema,
374 client_id: String,
375 limits: ClientLimits,
376 subs: Vec<Subscription>,
377 outbox: Vec<OutboxCommit>,
378 conflicts: Vec<ConflictRecord>,
379 rejections: Vec<RejectionRecord>,
380 schema_floor: Option<SchemaFloor>,
381 lease_state: Option<LeaseState>,
383 stopped: bool,
385 upgrading: bool,
387 sync_needed: bool,
389 realtime_connected: bool,
390 presence: HashMap<String, HashMap<String, PresencePeer>>,
392 now_ms: Option<i64>,
395 encryption: crate::values::EncryptionConfig,
399 insert_sql: RefCell<HashMap<String, String>>,
405 overlay_dirty: Cell<bool>,
409 change_queue: VecDeque<ClientChangeBatch>,
411 sync_intent_queue: VecDeque<SyncIntent>,
412 retry_delay_ms: u64,
414}
415
416fn quote_ident(name: &str) -> String {
417 format!("\"{}\"", name.replace('"', "\"\""))
418}
419
420fn base_table(name: &str) -> String {
421 quote_ident(&format!("_syncular_base_{name}"))
422}
423
424type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
429
430fn window_base_key(base: &WindowBase) -> String {
431 format!(
432 "{}\0{}\0{}",
433 base.table,
434 base.variable,
435 canonical_scope_json(&base.fixed_scopes)
436 )
437}
438
439fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
441 let mut scopes = base.fixed_scopes.clone();
442 scopes.retain(|(k, _)| k != &base.variable);
443 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
444 scopes
445}
446
447fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
451 let canonical = canonical_scope_json(&unit_scopes(base, unit));
452 let digest = Sha256::digest(canonical.as_bytes());
453 let hex = bytes_to_hex(&digest);
454 format!("w:{}:{}", base.table, &hex[..16])
455}
456
457fn visible_table(name: &str) -> String {
458 quote_ident(name)
459}
460
461fn is_synced_table_name(name: &str) -> bool {
464 if name.starts_with("sqlite_") {
465 return false;
466 }
467 if name.starts_with("_syncular_base_") {
468 return true; }
470 !name.starts_with("_syncular_")
472}
473
474fn blob_id_for(bytes: &[u8]) -> String {
476 let digest = Sha256::digest(bytes);
477 format!("sha256:{}", bytes_to_hex(&digest))
478}
479
480enum RowParam<'a> {
484 Cell(&'a Option<ColumnValue>),
485 Version(i64),
486}
487
488impl rusqlite::ToSql for RowParam<'_> {
489 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
490 Ok(match self {
491 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
492 RowParam::Cell(cell) => match cell {
493 None => ToSqlOutput::Owned(SqlValue::Null),
494 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
495 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
496 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
497 Some(ColumnValue::Boolean(b)) => {
498 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
499 }
500 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
501 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
502 }
503 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
505 ToSqlOutput::Borrowed(ValueRef::Blob(b))
506 }
507 },
508 })
509 }
510}
511
512fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
520 use ssp2::segment::ColumnType;
521 let mismatch = || {
522 Err(format!(
523 "image column {:?} holds a value of the wrong type",
524 column.name
525 ))
526 };
527 match value {
528 ValueRef::Null => {
529 if !column.nullable {
530 return Err(format!(
531 "image column {:?} is NULL but not nullable",
532 column.name
533 ));
534 }
535 Ok(ToSqlOutput::Owned(SqlValue::Null))
536 }
537 ValueRef::Integer(i) => match column.ty {
538 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
539 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
540 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
541 _ => mismatch(),
542 },
543 ValueRef::Real(_) => match column.ty {
544 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
545 _ => mismatch(),
546 },
547 ValueRef::Text(t) => {
548 std::str::from_utf8(t)
549 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
550 match column.ty {
551 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
552 Ok(ToSqlOutput::Borrowed(value))
553 }
554 _ => mismatch(),
555 }
556 }
557 ValueRef::Blob(_) => match column.ty {
558 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
560 _ => mismatch(),
561 },
562 }
563}
564
565fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
566 use rusqlite::types::ValueRef;
567 match value {
568 ValueRef::Null => Value::Null,
569 ValueRef::Integer(i) => match column.ty {
570 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
571 ssp2::segment::ColumnType::Float => {
572 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
573 }
574 _ => Value::from(i),
575 },
576 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
577 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
578 ValueRef::Blob(b) => {
579 let mut map = Map::new();
580 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
581 Value::Object(map)
582 }
583 }
584}
585
586fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
590 Ok(match value {
591 Value::Null => SqlValue::Null,
592 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
593 Value::Number(n) => {
594 if let Some(i) = n.as_i64() {
595 SqlValue::Integer(i)
596 } else if let Some(f) = n.as_f64() {
597 SqlValue::Real(f)
598 } else {
599 return Err(format!("query param number {n} is out of range"));
600 }
601 }
602 Value::String(s) => SqlValue::Text(s.clone()),
603 Value::Object(_) => {
604 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
605 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
606 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
607 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
608 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
609 })?)
610 } else {
611 return Err(
612 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
613 .to_owned(),
614 );
615 }
616 }
617 Value::Array(_) => return Err("query array params are not supported".to_owned()),
618 })
619}
620
621fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
626 use rusqlite::types::ValueRef;
627 match value {
628 ValueRef::Null => Value::Null,
629 ValueRef::Integer(i) => {
630 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
634 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
635 Value::from(i)
636 } else {
637 let mut map = Map::new();
638 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
639 Value::Object(map)
640 }
641 }
642 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
643 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
644 ValueRef::Blob(b) => {
645 let mut map = Map::new();
646 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
647 Value::Object(map)
648 }
649 }
650}
651
652fn query_connection(
653 conn: &Connection,
654 sql: &str,
655 params: &[Value],
656) -> Result<Vec<Map<String, Value>>, String> {
657 crate::query_guard::assert_read_only_query(sql)?;
658 let bound: Vec<SqlValue> = params
659 .iter()
660 .map(json_param_to_sql)
661 .collect::<Result<_, _>>()?;
662 let mut stmt = conn.prepare(sql).map_err(|e| e.to_string())?;
663 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
664 let bound_refs: Vec<&dyn rusqlite::ToSql> =
665 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
666 let mut sql_rows = stmt
667 .query(bound_refs.as_slice())
668 .map_err(|e| e.to_string())?;
669 let mut out = Vec::new();
670 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
671 let mut record = Map::new();
672 for (i, name) in column_names.iter().enumerate() {
673 let value = row.get_ref(i).map_err(|e| e.to_string())?;
674 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
675 }
676 out.push(record);
677 }
678 Ok(out)
679}
680
681fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
682 let mut stmt = conn
683 .prepare(
684 "SELECT windows.unit, subscriptions.state_json
685 FROM _syncular_windows AS windows
686 JOIN _syncular_subscriptions AS subscriptions
687 ON subscriptions.id = windows.sub_id
688 WHERE windows.base = ?1
689 ORDER BY windows.unit ASC",
690 )
691 .map_err(|error| error.to_string())?;
692 let rows = stmt
693 .query_map(rusqlite::params![window_base_key(base)], |row| {
694 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
695 })
696 .map_err(|error| error.to_string())?;
697 let mut units = Vec::new();
698 let mut pending = Vec::new();
699 for row in rows {
700 let (unit, raw) = row.map_err(|error| error.to_string())?;
701 let state: Value = serde_json::from_str(&raw)
702 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
703 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
704 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
705 || state
706 .get("bootstrapState")
707 .is_some_and(|value| !value.is_null());
708 if is_pending {
709 pending.push(unit.clone());
710 }
711 units.push(unit);
712 }
713 Ok(WindowState { units, pending })
714}
715
716fn snapshot_connection(
717 conn: &Connection,
718 sql: &str,
719 params: &[Value],
720 coverage: &[WindowCoverage],
721) -> Result<QuerySnapshot, String> {
722 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
723 .map_err(|error| error.to_string())?;
724 let result = (|| {
725 let revision = conn
726 .query_row(
727 "SELECT value FROM _syncular_meta WHERE key = ?1",
728 rusqlite::params![LOCAL_REVISION_KEY],
729 |row| row.get::<_, String>(0),
730 )
731 .ok()
732 .and_then(|value| value.parse::<u64>().ok())
733 .unwrap_or(0);
734 let rows = query_connection(conn, sql, params)?;
735 let mut pending = Vec::new();
736 let mut missing = Vec::new();
737 for requested in coverage {
738 let base_key = window_base_key(&requested.base);
739 let state = persisted_window_state(conn, &requested.base)?;
740 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
741 let reference = WindowUnitRef {
742 base_key: base_key.clone(),
743 unit: unit.clone(),
744 };
745 if !state.units.iter().any(|held| held == &unit) {
746 missing.push(reference);
747 } else if state.pending.iter().any(|held| held == &unit) {
748 pending.push(reference);
749 }
750 }
751 }
752 Ok(QuerySnapshot {
753 revision: revision.to_string(),
754 rows,
755 coverage: CoverageSnapshot {
756 complete: pending.is_empty() && missing.is_empty(),
757 pending,
758 missing,
759 },
760 })
761 })();
762 match result {
763 Ok(snapshot) => {
764 conn.execute_batch("RELEASE syncular_snapshot_read")
765 .map_err(|error| error.to_string())?;
766 Ok(snapshot)
767 }
768 Err(error) => {
769 let _ = conn.execute_batch(
770 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
771 );
772 Err(error)
773 }
774 }
775}
776
777pub struct FileQuerySnapshotReader {
782 path: String,
783 conn: Option<Connection>,
784}
785
786impl FileQuerySnapshotReader {
787 #[must_use]
788 pub fn new(path: impl Into<String>) -> Self {
789 Self {
790 path: path.into(),
791 conn: None,
792 }
793 }
794
795 fn connection(&mut self) -> Result<&Connection, String> {
796 if self.conn.is_none() {
797 let conn = Connection::open_with_flags(
798 &self.path,
799 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
800 )
801 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
802 conn.busy_timeout(std::time::Duration::from_millis(250))
803 .map_err(|error| error.to_string())?;
804 self.conn = Some(conn);
805 }
806 self.conn
807 .as_ref()
808 .ok_or_else(|| "read sidecar connection missing".to_owned())
809 }
810
811 pub fn query_snapshot(
812 &mut self,
813 sql: &str,
814 params: &[Value],
815 coverage: &[WindowCoverage],
816 ) -> Result<QuerySnapshot, String> {
817 snapshot_connection(self.connection()?, sql, params, coverage)
818 }
819}
820
821impl SyncClient {
822 pub fn new_with_identity(
823 client_id: Option<String>,
824 schema_json: &Value,
825 limits: ClientLimits,
826 ) -> Result<Self, String> {
827 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
828 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
829 Self::with_connection(resolved, schema_json, limits, conn)
830 }
831
832 pub fn new(
833 client_id: String,
834 schema_json: &Value,
835 limits: ClientLimits,
836 ) -> Result<Self, String> {
837 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
838 Self::with_connection(client_id, schema_json, limits, conn)
839 }
840
841 pub fn open_path(
847 client_id: String,
848 schema_json: &Value,
849 limits: ClientLimits,
850 path: &str,
851 ) -> Result<Self, String> {
852 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
853 Self::with_connection(client_id, schema_json, limits, conn)
854 }
855
856 pub fn open_path_with_identity(
857 client_id: Option<String>,
858 schema_json: &Value,
859 limits: ClientLimits,
860 path: &str,
861 ) -> Result<Self, String> {
862 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
863 conn.busy_timeout(std::time::Duration::from_millis(250))
869 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
870 conn.pragma_update(None, "journal_mode", "WAL")
871 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
872 let persisted = conn
873 .query_row(
874 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
875 [],
876 |row| row.get::<_, String>(0),
877 )
878 .ok();
879 let resolved = persisted
880 .clone()
881 .or(client_id.clone())
882 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
883 if let (Some(existing), Some(requested)) = (persisted, client_id) {
884 if existing != requested {
885 return Err(format!(
886 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
887 ));
888 }
889 }
890 Self::with_connection(resolved, schema_json, limits, conn)
891 }
892
893 #[must_use]
894 pub fn client_id(&self) -> &str {
895 &self.client_id
896 }
897
898 pub fn with_connection(
905 client_id: String,
906 schema_json: &Value,
907 limits: ClientLimits,
908 conn: Connection,
909 ) -> Result<Self, String> {
910 let schema = parse_schema_json(schema_json)?;
911 let mut client = SyncClient {
912 conn,
913 schema,
914 client_id,
915 limits,
916 subs: Vec::new(),
917 outbox: Vec::new(),
918 conflicts: Vec::new(),
919 rejections: Vec::new(),
920 schema_floor: None,
921 lease_state: None,
922 stopped: false,
923 upgrading: false,
924 sync_needed: false,
925 realtime_connected: false,
926 presence: HashMap::new(),
927 now_ms: None,
928 encryption: crate::values::EncryptionConfig::default(),
929 insert_sql: RefCell::new(HashMap::new()),
930 overlay_dirty: Cell::new(false),
931 change_queue: VecDeque::new(),
932 sync_intent_queue: VecDeque::new(),
933 retry_delay_ms: 250,
934 };
935 client
939 .conn
940 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
941 client.create_tables()?;
942 match client.get_meta(CLIENT_ID_KEY) {
943 Some(existing) if existing != client.client_id => {
944 return Err(format!(
945 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
946 client.client_id
947 ));
948 }
949 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
950 _ => {}
951 }
952 client.restore_persisted_state()?;
953 let marker = client
954 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
955 .and_then(|value| value.parse::<i32>().ok());
956 if marker != Some(client.schema.version) {
957 client.run_schema_reset()?;
958 } else if !client.outbox.is_empty() {
959 client.overlay_dirty.set(true);
962 client.rebuild_overlay();
963 }
964 client.enqueue_startup_sync_if_needed();
970 Ok(client)
971 }
972
973 pub fn set_now_ms(&mut self, now_ms: i64) {
976 self.now_ms = Some(now_ms);
977 }
978
979 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
983 self.encryption = encryption;
984 }
985
986 fn clock_now_ms(&self) -> i64 {
987 self.now_ms.unwrap_or_else(|| {
988 std::time::SystemTime::now()
989 .duration_since(std::time::UNIX_EPOCH)
990 .map(|d| d.as_millis() as i64)
991 .unwrap_or(0)
992 })
993 }
994
995 fn create_tables(&self) -> Result<(), String> {
996 self.create_synced_tables()?;
997 self.conn
999 .execute_batch(
1000 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
1001 seq INTEGER PRIMARY KEY AUTOINCREMENT,
1002 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
1003 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
1004 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
1005 CREATE TABLE IF NOT EXISTS _syncular_meta (
1006 key TEXT PRIMARY KEY, value TEXT NOT NULL);
1007 CREATE TABLE IF NOT EXISTS _syncular_windows (
1008 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
1009 PRIMARY KEY (base, unit));
1010 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
1011 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
1012 effective_scopes TEXT NOT NULL);",
1013 )
1014 .map_err(|e| e.to_string())?;
1015 if self.get_meta(LOCAL_SCHEMA_VERSION_KEY).is_none() {
1018 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1019 }
1020 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
1021 self.set_meta(LOCAL_REVISION_KEY, "0");
1022 }
1023 if self.schema_has_blobs() {
1028 self.conn
1029 .execute_batch(
1030 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
1031 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
1032 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
1033 created_at_ms INTEGER NOT NULL,
1034 last_used_ms INTEGER NOT NULL DEFAULT 0);
1035 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
1036 media_type TEXT, created_at_ms INTEGER NOT NULL);",
1037 )
1038 .map_err(|e| e.to_string())?;
1039 let _ = self.conn.execute_batch(
1043 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
1044 );
1045 }
1046 Ok(())
1047 }
1048
1049 fn schema_has_blobs(&self) -> bool {
1051 self.schema
1052 .tables
1053 .iter()
1054 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
1055 }
1056
1057 fn get_meta(&self, key: &str) -> Option<String> {
1060 self.conn
1061 .query_row(
1062 "SELECT value FROM _syncular_meta WHERE key = ?1",
1063 rusqlite::params![key],
1064 |row| row.get::<_, String>(0),
1065 )
1066 .ok()
1067 }
1068
1069 fn set_meta(&self, key: &str, value: &str) {
1070 let _ = self.conn.execute(
1071 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
1072 rusqlite::params![key, value],
1073 );
1074 }
1075
1076 fn delete_meta(&self, key: &str) {
1077 let _ = self.conn.execute(
1078 "DELETE FROM _syncular_meta WHERE key = ?1",
1079 rusqlite::params![key],
1080 );
1081 }
1082
1083 fn restore_persisted_state(&mut self) -> Result<(), String> {
1084 self.subs = {
1085 let mut stmt = self
1086 .conn
1087 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
1088 .map_err(|error| error.to_string())?;
1089 let rows = stmt
1090 .query_map([], |row| {
1091 Ok((
1092 row.get::<_, String>(0)?,
1093 row.get::<_, String>(1)?,
1094 row.get::<_, String>(2)?,
1095 ))
1096 })
1097 .map_err(|error| error.to_string())?;
1098 let mut subscriptions = Vec::new();
1099 for row in rows {
1100 let (id, table, raw) = row.map_err(|error| error.to_string())?;
1101 let state: Value = serde_json::from_str(&raw)
1102 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
1103 let requested = json_to_scope_map(
1104 state.get("requested").unwrap_or(&Value::Object(Map::new())),
1105 )?;
1106 let effective = state
1107 .get("effectiveScopes")
1108 .filter(|value| !value.is_null())
1109 .map(json_to_scope_map)
1110 .transpose()?;
1111 subscriptions.push(Subscription {
1112 id,
1113 table,
1114 requested,
1115 params: state
1116 .get("params")
1117 .and_then(Value::as_str)
1118 .map(str::to_owned),
1119 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
1120 bootstrap_state: state
1121 .get("bootstrapState")
1122 .and_then(Value::as_str)
1123 .map(str::to_owned),
1124 state: SubState::parse(
1125 state
1126 .get("status")
1127 .and_then(Value::as_str)
1128 .unwrap_or("active"),
1129 ),
1130 reason_code: state
1131 .get("reasonCode")
1132 .and_then(Value::as_str)
1133 .map(str::to_owned),
1134 effective,
1135 synced_once: state
1136 .get("syncedOnce")
1137 .and_then(Value::as_bool)
1138 .unwrap_or(false),
1139 });
1140 }
1141 subscriptions
1142 };
1143
1144 self.outbox = {
1145 let mut stmt = self
1146 .conn
1147 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
1148 .map_err(|error| error.to_string())?;
1149 let rows = stmt
1150 .query_map([], |row| {
1151 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1152 })
1153 .map_err(|error| error.to_string())?;
1154 let mut commits = Vec::new();
1155 for row in rows {
1156 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
1157 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
1158 format!("invalid persisted outbox {client_commit_id:?}: {error}")
1159 })?;
1160 let mut ops = Vec::with_capacity(entries.len());
1161 for entry in entries {
1162 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
1163 ops.push(OutboxOp {
1164 upsert: op == "upsert",
1165 table: entry
1166 .get("table")
1167 .and_then(Value::as_str)
1168 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
1169 .to_owned(),
1170 row_id: entry
1171 .get("rowId")
1172 .and_then(Value::as_str)
1173 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
1174 .to_owned(),
1175 base_version: entry.get("baseVersion").and_then(Value::as_i64),
1176 values: entry.get("values").and_then(Value::as_object).cloned(),
1177 });
1178 }
1179 commits.push(OutboxCommit {
1180 client_commit_id,
1181 ops,
1182 });
1183 }
1184 commits
1185 };
1186
1187 self.lease_state = self
1188 .get_meta(LEASE_STATE_KEY)
1189 .map(|raw| serde_json::from_str(&raw))
1190 .transpose()
1191 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
1192 self.schema_floor = self
1193 .get_meta(SCHEMA_FLOOR_KEY)
1194 .map(|raw| serde_json::from_str(&raw))
1195 .transpose()
1196 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
1197 self.stopped = self.schema_floor.is_some();
1198 Ok(())
1199 }
1200
1201 #[must_use]
1202 pub fn local_revision(&self) -> u64 {
1203 self.get_meta(LOCAL_REVISION_KEY)
1204 .and_then(|value| value.parse().ok())
1205 .unwrap_or(0)
1206 }
1207
1208 #[must_use]
1209 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
1210 SyncStatusSnapshot {
1211 outbox: self.outbox.len(),
1212 upgrading: self.upgrading,
1213 lease_state: self.lease_state.clone(),
1214 schema_floor: self.schema_floor.clone(),
1215 sync_needed: self.sync_needed,
1216 }
1217 }
1218
1219 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
1220 self.change_queue.drain(..).collect()
1221 }
1222
1223 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
1224 self.sync_intent_queue.drain(..).collect()
1225 }
1226
1227 fn schedule_background_retry(&mut self) {
1228 self.sync_intent_queue.push_back(SyncIntent::Background {
1229 delay_ms: self.retry_delay_ms,
1230 });
1231 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
1232 }
1233
1234 fn reset_background_retry(&mut self) {
1235 self.retry_delay_ms = 250;
1236 }
1237
1238 fn retryable_transport_code(code: &str) -> bool {
1239 code == "transport.failed" || code == "sync.transport_failed"
1240 }
1241
1242 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
1243 if self.sync_needed != value {
1244 if self.begin_observation("syncular_status").is_ok() {
1245 self.sync_needed = value;
1246 let batch = ChangeAccumulator {
1247 status: true,
1248 ..ChangeAccumulator::default()
1249 };
1250 if self.finish_observation("syncular_status", batch).is_err() {
1251 self.rollback_observation("syncular_status");
1252 }
1253 } else {
1254 self.sync_needed = value;
1255 }
1256 }
1257 if value && interactive {
1258 self.sync_intent_queue.push_back(SyncIntent::Interactive);
1259 }
1260 }
1261
1262 fn begin_observation(&self, name: &str) -> Result<(), String> {
1263 self.conn
1264 .execute_batch(&format!("SAVEPOINT {name}"))
1265 .map_err(|error| error.to_string())
1266 }
1267
1268 fn rollback_observation(&self, name: &str) {
1269 let _ = self
1270 .conn
1271 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
1272 }
1273
1274 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
1275 if !batch.touched() {
1276 self.conn
1277 .execute_batch(&format!("RELEASE {name}"))
1278 .map_err(|error| error.to_string())?;
1279 return Ok(());
1280 }
1281 let revision = self
1282 .local_revision()
1283 .checked_add(1)
1284 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
1285 self.conn
1286 .execute(
1287 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
1288 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
1289 )
1290 .map_err(|error| error.to_string())?;
1291 let status = batch.status.then(|| self.status_snapshot());
1292 let event = ClientChangeBatch {
1293 revision: revision.to_string(),
1294 tables: batch
1295 .tables
1296 .into_iter()
1297 .map(|(table, scope_keys)| TableChange {
1298 table,
1299 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
1300 })
1301 .collect(),
1302 windows: batch
1303 .windows
1304 .into_iter()
1305 .map(|((base_key, table), units)| WindowChange {
1306 base_key,
1307 table,
1308 units: units.into_iter().collect(),
1309 })
1310 .collect(),
1311 status,
1312 conflicts_changed: batch.conflicts,
1313 rejections_changed: batch.rejections,
1314 };
1315 self.conn
1316 .execute_batch(&format!("RELEASE {name}"))
1317 .map_err(|error| error.to_string())?;
1318 self.change_queue.push_back(event);
1319 Ok(())
1320 }
1321
1322 fn record_scope_map(
1323 &self,
1324 batch: &mut ChangeAccumulator,
1325 table_name: &str,
1326 scopes: &[(String, Vec<String>)],
1327 ) {
1328 let Some(table) = self.schema.table(table_name) else {
1329 return;
1330 };
1331 for (variable, values) in scopes {
1332 let Some(scope) = table
1333 .scope_variables
1334 .iter()
1335 .find(|scope| &scope.variable == variable)
1336 else {
1337 continue;
1338 };
1339 for value in values {
1340 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1341 }
1342 }
1343 }
1344
1345 fn record_row_scopes(
1347 &self,
1348 batch: &mut ChangeAccumulator,
1349 table_name: &str,
1350 row_id: &str,
1351 base: bool,
1352 ) -> bool {
1353 let Some(table) = self.schema.table(table_name) else {
1354 return false;
1355 };
1356 if table.scope_variables.is_empty() {
1357 return false;
1358 }
1359 let columns = table
1360 .scope_variables
1361 .iter()
1362 .map(|scope| quote_ident(&scope.column))
1363 .collect::<Vec<_>>()
1364 .join(", ");
1365 let full_table = if base {
1366 base_table(table_name)
1367 } else {
1368 visible_table(table_name)
1369 };
1370 let sql = format!(
1371 "SELECT {columns} FROM {full_table} WHERE CAST({} AS TEXT) = ?1 LIMIT 1",
1372 quote_ident(&table.primary_key)
1373 );
1374 let Ok(mut stmt) = self.conn.prepare(&sql) else {
1375 return false;
1376 };
1377 let values = stmt.query_row(rusqlite::params![row_id], |row| {
1378 let mut values = Vec::with_capacity(table.scope_variables.len());
1379 for index in 0..table.scope_variables.len() {
1380 values.push(row.get::<_, Option<String>>(index)?);
1381 }
1382 Ok(values)
1383 });
1384 let Ok(values) = values else {
1385 return false;
1386 };
1387 let mut recorded = false;
1388 for (scope, value) in table.scope_variables.iter().zip(values) {
1389 if let Some(value) = value {
1390 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1391 recorded = true;
1392 }
1393 }
1394 recorded
1395 }
1396
1397 fn record_commit_changes(
1398 &self,
1399 batch: &mut ChangeAccumulator,
1400 tables: &[String],
1401 changes: &[ssp2::model::Change],
1402 ) {
1403 for change in changes {
1404 let Some(table_name) = tables.get(change.table_index as usize) else {
1405 continue;
1406 };
1407 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
1408 if let Some(table) = self.schema.table(table_name) {
1409 for (variable, value) in &change.scopes {
1410 if let Some(scope) = table
1411 .scope_variables
1412 .iter()
1413 .find(|scope| &scope.variable == variable)
1414 {
1415 batch.scope(table_name, format!("{}:{value}", scope.prefix));
1416 precise = true;
1417 }
1418 }
1419 }
1420 if !precise {
1421 batch.table(table_name);
1422 }
1423 }
1424 }
1425
1426 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
1427 if effective.is_empty() {
1428 return false;
1429 }
1430 let Some(table) = self.schema.table(table_name) else {
1431 return false;
1432 };
1433 let mut clauses = Vec::new();
1434 let mut params = Vec::new();
1435 for (variable, values) in effective {
1436 let Some(column) = table.scope_column(variable) else {
1437 return false;
1438 };
1439 if values.is_empty() {
1440 return false;
1441 }
1442 let placeholders = values
1443 .iter()
1444 .map(|value| {
1445 params.push(SqlValue::Text(value.clone()));
1446 "?"
1447 })
1448 .collect::<Vec<_>>()
1449 .join(", ");
1450 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
1451 }
1452 let sql = format!(
1453 "SELECT 1 FROM {} WHERE {} LIMIT 1",
1454 base_table(table_name),
1455 clauses.join(" AND ")
1456 );
1457 self.conn
1458 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
1459 .is_ok()
1460 }
1461
1462 pub fn upgrading(&self) -> bool {
1464 self.upgrading
1465 }
1466
1467 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
1473 let new_schema = parse_schema_json(schema_json)?;
1474 let marker: Option<i32> = self
1475 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
1476 .and_then(|v| v.parse().ok());
1477 self.schema = new_schema;
1478 if marker != Some(self.schema.version) {
1479 self.run_schema_reset()?;
1480 }
1481 self.enqueue_startup_sync_if_needed();
1485 Ok(())
1486 }
1487
1488 fn enqueue_startup_sync_if_needed(&mut self) {
1489 let startup_work = !self.stopped
1490 && (!self.outbox.is_empty()
1491 || self.subs.iter().any(|sub| sub.state == SubState::Active));
1492 if startup_work {
1493 self.sync_needed = true;
1494 self.sync_intent_queue.push_back(SyncIntent::Interactive);
1495 }
1496 }
1497
1498 fn run_schema_reset(&mut self) -> Result<(), String> {
1504 self.begin_observation("syncular_schema_reset")?;
1505 let mut batch = ChangeAccumulator::default();
1506 let result = self.run_schema_reset_observed(&mut batch);
1507 if let Err(error) = result {
1508 self.rollback_observation("syncular_schema_reset");
1509 return Err(error);
1510 }
1511 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
1512 self.rollback_observation("syncular_schema_reset");
1513 return Err(error);
1514 }
1515 Ok(())
1516 }
1517
1518 fn run_schema_reset_observed(&mut self, batch: &mut ChangeAccumulator) -> Result<(), String> {
1519 self.upgrading = true;
1520 batch.status = true;
1521 for table in &self.schema.tables {
1522 batch.table(&table.name);
1523 }
1524 for (base_key, unit, table) in self.load_registered_window_units() {
1525 batch.window(&base_key, &table, &unit);
1526 }
1527 self.insert_sql.borrow_mut().clear();
1529 self.overlay_dirty.set(true);
1530 let existing: Vec<String> = {
1536 let mut stmt = self
1537 .conn
1538 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
1539 .map_err(|e| e.to_string())?;
1540 let rows = stmt
1541 .query_map([], |row| row.get::<_, String>(0))
1542 .map_err(|e| e.to_string())?;
1543 rows.filter_map(Result::ok)
1544 .filter(|name| is_synced_table_name(name))
1545 .collect()
1546 };
1547 for name in &existing {
1548 if let Some(table) = name.strip_prefix("_syncular_base_") {
1549 batch.table(table);
1550 } else if !name.starts_with("_syncular_") {
1551 batch.table(name);
1552 }
1553 }
1554 for name in existing {
1555 let _ = self
1556 .conn
1557 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), []);
1558 }
1559 self.create_synced_tables()?;
1561 for sub in &mut self.subs {
1563 sub.cursor = -1;
1564 sub.bootstrap_state = None;
1565 sub.effective = None;
1566 sub.state = SubState::Active;
1567 sub.reason_code = None;
1568 sub.synced_once = false;
1569 }
1570 let subs = self.subs.clone();
1571 for sub in &subs {
1572 self.persist_sub(sub);
1573 }
1574 self.stopped = false;
1576 self.schema_floor = None;
1577 self.delete_meta(SCHEMA_FLOOR_KEY);
1578 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
1580 if self.drop_incompatible_outbox() {
1584 batch.rejections = true;
1585 batch.status = true;
1586 }
1587 self.rebuild_overlay();
1589 Ok(())
1590 }
1591
1592 fn drop_incompatible_outbox(&mut self) -> bool {
1596 let schema = &self.schema;
1597 let mut incompatible: Vec<String> = Vec::new();
1598 self.outbox.retain(|commit| {
1599 let bad = commit.ops.iter().any(|op| {
1600 if !op.upsert {
1601 return false;
1602 }
1603 match schema.table(&op.table) {
1604 None => true,
1605 Some(table) => op.values.as_ref().is_some_and(|values| {
1606 values
1607 .keys()
1608 .any(|key| !table.columns.iter().any(|c| &c.name == key))
1609 }),
1610 }
1611 });
1612 if bad {
1613 incompatible.push(commit.client_commit_id.clone());
1614 }
1615 !bad
1616 });
1617 let changed = !incompatible.is_empty();
1618 for client_commit_id in incompatible {
1619 self.persist_outbox_delete(&client_commit_id);
1620 self.rejections.push(RejectionRecord {
1621 client_commit_id,
1622 op_index: 0,
1623 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
1624 retryable: false,
1625 });
1626 }
1627 changed
1628 }
1629
1630 fn create_synced_tables(&self) -> Result<(), String> {
1633 for table in &self.schema.tables {
1634 for (full, index_prefix) in [
1638 (base_table(&table.name), "_syncular_base_"),
1639 (visible_table(&table.name), ""),
1640 ] {
1641 let mut cols: Vec<String> =
1642 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
1643 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
1644 let sql = format!(
1645 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
1646 cols.join(", "),
1647 quote_ident(&table.primary_key)
1648 );
1649 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
1650 for index in &table.indexes {
1654 let unique = if index.unique { "UNIQUE " } else { "" };
1655 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
1656 let cols_sql = index
1657 .columns
1658 .iter()
1659 .map(|c| quote_ident(c))
1660 .collect::<Vec<_>>()
1661 .join(", ");
1662 let index_sql = format!(
1663 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
1664 );
1665 self.conn
1666 .execute(&index_sql, [])
1667 .map_err(|e| e.to_string())?;
1668 }
1669 }
1670 }
1671 Ok(())
1672 }
1673
1674 fn persist_sub(&self, sub: &Subscription) {
1677 let state = serde_json::json!({
1678 "requested": scope_map_to_json(&sub.requested),
1679 "params": sub.params,
1680 "cursor": sub.cursor,
1681 "bootstrapState": sub.bootstrap_state,
1682 "status": sub.state.name(),
1683 "reasonCode": sub.reason_code,
1684 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1685 "syncedOnce": sub.synced_once,
1686 });
1687 let _ = self.conn.execute(
1688 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
1689 rusqlite::params![sub.id, sub.table, state.to_string()],
1690 );
1691 }
1692
1693 fn persist_outbox_insert(&self, commit: &OutboxCommit) {
1694 let ops: Vec<Value> = commit
1695 .ops
1696 .iter()
1697 .map(|op| {
1698 serde_json::json!({
1699 "op": if op.upsert { "upsert" } else { "delete" },
1700 "table": op.table,
1701 "rowId": op.row_id,
1702 "baseVersion": op.base_version,
1703 "values": op.values.clone().map(Value::Object),
1704 })
1705 })
1706 .collect();
1707 let _ = self.conn.execute(
1708 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
1709 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
1710 );
1711 }
1712
1713 fn persist_outbox_delete(&self, client_commit_id: &str) {
1714 let _ = self.conn.execute(
1715 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
1716 rusqlite::params![client_commit_id],
1717 );
1718 }
1719
1720 pub fn subscribe(
1723 &mut self,
1724 id: String,
1725 table: String,
1726 scopes: Vec<(String, Vec<String>)>,
1727 params: Option<String>,
1728 ) -> Result<(), String> {
1729 if self.schema.table(&table).is_none() {
1730 return Err(format!("unknown table {table:?}"));
1731 }
1732 let sub = Subscription {
1733 id: id.clone(),
1734 table,
1735 requested: scopes,
1736 params,
1737 cursor: -1,
1738 bootstrap_state: None,
1739 state: SubState::Active,
1740 reason_code: None,
1741 effective: None,
1742 synced_once: false,
1743 };
1744 self.persist_sub(&sub);
1745 if let Some(existing) = self.subs.iter_mut().find(|s| s.id == id) {
1746 *existing = sub;
1747 } else {
1748 self.subs.push(sub);
1749 }
1750 Ok(())
1751 }
1752
1753 pub fn unsubscribe(&mut self, id: &str) {
1754 self.subs.retain(|s| s.id != id);
1755 let _ = self.conn.execute(
1756 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
1757 rusqlite::params![id],
1758 );
1759 }
1760
1761 pub fn set_window(
1769 &mut self,
1770 base: &WindowBase,
1771 units: &[String],
1772 ) -> Result<CommandEffects, String> {
1773 let table = self
1774 .schema
1775 .table(&base.table)
1776 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
1777 if table.scope_column(&base.variable).is_none() {
1778 return Err(format!(
1779 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
1780 base.table, base.variable
1781 ));
1782 }
1783 let base_key = window_base_key(base);
1784 let wanted: std::collections::HashSet<&String> = units.iter().collect();
1785 let live = self.load_window_units(&base_key);
1786 self.begin_observation("syncular_window")?;
1787 let mut batch = ChangeAccumulator::default();
1788 let mut changed = false;
1789
1790 for unit in units {
1792 if live.iter().any(|(u, _)| u == unit) {
1793 continue;
1794 }
1795 let sub_id = derive_sub_id(base, unit);
1796 self.delete_pending_evict(&sub_id);
1797 self.insert_window_unit(&base_key, unit, &sub_id);
1798 self.subscribe(
1799 sub_id,
1800 base.table.clone(),
1801 unit_scopes(base, unit),
1802 base.params.clone(),
1803 )?;
1804 batch.window(&base_key, &base.table, unit);
1805 changed = true;
1806 }
1807
1808 for (unit, sub_id) in live {
1810 if wanted.contains(&unit) {
1811 continue;
1812 }
1813 let effective = self
1814 .subs
1815 .iter()
1816 .find(|sub| sub.id == sub_id)
1817 .and_then(|sub| sub.effective.clone())
1818 .unwrap_or_else(|| unit_scopes(base, &unit));
1819 self.record_scope_map(&mut batch, &base.table, &effective);
1820 batch.window(&base_key, &base.table, &unit);
1821 self.evict_unit(&base_key, base, &unit, &sub_id);
1822 changed = true;
1823 }
1824 if let Err(error) = self.finish_observation("syncular_window", batch) {
1825 self.rollback_observation("syncular_window");
1826 return Err(error);
1827 }
1828 Ok(if changed {
1829 CommandEffects::interactive()
1830 } else {
1831 CommandEffects::none()
1832 })
1833 }
1834
1835 pub fn window_state(&self, base: &WindowBase) -> WindowState {
1840 let mut units = Vec::new();
1841 let mut pending = Vec::new();
1842 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
1843 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
1844 Some(sub) => {
1845 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
1846 }
1847 None => true,
1848 };
1849 if is_pending {
1850 pending.push(unit.clone());
1851 }
1852 units.push(unit);
1853 }
1854 WindowState { units, pending }
1855 }
1856
1857 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
1858 let mut stmt = match self
1859 .conn
1860 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
1861 {
1862 Ok(stmt) => stmt,
1863 Err(_) => return Vec::new(),
1864 };
1865 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
1866 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1867 });
1868 match rows {
1869 Ok(rows) => rows.filter_map(Result::ok).collect(),
1870 Err(_) => Vec::new(),
1871 }
1872 }
1873
1874 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
1875 let mut stmt = match self.conn.prepare(
1876 "SELECT windows.base, windows.unit, subscriptions.tbl
1877 FROM _syncular_windows AS windows
1878 JOIN _syncular_subscriptions AS subscriptions
1879 ON subscriptions.id = windows.sub_id
1880 ORDER BY windows.base, windows.unit",
1881 ) {
1882 Ok(stmt) => stmt,
1883 Err(_) => return Vec::new(),
1884 };
1885 let rows = stmt.query_map([], |row| {
1886 Ok((
1887 row.get::<_, String>(0)?,
1888 row.get::<_, String>(1)?,
1889 row.get::<_, String>(2)?,
1890 ))
1891 });
1892 match rows {
1893 Ok(rows) => rows.filter_map(Result::ok).collect(),
1894 Err(_) => Vec::new(),
1895 }
1896 }
1897
1898 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
1899 self.conn
1900 .query_row(
1901 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
1902 rusqlite::params![sub_id],
1903 |row| Ok((row.get(0)?, row.get(1)?)),
1904 )
1905 .ok()
1906 }
1907
1908 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
1909 let _ = self.conn.execute(
1910 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
1911 rusqlite::params![base_key, unit, sub_id],
1912 );
1913 }
1914
1915 fn delete_window_unit(&self, base_key: &str, unit: &str) {
1916 let _ = self.conn.execute(
1917 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
1918 rusqlite::params![base_key, unit],
1919 );
1920 }
1921
1922 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
1929 let effective = self
1930 .subs
1931 .iter()
1932 .find(|s| s.id == sub_id)
1933 .and_then(|s| s.effective.clone())
1934 .unwrap_or_else(|| unit_scopes(base, unit));
1935 let pinned = self.pinned_row_ids(&base.table);
1936 let deferred = self
1937 .evict_scope_rows(&base.table, &effective, &pinned)
1938 .unwrap_or(false);
1939 self.delete_window_unit(base_key, unit);
1940 self.unsubscribe(sub_id);
1941 if deferred {
1942 self.save_pending_evict(sub_id, &base.table, &effective);
1943 } else {
1944 self.delete_pending_evict(sub_id);
1945 }
1946 self.rebuild_overlay();
1947 }
1948
1949 fn evict_scope_rows(
1953 &mut self,
1954 table_name: &str,
1955 effective: &[(String, Vec<String>)],
1956 pinned: &std::collections::HashSet<String>,
1957 ) -> Result<bool, ()> {
1958 if effective.is_empty() {
1959 return Ok(false);
1960 }
1961 let table = self.schema.table(table_name).ok_or(())?.clone();
1962 let mut clauses = Vec::new();
1963 let mut params: Vec<SqlValue> = Vec::new();
1964 for (variable, values) in effective {
1965 let column = table.scope_column(variable).ok_or(())?;
1966 let placeholders: Vec<String> = values
1967 .iter()
1968 .map(|v| {
1969 params.push(SqlValue::Text(v.clone()));
1970 "?".to_owned()
1971 })
1972 .collect();
1973 clauses.push(format!(
1974 "{} IN ({})",
1975 quote_ident(column),
1976 placeholders.join(", ")
1977 ));
1978 }
1979 let mut sql = format!(
1980 "DELETE FROM {} WHERE {}",
1981 base_table(table_name),
1982 clauses.join(" AND ")
1983 );
1984 if !pinned.is_empty() {
1985 let pk = quote_ident(&table.primary_key);
1986 let holes: Vec<String> = pinned
1987 .iter()
1988 .map(|id| {
1989 params.push(SqlValue::Text(id.clone()));
1990 "?".to_owned()
1991 })
1992 .collect();
1993 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
1994 }
1995 self.overlay_dirty.set(true);
1996 self.conn
1997 .execute(&sql, rusqlite::params_from_iter(params))
1998 .map_err(|_| ())?;
1999 if pinned.is_empty() {
2000 return Ok(false);
2001 }
2002 let mut where_params: Vec<SqlValue> = Vec::new();
2005 let mut where_clauses = Vec::new();
2006 for (variable, values) in effective {
2007 let column = table.scope_column(variable).ok_or(())?;
2008 let placeholders: Vec<String> = values
2009 .iter()
2010 .map(|v| {
2011 where_params.push(SqlValue::Text(v.clone()));
2012 "?".to_owned()
2013 })
2014 .collect();
2015 where_clauses.push(format!(
2016 "{} IN ({})",
2017 quote_ident(column),
2018 placeholders.join(", ")
2019 ));
2020 }
2021 let pk = quote_ident(&table.primary_key);
2022 let select = format!(
2023 "SELECT {} FROM {} WHERE {}",
2024 pk,
2025 base_table(table_name),
2026 where_clauses.join(" AND ")
2027 );
2028 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
2029 let survivors: Vec<String> = stmt
2030 .query_map(rusqlite::params_from_iter(where_params), |row| {
2031 row.get::<_, String>(0)
2032 })
2033 .map_err(|_| ())?
2034 .filter_map(Result::ok)
2035 .collect();
2036 Ok(survivors.iter().any(|id| pinned.contains(id)))
2037 }
2038
2039 fn drain_pending_evictions(&mut self) {
2043 let pending = self.load_pending_evictions();
2044 if pending.is_empty() {
2045 return;
2046 }
2047 for (sub_id, table_name, effective) in pending {
2048 if self.schema.table(&table_name).is_none() {
2049 self.delete_pending_evict(&sub_id);
2050 continue;
2051 }
2052 let pinned = self.pinned_row_ids(&table_name);
2053 let deferred = self
2054 .evict_scope_rows(&table_name, &effective, &pinned)
2055 .unwrap_or(false);
2056 if !deferred {
2057 self.delete_pending_evict(&sub_id);
2058 }
2059 }
2060 self.rebuild_overlay_if_dirty();
2061 }
2062
2063 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
2066 let mut pinned = std::collections::HashSet::new();
2067 for commit in &self.outbox {
2068 for op in &commit.ops {
2069 if op.table == table {
2070 pinned.insert(op.row_id.clone());
2071 }
2072 }
2073 }
2074 pinned
2075 }
2076
2077 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
2078 let _ = self.conn.execute(
2079 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
2080 VALUES (?1, ?2, ?3)",
2081 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
2082 );
2083 }
2084
2085 fn delete_pending_evict(&self, sub_id: &str) {
2086 let _ = self.conn.execute(
2087 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
2088 rusqlite::params![sub_id],
2089 );
2090 }
2091
2092 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
2093 let mut stmt = match self
2094 .conn
2095 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
2096 {
2097 Ok(stmt) => stmt,
2098 Err(_) => return Vec::new(),
2099 };
2100 let rows = stmt.query_map([], |row| {
2101 Ok((
2102 row.get::<_, String>(0)?,
2103 row.get::<_, String>(1)?,
2104 row.get::<_, String>(2)?,
2105 ))
2106 });
2107 let mut out = Vec::new();
2108 if let Ok(rows) = rows {
2109 for entry in rows.filter_map(Result::ok) {
2110 let (sub_id, table, json) = entry;
2111 if let Ok(value) = serde_json::from_str::<Value>(&json) {
2112 if let Ok(effective) = json_to_scope_map(&value) {
2113 out.push((sub_id, table, effective));
2114 }
2115 }
2116 }
2117 }
2118 out
2119 }
2120
2121 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
2123 if mutations.is_empty() {
2124 return Err("a commit must contain at least one operation (§6.1)".to_owned());
2125 }
2126 let mut ops = Vec::with_capacity(mutations.len());
2127 for mutation in mutations {
2128 match mutation {
2129 Mutation::Upsert {
2130 table,
2131 values,
2132 base_version,
2133 } => {
2134 let schema_table = self
2135 .schema
2136 .table(&table)
2137 .ok_or_else(|| format!("unknown table {table:?}"))?;
2138 let values = normalize_values_casing(schema_table, values)?;
2142 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
2143 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
2146 ops.push(OutboxOp {
2147 upsert: true,
2148 table,
2149 row_id,
2150 base_version,
2151 values: Some(values),
2152 });
2153 }
2154 Mutation::Delete {
2155 table,
2156 row_id,
2157 base_version,
2158 } => {
2159 if self.schema.table(&table).is_none() {
2160 return Err(format!("unknown table {table:?}"));
2161 }
2162 ops.push(OutboxOp {
2163 upsert: false,
2164 table,
2165 row_id,
2166 base_version,
2167 values: None,
2168 });
2169 }
2170 }
2171 }
2172 let commit = OutboxCommit {
2173 client_commit_id: uuid::Uuid::new_v4().to_string(),
2174 ops,
2175 };
2176 self.begin_observation("syncular_mutation")?;
2177 let mut batch = ChangeAccumulator::default();
2178 for op in &commit.ops {
2179 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
2180 if let Some(values) = &op.values {
2181 if let Some(table) = self.schema.table(&op.table) {
2182 for scope in &table.scope_variables {
2183 if let Some(Value::String(value)) = values.get(&scope.column) {
2184 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
2185 precise = true;
2186 }
2187 }
2188 }
2189 }
2190 if !precise {
2191 batch.table(&op.table);
2192 }
2193 }
2194 self.persist_outbox_insert(&commit);
2195 let id = commit.client_commit_id.clone();
2196 self.outbox.push(commit);
2197 self.overlay_dirty.set(true);
2198 self.rebuild_overlay();
2199 batch.status = true;
2200 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
2201 self.rollback_observation("syncular_mutation");
2202 return Err(error);
2203 }
2204 Ok(id)
2205 }
2206
2207 pub fn patch(
2212 &mut self,
2213 table: &str,
2214 row_id: &str,
2215 partial: Map<String, Value>,
2216 base_version: Option<i64>,
2217 ) -> Result<String, String> {
2218 let schema_table = self
2219 .schema
2220 .table(table)
2221 .ok_or_else(|| format!("unknown table {table:?}"))?;
2222 let partial = normalize_values_casing(schema_table, partial)?;
2223 let mut values = self
2224 .read_rows(table)?
2225 .into_iter()
2226 .find(|row| row.row_id == row_id)
2227 .map(|row| row.values)
2228 .ok_or_else(|| {
2229 format!(
2230 "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
2231 )
2232 })?;
2233 values.extend(partial);
2234 self.mutate(vec![Mutation::Upsert {
2235 table: table.to_owned(),
2236 values,
2237 base_version,
2238 }])
2239 }
2240
2241 pub fn pending_commit_ids(&self) -> Vec<String> {
2242 self.outbox
2243 .iter()
2244 .map(|c| c.client_commit_id.clone())
2245 .collect()
2246 }
2247
2248 pub fn conflicts(&self) -> &[ConflictRecord] {
2249 &self.conflicts
2250 }
2251
2252 pub fn rejections(&self) -> &[RejectionRecord] {
2253 &self.rejections
2254 }
2255
2256 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
2257 self.schema_floor.as_ref()
2258 }
2259
2260 pub fn lease_state(&self) -> Option<&LeaseState> {
2262 self.lease_state.as_ref()
2263 }
2264
2265 fn record_lease_error(&mut self, code: &str) {
2268 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
2269 return;
2270 }
2271 let mut next = self.lease_state.clone().unwrap_or_default();
2272 next.error_code = Some(code.to_owned());
2273 self.set_lease_state(Some(next));
2274 }
2275
2276 fn set_lease_state(&mut self, next: Option<LeaseState>) {
2277 if self.lease_state == next {
2278 return;
2279 }
2280 if self.begin_observation("syncular_lease").is_err() {
2281 return;
2282 }
2283 self.lease_state = next;
2284 if let Some(lease) = &self.lease_state {
2285 if let Ok(json) = serde_json::to_string(lease) {
2286 self.set_meta(LEASE_STATE_KEY, &json);
2287 }
2288 } else {
2289 self.delete_meta(LEASE_STATE_KEY);
2290 }
2291 let batch = ChangeAccumulator {
2292 status: true,
2293 ..ChangeAccumulator::default()
2294 };
2295 if self.finish_observation("syncular_lease", batch).is_err() {
2296 self.rollback_observation("syncular_lease");
2297 }
2298 }
2299
2300 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
2301 if self.schema_floor == next {
2302 return;
2303 }
2304 if self.begin_observation("syncular_schema_floor").is_err() {
2305 return;
2306 }
2307 self.schema_floor = next;
2308 self.stopped = self.schema_floor.is_some();
2309 if let Some(floor) = &self.schema_floor {
2310 if let Ok(json) = serde_json::to_string(floor) {
2311 self.set_meta(SCHEMA_FLOOR_KEY, &json);
2312 }
2313 } else {
2314 self.delete_meta(SCHEMA_FLOOR_KEY);
2315 }
2316 let batch = ChangeAccumulator {
2317 status: true,
2318 ..ChangeAccumulator::default()
2319 };
2320 if self
2321 .finish_observation("syncular_schema_floor", batch)
2322 .is_err()
2323 {
2324 self.rollback_observation("syncular_schema_floor");
2325 }
2326 }
2327
2328 fn set_upgrading(&mut self, value: bool) {
2329 if self.upgrading == value {
2330 return;
2331 }
2332 if self.begin_observation("syncular_upgrading").is_err() {
2333 return;
2334 }
2335 self.upgrading = value;
2336 let batch = ChangeAccumulator {
2337 status: true,
2338 ..ChangeAccumulator::default()
2339 };
2340 if self
2341 .finish_observation("syncular_upgrading", batch)
2342 .is_err()
2343 {
2344 self.rollback_observation("syncular_upgrading");
2345 }
2346 }
2347
2348 pub fn sync_needed(&self) -> bool {
2349 self.sync_needed
2350 }
2351
2352 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
2353 let sub = self.subs.iter().find(|s| s.id == id)?;
2354 Some(SubscriptionStateView {
2355 id: sub.id.clone(),
2356 table: sub.table.clone(),
2357 status: sub.state.name().to_owned(),
2358 cursor: sub.cursor,
2359 has_resume_token: sub.bootstrap_state.is_some(),
2360 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
2361 reason_code: sub.reason_code.clone(),
2362 })
2363 }
2364
2365 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
2366 let schema_table = self
2367 .schema
2368 .table(table)
2369 .ok_or_else(|| format!("unknown table {table:?}"))?;
2370 let sql = format!(
2371 "SELECT * FROM {} ORDER BY {} ASC",
2372 visible_table(table),
2373 quote_ident(&schema_table.primary_key)
2374 );
2375 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
2376 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
2377 let mut out = Vec::new();
2378 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
2379 let mut values = Map::new();
2380 for (i, column) in schema_table.columns.iter().enumerate() {
2381 let value = row.get_ref(i).map_err(|e| e.to_string())?;
2382 values.insert(column.name.clone(), sql_ref_to_json(column, value));
2383 }
2384 let version: i64 = row
2385 .get(schema_table.columns.len())
2386 .map_err(|e| e.to_string())?;
2387 let row_id = match values.get(&schema_table.primary_key) {
2388 Some(Value::String(s)) => s.clone(),
2389 Some(Value::Number(n)) => n.to_string(),
2390 Some(Value::Bool(b)) => b.to_string(),
2391 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
2392 };
2393 out.push(RowState {
2394 row_id,
2395 version,
2396 values,
2397 });
2398 }
2399 Ok(out)
2400 }
2401
2402 #[cfg(feature = "crdt-yjs")]
2418 fn crdt_column_bytes(
2419 &self,
2420 table: &str,
2421 row_id: &str,
2422 column: &str,
2423 ) -> Result<Option<Vec<u8>>, String> {
2424 let schema_table = self
2425 .schema
2426 .table(table)
2427 .ok_or_else(|| format!("unknown table {table:?}"))?;
2428 let col = schema_table
2429 .columns
2430 .iter()
2431 .find(|c| c.name == column)
2432 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
2433 if col.ty != ColumnType::Crdt {
2434 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
2435 }
2436 let sql = format!(
2437 "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
2438 quote_ident(column),
2439 visible_table(table),
2440 quote_ident(&schema_table.primary_key)
2441 );
2442 let bytes: Option<Vec<u8>> = self
2443 .conn
2444 .query_row(&sql, rusqlite::params![row_id], |row| {
2445 row.get::<_, Option<Vec<u8>>>(0)
2446 })
2447 .map_err(|e| match e {
2448 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
2449 other => other.to_string(),
2450 })?;
2451 Ok(bytes)
2452 }
2453
2454 #[cfg(feature = "crdt-yjs")]
2458 pub fn crdt_text(
2459 &self,
2460 table: &str,
2461 row_id: &str,
2462 column: &str,
2463 name: &str,
2464 ) -> Result<String, String> {
2465 let bytes = self
2466 .crdt_column_bytes(table, row_id, column)?
2467 .unwrap_or_default();
2468 crate::crdt::text(&bytes, name)
2469 }
2470
2471 #[cfg(feature = "crdt-yjs")]
2475 pub fn crdt_insert_text(
2476 &mut self,
2477 table: &str,
2478 row_id: &str,
2479 column: &str,
2480 name: &str,
2481 index: u32,
2482 value: &str,
2483 ) -> Result<String, String> {
2484 let current = self
2485 .crdt_column_bytes(table, row_id, column)?
2486 .unwrap_or_default();
2487 let update = crate::crdt::insert_text(¤t, name, index, value)?;
2488 self.crdt_push_update(table, row_id, column, &update)
2489 }
2490
2491 #[cfg(feature = "crdt-yjs")]
2494 pub fn crdt_delete_text(
2495 &mut self,
2496 table: &str,
2497 row_id: &str,
2498 column: &str,
2499 name: &str,
2500 index: u32,
2501 len: u32,
2502 ) -> Result<String, String> {
2503 let current = self
2504 .crdt_column_bytes(table, row_id, column)?
2505 .unwrap_or_default();
2506 let update = crate::crdt::delete_text(¤t, name, index, len)?;
2507 self.crdt_push_update(table, row_id, column, &update)
2508 }
2509
2510 #[cfg(feature = "crdt-yjs")]
2515 pub fn crdt_apply_update(
2516 &mut self,
2517 table: &str,
2518 row_id: &str,
2519 column: &str,
2520 update: &[u8],
2521 ) -> Result<String, String> {
2522 let current = self
2523 .crdt_column_bytes(table, row_id, column)?
2524 .unwrap_or_default();
2525 let next = crate::crdt::apply_update(¤t, update)?;
2526 self.crdt_push_update(table, row_id, column, &next)
2527 }
2528
2529 #[cfg(feature = "crdt-yjs")]
2535 fn crdt_push_update(
2536 &mut self,
2537 table: &str,
2538 row_id: &str,
2539 column: &str,
2540 crdt_bytes: &[u8],
2541 ) -> Result<String, String> {
2542 let schema_table = self
2543 .schema
2544 .table(table)
2545 .ok_or_else(|| format!("unknown table {table:?}"))?
2546 .clone();
2547 let mut values: Map<String, Value> = self
2550 .read_rows(table)?
2551 .into_iter()
2552 .find(|r| r.row_id == row_id)
2553 .map(|r| r.values)
2554 .unwrap_or_else(|| {
2555 let mut map = Map::new();
2556 map.insert(
2557 schema_table.primary_key.clone(),
2558 Value::from(row_id.to_owned()),
2559 );
2560 map
2561 });
2562 let mut bytes_obj = Map::new();
2564 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
2565 values.insert(column.to_owned(), Value::Object(bytes_obj));
2566 self.mutate(vec![Mutation::Upsert {
2567 table: table.to_owned(),
2568 values,
2569 base_version: None,
2570 }])
2571 }
2572
2573 pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
2588 query_connection(&self.conn, sql, params)
2589 }
2590
2591 pub fn query_snapshot(
2593 &mut self,
2594 sql: &str,
2595 params: &[Value],
2596 coverage: &[WindowCoverage],
2597 ) -> Result<QuerySnapshot, String> {
2598 snapshot_connection(&self.conn, sql, params, coverage)
2599 }
2600
2601 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
2604 let mut frames = vec![Frame::ReqHeader {
2605 client_id: self.client_id.clone(),
2606 schema_version: self.schema.version,
2607 }];
2608 let mut pushed_ids = Vec::new();
2609 let mut ops_in_request = 0usize;
2610 let mut deferred_commits = 0usize;
2611 for (index, commit) in self.outbox.iter().enumerate() {
2612 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
2617 deferred_commits = self.outbox.len() - index;
2618 break;
2619 }
2620 ops_in_request += commit.ops.len();
2621 let operations = commit
2622 .ops
2623 .iter()
2624 .map(|op| {
2625 let payload = op.values.as_ref().and_then(|values| {
2626 let table = self.schema.table(&op.table)?;
2627 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
2631 });
2632 ssp2::model::Operation {
2633 table: op.table.clone(),
2634 row_id: op.row_id.clone(),
2635 op: if op.upsert { Op::Upsert } else { Op::Delete },
2636 base_version: op.base_version,
2637 payload,
2638 }
2639 })
2640 .collect();
2641 frames.push(Frame::PushCommit {
2642 client_commit_id: commit.client_commit_id.clone(),
2643 operations,
2644 });
2645 pushed_ids.push(commit.client_commit_id.clone());
2646 }
2647 let accept = self.limits.accept.unwrap_or(if url_capable {
2650 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
2651 } else {
2652 DEFAULT_ACCEPT
2653 });
2654 frames.push(Frame::PullHeader {
2655 limit_commits: self.limits.limit_commits.unwrap_or(0),
2656 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
2657 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
2658 accept,
2659 });
2660 let mut fresh = Vec::new();
2661 for sub in &self.subs {
2662 if sub.state != SubState::Active {
2663 continue;
2664 }
2665 let mut scopes = sub.requested.clone();
2666 sort_scope_map(&mut scopes);
2667 frames.push(Frame::Subscription {
2668 id: sub.id.clone(),
2669 table: sub.table.clone(),
2670 scopes,
2671 params: sub.params.clone().map(RawJson),
2672 cursor: sub.cursor,
2673 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
2674 });
2675 fresh.push((
2676 sub.id.clone(),
2677 sub.cursor < 0 && sub.bootstrap_state.is_none(),
2678 ));
2679 }
2680 let message = Message {
2681 msg_kind: MsgKind::Request,
2682 frames,
2683 };
2684 (
2685 message,
2686 RequestMeta {
2687 pushed_ids,
2688 fresh,
2689 accept,
2690 deferred_commits,
2691 },
2692 )
2693 }
2694
2695 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
2698 if self.stopped {
2699 return SyncOutcome::Ok(SyncReport {
2702 schema_floor: self.schema_floor.clone(),
2703 ..SyncReport::default()
2704 });
2705 }
2706 self.set_sync_needed(false, false);
2709 if self.schema_has_blobs() {
2712 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
2713 if Self::retryable_transport_code(&code) {
2714 self.schedule_background_retry();
2715 }
2716 return SyncOutcome::Failed {
2717 error_code: code,
2718 message,
2719 };
2720 }
2721 }
2722 let (message, meta) = self.build_request(transport.supports_url_fetch());
2723 let request_bytes = encode_message(&message);
2724 let round = if self.realtime_connected {
2728 transport.realtime_sync(&request_bytes)
2729 } else {
2730 transport.sync(&request_bytes)
2731 };
2732 let response_bytes = match round {
2733 Ok(bytes) => bytes,
2734 Err(TransportError { code, message }) => {
2735 self.record_lease_error(&code);
2738 if Self::retryable_transport_code(&code) {
2739 self.schedule_background_retry();
2740 }
2741 return SyncOutcome::Failed {
2742 error_code: code,
2743 message,
2744 };
2745 }
2746 };
2747 let response = match decode_message(&response_bytes) {
2748 Ok(message) => message,
2749 Err(error) => {
2750 return SyncOutcome::Failed {
2753 error_code: error.code.as_str().to_owned(),
2754 message: error.detail,
2755 };
2756 }
2757 };
2758 if response.msg_kind != MsgKind::Response {
2759 return SyncOutcome::Failed {
2760 error_code: "sync.invalid_request".to_owned(),
2761 message: "expected a response message".to_owned(),
2762 };
2763 }
2764 let outcome = self.process_response(transport, response, &meta);
2765 match &outcome {
2766 SyncOutcome::Ok(_) => self.reset_background_retry(),
2767 SyncOutcome::Failed { error_code, .. }
2768 if Self::retryable_transport_code(error_code) =>
2769 {
2770 self.schedule_background_retry();
2771 }
2772 SyncOutcome::Failed { .. } => {}
2773 }
2774 if meta.deferred_commits > 0 {
2775 self.set_sync_needed(true, true);
2778 }
2779 outcome
2780 }
2781
2782 pub fn sync_until_idle(
2783 &mut self,
2784 transport: &mut dyn Transport,
2785 max_rounds: Option<u32>,
2786 ) -> SyncOutcome {
2787 let rounds = max_rounds.unwrap_or(12).max(1);
2788 let mut aggregate = SyncReport::default();
2789 for _ in 0..rounds {
2790 match self.sync(transport) {
2791 SyncOutcome::Failed {
2792 error_code,
2793 message,
2794 } => {
2795 return SyncOutcome::Failed {
2796 error_code,
2797 message,
2798 };
2799 }
2800 SyncOutcome::Ok(report) => {
2801 aggregate.pushed += report.pushed;
2802 aggregate.applied.extend(report.applied.iter().cloned());
2803 aggregate.rejected.extend(report.rejected.iter().cloned());
2804 aggregate.retryable.extend(report.retryable.iter().cloned());
2805 aggregate.conflicts += report.conflicts;
2806 aggregate.commits_applied += report.commits_applied;
2807 aggregate.segment_rows_applied += report.segment_rows_applied;
2808 aggregate.bootstrapping = report.bootstrapping.clone();
2809 aggregate.resets.extend(report.resets.iter().cloned());
2810 aggregate.revoked.extend(report.revoked.iter().cloned());
2811 aggregate.failed.extend(report.failed.iter().cloned());
2812 if report.schema_floor.is_some() {
2813 aggregate.schema_floor = report.schema_floor.clone();
2814 }
2815 let more = !report.bootstrapping.is_empty()
2821 || report.commits_applied > 0
2822 || report.segment_rows_applied > 0
2823 || !report.resets.is_empty()
2824 || self.sync_needed;
2825 if !more {
2826 break;
2827 }
2828 }
2829 }
2830 }
2831 SyncOutcome::Ok(aggregate)
2832 }
2833
2834 fn process_response(
2835 &mut self,
2836 transport: &mut dyn Transport,
2837 response: Message,
2838 meta: &RequestMeta,
2839 ) -> SyncOutcome {
2840 let mut report = SyncReport {
2841 pushed: meta.pushed_ids.len() as u32,
2842 ..SyncReport::default()
2843 };
2844 let mut frames = response.frames.into_iter();
2845 match frames.next() {
2846 Some(Frame::RespHeader {
2847 required_schema_version,
2848 latest_schema_version,
2849 }) => {
2850 if let Some(required) = required_schema_version {
2851 let floor = SchemaFloor {
2854 required_schema_version: Some(required),
2855 latest_schema_version,
2856 };
2857 self.set_schema_floor(Some(floor.clone()));
2858 report.schema_floor = Some(floor);
2859 return SyncOutcome::Ok(report);
2860 }
2861 }
2862 _ => {
2863 return SyncOutcome::Failed {
2864 error_code: "sync.invalid_request".to_owned(),
2865 message: "response does not start with RESP_HEADER".to_owned(),
2866 };
2867 }
2868 }
2869
2870 let mut failure: Option<(String, String)> = None;
2871 while let Some(frame) = frames.next() {
2872 match frame {
2873 Frame::PushResult {
2874 client_commit_id,
2875 status,
2876 commit_seq: _,
2877 results,
2878 } => {
2879 self.handle_push_result(&client_commit_id, status, &results, &mut report);
2880 }
2881 Frame::SubStart {
2882 id,
2883 status,
2884 reason_code,
2885 effective_scopes,
2886 bootstrap: _,
2887 } => {
2888 let mut body = Vec::new();
2889 let mut sub_end: Option<(i64, Option<String>)> = None;
2890 for inner in frames.by_ref() {
2891 match inner {
2892 Frame::SubEnd {
2893 next_cursor,
2894 bootstrap_state,
2895 } => {
2896 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
2897 break;
2898 }
2899 Frame::Unknown { .. } => {}
2900 other => body.push(other),
2901 }
2902 }
2903 let Some((next_cursor, bootstrap_state)) = sub_end else {
2904 failure = Some((
2905 "sync.invalid_request".to_owned(),
2906 "subscription section without SUB_END".to_owned(),
2907 ));
2908 break;
2909 };
2910 if let Err(SectionError::Abort(code, message)) = self.process_section(
2911 transport,
2912 &id,
2913 status,
2914 &reason_code,
2915 effective_scopes,
2916 body,
2917 next_cursor,
2918 bootstrap_state,
2919 meta,
2920 &mut report,
2921 ) {
2922 failure = Some((code, message));
2923 break;
2924 }
2925 }
2926 Frame::Lease {
2927 lease_id,
2928 expires_at_ms,
2929 } => {
2930 self.set_lease_state(Some(LeaseState {
2933 lease_id: Some(lease_id),
2934 expires_at_ms: Some(expires_at_ms),
2935 error_code: None,
2936 }));
2937 }
2938 Frame::Error { code, message, .. } => {
2939 failure = Some((code, message));
2942 break;
2943 }
2944 Frame::Unknown { .. } => {}
2945 _ => {
2946 failure = Some((
2947 "sync.invalid_request".to_owned(),
2948 "unexpected frame in response".to_owned(),
2949 ));
2950 break;
2951 }
2952 }
2953 }
2954
2955 if self.overlay_dirty.get() {
2958 self.rebuild_overlay();
2959 }
2960 self.reconcile_blob_refcounts(false);
2967
2968 if let Some((error_code, message)) = failure {
2969 return SyncOutcome::Failed {
2970 error_code,
2971 message,
2972 };
2973 }
2974 self.drain_pending_evictions();
2977 self.ack_after_pull(transport);
2978 if self.upgrading && report.bootstrapping.is_empty() {
2981 self.set_upgrading(false);
2982 }
2983 SyncOutcome::Ok(report)
2984 }
2985
2986 fn handle_push_result(
2989 &mut self,
2990 client_commit_id: &str,
2991 status: PushStatus,
2992 results: &[OpResult],
2993 report: &mut SyncReport,
2994 ) {
2995 let Some(index) = self
2996 .outbox
2997 .iter()
2998 .position(|c| c.client_commit_id == client_commit_id)
2999 else {
3000 return;
3001 };
3002 if self.begin_observation("syncular_push_result").is_err() {
3003 return;
3004 }
3005 let mut batch = ChangeAccumulator::default();
3006 let operations = self.outbox[index].ops.clone();
3007 self.overlay_dirty.set(true);
3010 match status {
3011 PushStatus::Applied | PushStatus::Cached => {
3012 report.applied.push(client_commit_id.to_owned());
3015 self.outbox.remove(index);
3016 self.persist_outbox_delete(client_commit_id);
3017 batch.status = true;
3018 }
3019 PushStatus::Rejected => {
3020 let terminating = results
3021 .iter()
3022 .find(|r| !matches!(r, OpResult::Applied { .. }));
3023 match terminating {
3024 Some(OpResult::Conflict {
3025 op_index,
3026 code,
3027 message: _,
3028 server_version,
3029 server_row,
3030 }) => {
3031 let commit = &self.outbox[index];
3032 let (table, row_id) = commit
3033 .ops
3034 .get(*op_index as usize)
3035 .map(|op| (op.table.clone(), op.row_id.clone()))
3036 .unwrap_or_default();
3037 let server_row_json = self
3038 .schema
3039 .table(&table)
3040 .and_then(|t| {
3041 decode_row_bytes(t, server_row, &self.encryption)
3042 .ok()
3043 .map(|row| (t, row))
3044 })
3045 .map(|(t, row)| {
3046 let mut map = Map::new();
3047 for (i, column) in t.columns.iter().enumerate() {
3048 map.insert(
3049 column.name.clone(),
3050 column_value_to_json(row.get(i).unwrap_or(&None)),
3051 );
3052 }
3053 map
3054 })
3055 .unwrap_or_default();
3056 self.conflicts.push(ConflictRecord {
3057 client_commit_id: client_commit_id.to_owned(),
3058 op_index: *op_index,
3059 table,
3060 row_id,
3061 code: code.clone(),
3062 server_version: *server_version,
3063 server_row: server_row_json,
3064 });
3065 batch.conflicts = true;
3066 report.conflicts += 1;
3067 report.rejected.push(client_commit_id.to_owned());
3068 self.outbox.remove(index);
3069 self.persist_outbox_delete(client_commit_id);
3070 batch.status = true;
3071 }
3072 Some(OpResult::Error {
3073 op_index,
3074 code,
3075 message: _,
3076 retryable,
3077 }) => {
3078 if code == "sync.idempotency_cache_miss" {
3079 report.retryable.push(client_commit_id.to_owned());
3082 } else {
3083 self.rejections.push(RejectionRecord {
3084 client_commit_id: client_commit_id.to_owned(),
3085 op_index: *op_index,
3086 code: code.clone(),
3087 retryable: *retryable,
3088 });
3089 batch.rejections = true;
3090 report.rejected.push(client_commit_id.to_owned());
3091 self.outbox.remove(index);
3092 self.persist_outbox_delete(client_commit_id);
3093 batch.status = true;
3094 }
3095 }
3096 _ => {
3097 report.rejected.push(client_commit_id.to_owned());
3098 self.outbox.remove(index);
3099 self.persist_outbox_delete(client_commit_id);
3100 batch.status = true;
3101 }
3102 }
3103 }
3104 }
3105 if batch.status {
3106 for operation in &operations {
3107 if !self.record_row_scopes(&mut batch, &operation.table, &operation.row_id, false) {
3108 batch.table(&operation.table);
3109 }
3110 }
3111 self.rebuild_overlay_if_dirty();
3112 }
3113 if self
3114 .finish_observation("syncular_push_result", batch)
3115 .is_err()
3116 {
3117 self.rollback_observation("syncular_push_result");
3118 }
3119 }
3120
3121 #[allow(clippy::too_many_arguments)]
3124 fn process_section(
3125 &mut self,
3126 transport: &mut dyn Transport,
3127 id: &str,
3128 status: SubStatus,
3129 reason_code: &str,
3130 effective_scopes: Vec<(String, Vec<String>)>,
3131 body: Vec<Frame>,
3132 next_cursor: i64,
3133 bootstrap_state: Option<String>,
3134 meta: &RequestMeta,
3135 report: &mut SyncReport,
3136 ) -> Result<(), SectionError> {
3137 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3138 return Ok(()); };
3140 match status {
3141 SubStatus::Revoked => {
3142 self.begin_observation("syncular_revocation")
3143 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3144 let mut batch = ChangeAccumulator::default();
3145 let registered = self.window_unit_by_sub_id(id);
3146 let (table, effective) = {
3148 let sub = &self.subs[sub_index];
3149 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3150 };
3151 let purged = self.purge_scope_rows(&table, &effective);
3152 match purged {
3153 Ok(()) => {
3154 self.record_scope_map(&mut batch, &table, &effective);
3155 let sub = &mut self.subs[sub_index];
3156 sub.state = SubState::Revoked;
3157 sub.reason_code = Some(if reason_code.is_empty() {
3158 "sync.scope_revoked".to_owned()
3159 } else {
3160 reason_code.to_owned()
3161 });
3162 report.revoked.push(id.to_owned());
3163 let doomed_effective = effective;
3164 let sub_table = table;
3165 self.persist_sub(&self.subs[sub_index].clone());
3166 let outbox_before = self.outbox.len();
3167 self.drop_doomed_outbox(&sub_table, &doomed_effective);
3168 if self.outbox.len() != outbox_before {
3169 batch.status = true;
3170 }
3171 self.reconcile_blob_refcounts(true);
3174 }
3175 Err(()) => {
3176 let sub = &mut self.subs[sub_index];
3179 sub.state = SubState::Failed;
3180 sub.reason_code = Some("sync.scope_revoked".to_owned());
3181 report.failed.push(id.to_owned());
3182 self.persist_sub(&self.subs[sub_index].clone());
3183 }
3184 }
3185 if let Some((base_key, unit)) = registered {
3186 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3187 }
3188 self.rebuild_overlay_if_dirty();
3189 self.finish_observation("syncular_revocation", batch)
3190 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3191 Ok(())
3192 }
3193 SubStatus::Reset => {
3194 self.begin_observation("syncular_reset")
3195 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3196 let mut batch = ChangeAccumulator::default();
3197 let registered = self.window_unit_by_sub_id(id);
3198 let sub = &mut self.subs[sub_index];
3201 sub.cursor = -1;
3202 sub.bootstrap_state = None;
3203 report.resets.push(id.to_owned());
3204 self.persist_sub(&self.subs[sub_index].clone());
3205 if let Some((base_key, unit)) = registered {
3206 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3207 }
3208 self.finish_observation("syncular_reset", batch)
3209 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3210 Ok(())
3211 }
3212 SubStatus::Active => {
3213 let fresh = meta
3214 .fresh
3215 .iter()
3216 .find(|(fid, _)| fid == id)
3217 .map(|(_, f)| *f)
3218 .unwrap_or(false);
3219 let was_pending = self.subs[sub_index].cursor < 0
3220 || self.subs[sub_index].bootstrap_state.is_some();
3221 let registered = self.window_unit_by_sub_id(id);
3222 self.subs[sub_index].effective = Some(effective_scopes);
3224 self.begin_observation("syncular_section")
3225 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
3226 let mut batch = ChangeAccumulator::default();
3227 let outcome = self.apply_section_body(
3228 transport, sub_index, body, fresh, meta, report, &mut batch,
3229 );
3230 match outcome {
3231 Ok(()) => {
3232 let sub = &mut self.subs[sub_index];
3233 sub.cursor = next_cursor;
3236 sub.bootstrap_state = bootstrap_state;
3237 sub.synced_once = true;
3238 if sub.bootstrap_state.is_some() {
3239 report.bootstrapping.push(id.to_owned());
3240 }
3241 let completed =
3242 was_pending && sub.cursor >= 0 && sub.bootstrap_state.is_none();
3243 self.persist_sub(&self.subs[sub_index].clone());
3244 if completed {
3245 if let Some((base_key, unit)) = registered.clone() {
3246 batch.window(&base_key, &self.subs[sub_index].table, &unit);
3247 }
3248 }
3249 self.rebuild_overlay_if_dirty();
3250 self.finish_observation("syncular_section", batch)
3251 .map_err(|message| {
3252 SectionError::Abort("storage.failed".to_owned(), message)
3253 })?;
3254 Ok(())
3255 }
3256 Err(SectionError::FailClosed) => {
3257 self.rollback_observation("syncular_section");
3260 self.begin_observation("syncular_section_failure")
3261 .map_err(|message| {
3262 SectionError::Abort("storage.failed".to_owned(), message)
3263 })?;
3264 let mut failure_batch = ChangeAccumulator::default();
3265 let sub = &mut self.subs[sub_index];
3266 sub.state = SubState::Failed;
3267 sub.reason_code = Some("sync.scope_revoked".to_owned());
3268 report.failed.push(id.to_owned());
3269 self.persist_sub(&self.subs[sub_index].clone());
3270 if let Some((base_key, unit)) = registered {
3271 failure_batch.window(&base_key, &self.subs[sub_index].table, &unit);
3272 }
3273 self.finish_observation("syncular_section_failure", failure_batch)
3274 .map_err(|message| {
3275 SectionError::Abort("storage.failed".to_owned(), message)
3276 })?;
3277 Ok(())
3278 }
3279 Err(SectionError::Abort(code, message)) => {
3280 self.rollback_observation("syncular_section");
3283 Err(SectionError::Abort(code, message))
3284 }
3285 }
3286 }
3287 }
3288 }
3289
3290 #[allow(clippy::too_many_arguments)]
3294 fn apply_section_body(
3295 &mut self,
3296 transport: &mut dyn Transport,
3297 sub_index: usize,
3298 body: Vec<Frame>,
3299 fresh: bool,
3300 meta: &RequestMeta,
3301 report: &mut SyncReport,
3302 batch: &mut ChangeAccumulator,
3303 ) -> Result<(), SectionError> {
3304 let mut saw_segment = false;
3305 for frame in body {
3306 match frame {
3307 Frame::Commit {
3308 tables, changes, ..
3309 } => {
3310 self.record_commit_changes(batch, &tables, &changes);
3311 self.apply_commit_changes(&tables, &changes)
3312 .map_err(|(c, m)| SectionError::Abort(c, m))?;
3313 report.commits_applied += 1;
3314 }
3315 Frame::SegmentInline { payload } => {
3316 let segment = decode_rows_segment(&payload)
3317 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
3318 let first = !saw_segment;
3319 saw_segment = true;
3320 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3321 let cleared =
3322 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
3323 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
3324 if applied > 0 || cleared {
3325 batch.table(&segment.table);
3326 }
3327 report.segment_rows_applied += applied;
3328 }
3329 Frame::SegmentRef {
3330 segment_id,
3331 media_type,
3332 table,
3333 row_count,
3334 as_of_commit_seq,
3335 scope_digest,
3336 row_cursor,
3337 next_row_cursor,
3338 url,
3339 url_expires_at_ms,
3340 ..
3341 } => {
3342 let advertised = match media_type {
3345 MediaType::Rows => {
3346 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
3347 || meta.accept & ACCEPT_INLINE_ROWS != 0
3348 }
3349 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
3350 };
3351 if !advertised {
3352 return Err(SectionError::Abort(
3353 "sync.invalid_request".to_owned(),
3354 format!(
3355 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
3356 media_type.name()
3357 ),
3358 ));
3359 }
3360 let bytes = if let Some(url) = url {
3361 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
3366 return Err(SectionError::Abort(
3367 "sync.invalid_request".to_owned(),
3368 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
3369 .to_owned(),
3370 ));
3371 }
3372 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
3374 return Err(SectionError::Abort(
3375 "sync.segment_expired".to_owned(),
3376 format!(
3377 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
3378 ),
3379 ));
3380 }
3381 transport
3382 .fetch_url(&url)
3383 .map_err(|e| SectionError::Abort(e.code, e.message))?
3384 } else {
3385 let requested_scopes_json =
3386 canonical_scope_json(&self.subs[sub_index].requested);
3387 transport
3388 .download_segment(&SegmentRequest {
3389 segment_id: segment_id.clone(),
3390 table,
3391 requested_scopes_json,
3392 })
3393 .map_err(|e| SectionError::Abort(e.code, e.message))?
3394 };
3395 let digest = Sha256::digest(&bytes);
3397 let expected = segment_id
3398 .strip_prefix("sha256:")
3399 .unwrap_or(segment_id.as_str());
3400 if bytes_to_hex(&digest) != expected {
3401 return Err(SectionError::Abort(
3402 "sync.invalid_request".to_owned(),
3403 "segment bytes do not match the content address (§5.1)".to_owned(),
3404 ));
3405 }
3406 if media_type == MediaType::Sqlite {
3407 if row_cursor.is_some() || next_row_cursor.is_some() {
3410 return Err(SectionError::Abort(
3411 "sync.invalid_request".to_owned(),
3412 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
3413 .to_owned(),
3414 ));
3415 }
3416 let first = !saw_segment;
3417 saw_segment = true;
3418 let sub_table = self.subs[sub_index].table.clone();
3419 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3420 let cleared =
3421 fresh && first && self.scoped_rows_exist(&sub_table, &effective);
3422 let applied = self.apply_sqlite_segment(
3423 sub_index,
3424 &bytes,
3425 fresh && first,
3426 row_count,
3427 as_of_commit_seq,
3428 &scope_digest,
3429 )?;
3430 if applied > 0 || cleared {
3431 batch.table(&sub_table);
3432 }
3433 report.segment_rows_applied += applied;
3434 } else {
3435 let segment = decode_rows_segment(&bytes).map_err(|e| {
3436 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
3437 })?;
3438 let first = row_cursor.is_none();
3439 saw_segment = true;
3440 let effective = self.subs[sub_index].effective.clone().unwrap_or_default();
3441 let cleared =
3442 fresh && first && self.scoped_rows_exist(&segment.table, &effective);
3443 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
3444 if applied > 0 || cleared {
3445 batch.table(&segment.table);
3446 }
3447 report.segment_rows_applied += applied;
3448 }
3449 }
3450 Frame::Unknown { .. } => {}
3451 _ => {
3452 return Err(SectionError::Abort(
3453 "sync.invalid_request".to_owned(),
3454 "unexpected frame inside a subscription section".to_owned(),
3455 ));
3456 }
3457 }
3458 }
3459 Ok(())
3460 }
3461
3462 fn apply_commit_changes(
3463 &mut self,
3464 tables: &[String],
3465 changes: &[ssp2::model::Change],
3466 ) -> Result<(), (String, String)> {
3467 for change in changes {
3468 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
3469 (
3470 "sync.invalid_request".to_owned(),
3471 "change tableIndex out of range".to_owned(),
3472 )
3473 })?;
3474 let table = self.schema.table(table_name).ok_or_else(|| {
3475 (
3476 "sync.schema_mismatch".to_owned(),
3477 format!("change targets unknown table {table_name:?}"),
3478 )
3479 })?;
3480 match change.op {
3481 Op::Upsert => {
3482 let payload = change.row.as_ref().ok_or_else(|| {
3483 (
3484 "sync.invalid_request".to_owned(),
3485 "upsert change without row payload".to_owned(),
3486 )
3487 })?;
3488 let row = decode_row_bytes(table, payload, &self.encryption)
3490 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3491 let version = change.row_version.unwrap_or(0);
3492 let table_name = table.name.clone();
3493 self.write_base_row(&table_name, &row, version)
3494 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3495 }
3496 Op::Delete => {
3497 self.delete_base_row(table_name, &change.row_id)
3498 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
3499 }
3500 }
3501 }
3502 Ok(())
3503 }
3504
3505 fn apply_segment(
3510 &mut self,
3511 sub_index: usize,
3512 segment: &RowsSegment,
3513 first_fresh_page: bool,
3514 ) -> Result<u32, SectionError> {
3515 let (sub_table, effective) = {
3516 let sub = &self.subs[sub_index];
3517 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3518 };
3519 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
3520 SectionError::Abort(
3521 "sync.schema_mismatch".to_owned(),
3522 format!("subscription table {sub_table:?} is not in the client schema"),
3523 )
3524 })?;
3525 let matches = segment.table == table.name
3530 && segment.schema_version == self.schema.version
3531 && segment.columns.len() == table.wire_columns.len()
3532 && segment
3533 .columns
3534 .iter()
3535 .zip(table.wire_columns.iter())
3536 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
3537 if !matches {
3538 return Err(SectionError::Abort(
3539 "sync.schema_mismatch".to_owned(),
3540 "segment column table does not match the generated schema (§5.2)".to_owned(),
3541 ));
3542 }
3543 if first_fresh_page {
3544 self.purge_scope_rows(&table.name, &effective)
3548 .map_err(|()| SectionError::FailClosed)?;
3549 }
3550 let mut applied = 0u32;
3551 for block in &segment.blocks {
3552 for row in block {
3553 let decrypted;
3558 let values = if table.has_encrypted_columns() {
3559 let mut values = row.values.clone();
3560 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
3561 .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
3562 decrypted = values;
3563 &decrypted
3564 } else {
3565 &row.values
3566 };
3567 self.write_base_row(&table.name, values, row.server_version)
3570 .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
3571 applied += 1;
3572 }
3573 }
3574 Ok(applied)
3575 }
3576
3577 fn apply_sqlite_segment(
3585 &mut self,
3586 sub_index: usize,
3587 bytes: &[u8],
3588 first_fresh_page: bool,
3589 row_count: i64,
3590 as_of_commit_seq: i64,
3591 scope_digest: &str,
3592 ) -> Result<u32, SectionError> {
3593 let invalid = |detail: &str| {
3594 SectionError::Abort(
3595 "sync.invalid_request".to_owned(),
3596 format!("sqlite segment rejected: {detail} (§5.3)"),
3597 )
3598 };
3599 let (sub_table, effective) = {
3600 let sub = &self.subs[sub_index];
3601 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
3602 };
3603 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
3604 SectionError::Abort(
3605 "sync.schema_mismatch".to_owned(),
3606 format!("subscription table {sub_table:?} is not in the client schema"),
3607 )
3608 })?;
3609
3610 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
3611 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
3612 let img = match rusqlite::Connection::open_with_flags(
3613 &path,
3614 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
3615 ) {
3616 Ok(conn) => conn,
3617 Err(_) => {
3618 let _ = std::fs::remove_file(&path);
3619 return Err(invalid("bytes do not open as a SQLite database"));
3620 }
3621 };
3622 let outcome = self.apply_sqlite_image(
3623 &img,
3624 &table,
3625 first_fresh_page,
3626 &effective,
3627 row_count,
3628 as_of_commit_seq,
3629 scope_digest,
3630 );
3631 drop(img);
3632 let _ = std::fs::remove_file(&path);
3633 outcome
3634 }
3635
3636 #[allow(clippy::too_many_arguments)]
3637 fn apply_sqlite_image(
3638 &mut self,
3639 img: &rusqlite::Connection,
3640 table: &crate::schema::TableSchema,
3641 first_fresh_page: bool,
3642 effective: &[(String, Vec<String>)],
3643 row_count: i64,
3644 as_of_commit_seq: i64,
3645 scope_digest: &str,
3646 ) -> Result<u32, SectionError> {
3647 let invalid = |detail: String| {
3648 SectionError::Abort(
3649 "sync.invalid_request".to_owned(),
3650 format!("sqlite segment rejected: {detail} (§5.3)"),
3651 )
3652 };
3653
3654 type MetaRow = (i64, String, i64, i64, String, i64, i64);
3657 let meta: MetaRow = img
3658 .query_row(
3659 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
3660 \"scopeDigest\", \"rowCount\",
3661 (SELECT count(*) FROM _syncular_segment)
3662 FROM _syncular_segment",
3663 [],
3664 |row| {
3665 Ok((
3666 row.get(0)?,
3667 row.get(1)?,
3668 row.get(2)?,
3669 row.get(3)?,
3670 row.get(4)?,
3671 row.get(5)?,
3672 row.get(6)?,
3673 ))
3674 },
3675 )
3676 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
3677 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
3678 if meta_count != 1 {
3679 return Err(invalid(format!(
3680 "_syncular_segment must contain exactly one row, found {meta_count}"
3681 )));
3682 }
3683 if format != 1 {
3684 return Err(invalid(format!("format {format}")));
3685 }
3686 if meta_table != table.name {
3687 return Err(invalid(format!("image table {meta_table:?}")));
3688 }
3689 if schema_version != i64::from(self.schema.version) {
3690 return Err(invalid(format!("schemaVersion {schema_version}")));
3691 }
3692 if pin != as_of_commit_seq {
3693 return Err(invalid(format!("asOfCommitSeq {pin}")));
3694 }
3695 if digest != scope_digest {
3696 return Err(invalid("scopeDigest mismatch".to_owned()));
3697 }
3698 if meta_rows != row_count {
3699 return Err(invalid(format!("rowCount {meta_rows}")));
3700 }
3701
3702 let mut names: Vec<String> = Vec::new();
3704 {
3705 let mut stmt = img
3706 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
3707 .map_err(|_| invalid("image data table missing".to_owned()))?;
3708 let mut rows = stmt
3709 .query([])
3710 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3711 while let Some(row) = rows
3712 .next()
3713 .map_err(|_| invalid("image data table unreadable".to_owned()))?
3714 {
3715 names.push(
3716 row.get::<_, String>(1)
3717 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
3718 );
3719 }
3720 }
3721 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
3722 expected.push("_syncular_version");
3723 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
3724 return Err(SectionError::Abort(
3725 "sync.schema_mismatch".to_owned(),
3726 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
3727 ));
3728 }
3729
3730 if first_fresh_page {
3733 self.purge_scope_rows(&table.name, effective)
3734 .map_err(|()| SectionError::FailClosed)?;
3735 }
3736 self.overlay_dirty.set(true);
3742 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
3749 table.indexes.iter().filter(|i| !i.unique).collect()
3750 } else {
3751 Vec::new()
3752 };
3753 for index in &bulk_indexes {
3754 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
3755 self.conn
3756 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
3757 .map_err(|e| invalid(e.to_string()))?;
3758 }
3759 let insert = self.insert_row_sql(&base_table(&table.name), table);
3760 let applied = {
3761 let mut ins = self
3762 .conn
3763 .prepare_cached(&insert)
3764 .map_err(|e| invalid(e.to_string()))?;
3765 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
3766 let mut stmt = img
3767 .prepare(&format!(
3768 "SELECT {} FROM {}",
3769 column_list.join(", "),
3770 quote_ident(&table.name)
3771 ))
3772 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3773 let mut rows = stmt
3774 .query([])
3775 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
3776 let version_index = table.columns.len();
3777 let mut applied = 0u32;
3778 while let Some(row) = rows
3779 .next()
3780 .map_err(|_| invalid("image row unreadable".to_owned()))?
3781 {
3782 for (i, column) in table.columns.iter().enumerate() {
3783 let cell = row
3784 .get_ref(i)
3785 .map_err(|_| invalid("image row unreadable".to_owned()))?;
3786 let param = image_cell_param(column, cell).map_err(&invalid)?;
3787 ins.raw_bind_parameter(i + 1, param)
3788 .map_err(|e| invalid(e.to_string()))?;
3789 }
3790 let version: i64 = row
3791 .get(version_index)
3792 .map_err(|_| invalid("image row unreadable".to_owned()))?;
3793 if version < 1 {
3794 return Err(invalid(format!(
3795 "row _syncular_version must be >= 1, got {version}"
3796 )));
3797 }
3798 ins.raw_bind_parameter(version_index + 1, version)
3799 .map_err(|e| invalid(e.to_string()))?;
3800 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
3801 applied += 1;
3802 }
3803 applied
3804 };
3805 for index in &bulk_indexes {
3806 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
3807 let cols_sql = index
3808 .columns
3809 .iter()
3810 .map(|c| quote_ident(c))
3811 .collect::<Vec<_>>()
3812 .join(", ");
3813 self.conn
3814 .execute(
3815 &format!(
3816 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
3817 base_table(&table.name)
3818 ),
3819 [],
3820 )
3821 .map_err(|e| invalid(e.to_string()))?;
3822 }
3823 if i64::from(applied) != row_count {
3824 return Err(invalid(format!(
3825 "image holds {applied} rows, descriptor says {row_count}"
3826 )));
3827 }
3828 Ok(applied)
3829 }
3830
3831 fn purge_scope_rows(
3836 &mut self,
3837 table_name: &str,
3838 effective: &[(String, Vec<String>)],
3839 ) -> Result<(), ()> {
3840 if effective.is_empty() {
3841 return Ok(());
3842 }
3843 let table = self.schema.table(table_name).ok_or(())?.clone();
3844 let mut clauses = Vec::new();
3845 let mut params: Vec<SqlValue> = Vec::new();
3846 for (variable, values) in effective {
3847 let column = table.scope_column(variable).ok_or(())?;
3848 let placeholders: Vec<String> = values
3849 .iter()
3850 .map(|v| {
3851 params.push(SqlValue::Text(v.clone()));
3852 "?".to_owned()
3853 })
3854 .collect();
3855 clauses.push(format!(
3856 "{} IN ({})",
3857 quote_ident(column),
3858 placeholders.join(", ")
3859 ));
3860 }
3861 let sql = format!(
3862 "DELETE FROM {} WHERE {}",
3863 base_table(table_name),
3864 clauses.join(" AND ")
3865 );
3866 self.overlay_dirty.set(true);
3867 self.conn
3868 .execute(&sql, rusqlite::params_from_iter(params))
3869 .map_err(|_| ())?;
3870 Ok(())
3871 }
3872
3873 fn drop_doomed_outbox(&mut self, table_name: &str, effective: &[(String, Vec<String>)]) {
3876 if effective.is_empty() {
3877 return;
3878 }
3879 let Some(table) = self.schema.table(table_name).cloned() else {
3880 return;
3881 };
3882 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
3883 for (variable, values) in effective {
3884 match table.scope_column(variable) {
3885 Some(column) => mappings.push((column, values)),
3886 None => return, }
3888 }
3889 let doomed: Vec<String> = self
3890 .outbox
3891 .iter()
3892 .filter(|commit| {
3893 commit.ops.iter().any(|op| {
3894 op.upsert
3895 && op.table == table_name
3896 && op.values.as_ref().is_some_and(|values| {
3897 mappings
3898 .iter()
3899 .all(|(column, allowed)| match values.get(*column) {
3900 Some(Value::String(s)) => allowed.contains(s),
3901 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
3902 _ => false,
3903 })
3904 })
3905 })
3906 })
3907 .map(|c| c.client_commit_id.clone())
3908 .collect();
3909 for id in doomed {
3910 self.overlay_dirty.set(true);
3911 self.outbox.retain(|c| c.client_commit_id != id);
3912 self.persist_outbox_delete(&id);
3913 }
3914 }
3915
3916 pub fn upload_blob(
3923 &mut self,
3924 bytes: &[u8],
3925 media_type: Option<String>,
3926 name: Option<String>,
3927 ) -> Result<Value, String> {
3928 let blob_id = blob_id_for(bytes);
3929 let now = self.clock_now_ms();
3930 self.conn
3931 .execute(
3932 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
3933 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
3934 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
3935 )
3936 .map_err(|e| e.to_string())?;
3937 self.conn
3938 .execute(
3939 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
3940 rusqlite::params![blob_id, media_type, now],
3941 )
3942 .map_err(|e| e.to_string())?;
3943 self.enforce_blob_cache_cap();
3946 let mut obj = Map::new();
3947 obj.insert("blobId".to_owned(), Value::from(blob_id));
3948 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
3949 if let Some(mt) = media_type {
3950 obj.insert("mediaType".to_owned(), Value::from(mt));
3951 }
3952 if let Some(n) = name {
3953 obj.insert("name".to_owned(), Value::from(n));
3954 }
3955 Ok(Value::Object(obj))
3956 }
3957
3958 pub fn fetch_blob(
3962 &mut self,
3963 transport: &mut dyn Transport,
3964 blob_id_or_ref: &str,
3965 ) -> Result<Value, (String, String)> {
3966 let simple = |m: String| ("client.failed".to_owned(), m);
3967 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
3968 blob_id_or_ref.to_owned()
3969 } else {
3970 let value: Value = serde_json::from_str(blob_id_or_ref)
3971 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
3972 value
3973 .get("blobId")
3974 .and_then(Value::as_str)
3975 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
3976 .to_owned()
3977 };
3978 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
3979 return Ok(cached);
3980 }
3981 let bytes = match transport
3987 .blob_download(&blob_id)
3988 .map_err(|e| (e.code, e.message))?
3989 {
3990 BlobDownload::Bytes(bytes) => bytes,
3991 BlobDownload::Url {
3992 url,
3993 url_expires_at_ms,
3994 } => {
3995 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
3997 return Err((
3998 "sync.segment_expired".to_owned(),
3999 format!(
4000 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
4001 ),
4002 ));
4003 }
4004 transport
4005 .fetch_blob_url(&url)
4006 .map_err(|e| (e.code, e.message))?
4007 }
4008 };
4009 if blob_id_for(&bytes) != blob_id {
4011 return Err(simple(format!(
4012 "blob content address mismatch for {blob_id}"
4013 )));
4014 }
4015 let now = self.clock_now_ms();
4016 self.conn
4017 .execute(
4018 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
4019 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
4020 )
4021 .map_err(|e| simple(e.to_string()))?;
4022 self.enforce_blob_cache_cap();
4023 self.get_cached_blob(&blob_id)
4024 .map_err(simple)?
4025 .ok_or_else(|| simple("blob cache write failed".to_owned()))
4026 }
4027
4028 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
4029 let _ = self.conn.execute(
4032 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
4033 rusqlite::params![self.clock_now_ms(), blob_id],
4034 );
4035 let mut stmt = self
4036 .conn
4037 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
4038 .map_err(|e| e.to_string())?;
4039 let mut rows = stmt
4040 .query(rusqlite::params![blob_id])
4041 .map_err(|e| e.to_string())?;
4042 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
4043 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
4044 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
4045 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
4046 let mut obj = Map::new();
4047 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
4048 obj.insert("byteLength".to_owned(), Value::from(byte_length));
4049 let mut bytes_obj = Map::new();
4050 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
4051 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
4052 if let Some(mt) = media_type {
4053 obj.insert("mediaType".to_owned(), Value::from(mt));
4054 }
4055 return Ok(Some(Value::Object(obj)));
4056 }
4057 Ok(None)
4058 }
4059
4060 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
4062 let pending: Vec<(String, Option<String>)> = {
4063 let mut stmt = self
4064 .conn
4065 .prepare(
4066 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
4067 )
4068 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4069 let rows = stmt
4070 .query_map([], |row| {
4071 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
4072 })
4073 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
4074 rows.filter_map(Result::ok).collect()
4075 };
4076 for (blob_id, media_type) in pending {
4077 let bytes: Option<Vec<u8>> = self
4078 .conn
4079 .query_row(
4080 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
4081 rusqlite::params![blob_id],
4082 |row| row.get(0),
4083 )
4084 .ok();
4085 if let Some(bytes) = bytes {
4086 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
4087 }
4088 let _ = self.conn.execute(
4089 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
4090 rusqlite::params![blob_id],
4091 );
4092 }
4093 Ok(())
4094 }
4095
4096 fn upload_one(
4103 &self,
4104 transport: &mut dyn Transport,
4105 blob_id: &str,
4106 bytes: &[u8],
4107 media_type: Option<&str>,
4108 ) -> Result<(), TransportError> {
4109 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
4110 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
4112 url,
4113 url_expires_at_ms,
4114 } => {
4115 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
4116 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
4117 return Ok(());
4118 }
4119 }
4121 BlobUploadGrant::None => {
4122 }
4124 }
4125 transport.blob_upload(blob_id, bytes, media_type)
4126 }
4127
4128 fn enforce_blob_cache_cap(&self) {
4137 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
4138 return;
4139 };
4140 let mut total: i64 = self
4141 .conn
4142 .query_row(
4143 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
4144 [],
4145 |row| row.get(0),
4146 )
4147 .unwrap_or(0);
4148 if total <= max_bytes {
4149 return;
4150 }
4151 let candidates: Vec<(String, i64)> = {
4152 let Ok(mut stmt) = self.conn.prepare(
4153 "SELECT blob_id, byte_length FROM _syncular_blobs
4154 WHERE refcount = 0
4155 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
4156 ORDER BY last_used_ms ASC, created_at_ms ASC",
4157 ) else {
4158 return;
4159 };
4160 let Ok(rows) = stmt.query_map([], |row| {
4161 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
4162 }) else {
4163 return;
4164 };
4165 rows.filter_map(Result::ok).collect()
4166 };
4167 for (blob_id, byte_length) in candidates {
4168 if total <= max_bytes {
4169 break;
4170 }
4171 let _ = self.conn.execute(
4172 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
4173 rusqlite::params![blob_id],
4174 );
4175 total -= byte_length;
4176 }
4177 }
4178
4179 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
4183 if !self.schema_has_blobs() {
4184 return;
4185 }
4186 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
4187 for table in self.schema.tables.clone() {
4188 let blob_cols: Vec<String> = table
4189 .columns
4190 .iter()
4191 .filter(|c| c.ty == ColumnType::BlobRef)
4192 .map(|c| c.name.clone())
4193 .collect();
4194 for column in blob_cols {
4195 let sql = format!(
4196 "SELECT {} FROM {} WHERE {} IS NOT NULL",
4197 quote_ident(&column),
4198 base_table(&table.name),
4199 quote_ident(&column)
4200 );
4201 let Ok(mut stmt) = self.conn.prepare(&sql) else {
4202 continue;
4203 };
4204 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
4205 continue;
4206 };
4207 for raw in rows.flatten().flatten() {
4208 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
4209 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
4210 *counts.entry(id.to_owned()).or_insert(0) += 1;
4211 }
4212 }
4213 }
4214 }
4215 }
4216 let _ = self
4217 .conn
4218 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
4219 for (blob_id, count) in &counts {
4220 let _ = self.conn.execute(
4221 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
4222 rusqlite::params![count, blob_id],
4223 );
4224 }
4225 if delete_orphans {
4226 let _ = self.conn.execute(
4227 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
4228 [],
4229 );
4230 }
4231 }
4232
4233 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
4238 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
4239 return sql.clone();
4240 }
4241 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
4242 columns.push(quote_ident("_syncular_version"));
4243 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
4244 let sql = format!(
4245 "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
4246 columns.join(", "),
4247 placeholders.join(", ")
4248 );
4249 self.insert_sql
4250 .borrow_mut()
4251 .insert(full_table.to_owned(), sql.clone());
4252 sql
4253 }
4254
4255 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
4256 self.overlay_dirty.set(true);
4257 self.write_row(&base_table(table_name), table_name, row, version)
4258 }
4259
4260 fn write_row(
4261 &self,
4262 full_table: &str,
4263 table_name: &str,
4264 row: &Row,
4265 version: i64,
4266 ) -> Result<(), String> {
4267 let table = self
4268 .schema
4269 .table(table_name)
4270 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4271 let sql = self.insert_row_sql(full_table, table);
4272 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4273 let params = row
4274 .iter()
4275 .map(RowParam::Cell)
4276 .chain(std::iter::once(RowParam::Version(version)));
4277 stmt.execute(rusqlite::params_from_iter(params))
4278 .map_err(|e| e.to_string())?;
4279 Ok(())
4280 }
4281
4282 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
4283 let table = self
4284 .schema
4285 .table(table_name)
4286 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
4287 self.overlay_dirty.set(true);
4288 let sql = format!(
4289 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
4290 base_table(table_name),
4291 quote_ident(&table.primary_key)
4292 );
4293 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
4294 stmt.execute(rusqlite::params![row_id])
4295 .map_err(|e| e.to_string())?;
4296 Ok(())
4297 }
4298
4299 fn rebuild_overlay_if_dirty(&mut self) {
4302 if self.overlay_dirty.get() {
4303 self.rebuild_overlay();
4304 }
4305 }
4306
4307 fn rebuild_overlay(&mut self) {
4311 self.exec("SAVEPOINT syncular_overlay");
4312 for table in self.schema.tables.clone() {
4313 let visible = visible_table(&table.name);
4314 let base = base_table(&table.name);
4315 self.exec(&format!("DELETE FROM {visible}"));
4316 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
4317 }
4318 for commit in self.outbox.clone() {
4319 for op in &commit.ops {
4320 let Some(table) = self.schema.table(&op.table).cloned() else {
4321 continue;
4322 };
4323 if op.upsert {
4324 let Some(values) = op.values.as_ref() else {
4325 continue;
4326 };
4327 let mut row: Row = Vec::with_capacity(table.columns.len());
4328 let mut ok = true;
4329 for column in &table.columns {
4330 match json_to_column_value(column, values.get(&column.name)) {
4331 Ok(v) => row.push(v),
4332 Err(_) => {
4333 ok = false;
4334 break;
4335 }
4336 }
4337 }
4338 if ok {
4339 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
4340 }
4341 } else {
4342 let sql = format!(
4343 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
4344 visible_table(&table.name),
4345 quote_ident(&table.primary_key)
4346 );
4347 let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
4348 }
4349 }
4350 }
4351 self.exec("RELEASE syncular_overlay");
4352 self.overlay_dirty.set(false);
4353 }
4354
4355 fn exec(&self, sql: &str) {
4356 let _ = self.conn.execute_batch(sql);
4357 }
4358
4359 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
4362 transport
4363 .realtime_connect_for_client(&self.client_id)
4364 .map_err(|e| format!("{}: {}", e.code, e.message))?;
4365 self.realtime_connected = true;
4366 Ok(())
4367 }
4368
4369 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
4370 let _ = transport.realtime_close();
4371 self.realtime_connected = false;
4372 self.presence.clear(); }
4374
4375 pub fn set_presence(
4381 &mut self,
4382 transport: &mut dyn Transport,
4383 scope_key: &str,
4384 doc: Option<&Value>,
4385 ) -> Result<(), String> {
4386 if !self.realtime_connected {
4387 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
4388 }
4389 let text = encode_presence_publish(scope_key, doc);
4390 transport
4391 .realtime_send(&text)
4392 .map_err(|e| format!("{}: {}", e.code, e.message))
4393 }
4394
4395 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
4397 self.presence
4398 .get(scope_key)
4399 .map(|peers| peers.values().cloned().collect())
4400 .unwrap_or_default()
4401 }
4402
4403 fn apply_presence(
4405 &mut self,
4406 scope_key: String,
4407 kind: Option<PresenceKind>,
4408 actor_id: Option<String>,
4409 client_id: Option<String>,
4410 doc: Option<Value>,
4411 error: Option<String>,
4412 ) {
4413 if error.is_some() {
4416 return;
4417 }
4418 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
4419 return;
4420 };
4421 let peer_key = format!("{actor_id} {client_id}");
4422 match kind {
4423 PresenceKind::Leave => {
4424 if let Some(peers) = self.presence.get_mut(&scope_key) {
4425 peers.remove(&peer_key);
4426 if peers.is_empty() {
4427 self.presence.remove(&scope_key);
4428 }
4429 }
4430 }
4431 _ => {
4432 let doc = match doc {
4433 Some(Value::Object(_)) => doc.unwrap(),
4434 _ => return,
4435 };
4436 self.presence.entry(scope_key).or_default().insert(
4437 peer_key,
4438 PresencePeer {
4439 actor_id,
4440 client_id,
4441 doc,
4442 },
4443 );
4444 }
4445 }
4446 }
4447
4448 pub fn on_realtime_text(&mut self, text: &str) {
4450 match parse_control(text) {
4451 Ok(ControlMessage::Hello { requires_sync, .. }) => {
4452 if requires_sync {
4453 self.set_sync_needed(true, true);
4455 }
4456 }
4457 Ok(ControlMessage::Presence {
4458 scope_key,
4459 kind,
4460 actor_id,
4461 client_id,
4462 doc,
4463 error,
4464 ..
4465 }) => {
4466 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
4467 }
4468 Ok(ControlMessage::Wake { .. }) => {
4469 self.set_sync_needed(true, true);
4471 }
4472 _ => {}
4473 }
4474 }
4475
4476 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
4479 if self.stopped {
4480 return;
4481 }
4482 let message = match decode_message(bytes) {
4483 Ok(m) if m.msg_kind == MsgKind::Response => m,
4484 _ => {
4485 self.set_sync_needed(true, true);
4486 return;
4487 }
4488 };
4489 let mut frames = message.frames.into_iter();
4490 let mut applied_cursor: Option<i64> = None;
4491 let mut any_covered = false;
4492 let mut dropped = false;
4493 while let Some(frame) = frames.next() {
4494 let Frame::SubStart {
4495 id,
4496 status,
4497 effective_scopes,
4498 ..
4499 } = frame
4500 else {
4501 continue;
4502 };
4503 let mut body = Vec::new();
4504 let mut next_cursor: Option<i64> = None;
4505 for inner in frames.by_ref() {
4506 match inner {
4507 Frame::SubEnd {
4508 next_cursor: nc, ..
4509 } => {
4510 next_cursor = Some(nc);
4511 break;
4512 }
4513 Frame::Unknown { .. } => {}
4514 other => body.push(other),
4515 }
4516 }
4517 let Some(next_cursor) = next_cursor else {
4518 dropped = true;
4519 break;
4520 };
4521 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
4522 dropped = true;
4523 continue;
4524 };
4525 let sub = &self.subs[sub_index];
4526 if status != SubStatus::Active
4529 || sub.state != SubState::Active
4530 || sub.bootstrap_state.is_some()
4531 || !sub.synced_once
4532 {
4533 dropped = true;
4534 continue;
4535 }
4536 if next_cursor <= sub.cursor {
4537 any_covered = true;
4539 continue;
4540 }
4541 let previous_effective = self.subs[sub_index].effective.clone();
4542 let previous_cursor = self.subs[sub_index].cursor;
4543 if self.begin_observation("syncular_delta").is_err() {
4544 dropped = true;
4545 continue;
4546 }
4547 self.subs[sub_index].effective = Some(effective_scopes);
4548 let mut batch = ChangeAccumulator::default();
4549 let mut failed = false;
4550 for inner in body {
4551 if let Frame::Commit {
4552 tables, changes, ..
4553 } = inner
4554 {
4555 self.record_commit_changes(&mut batch, &tables, &changes);
4556 if self.apply_commit_changes(&tables, &changes).is_err() {
4557 failed = true;
4558 break;
4559 }
4560 }
4561 }
4562 if failed {
4563 self.rollback_observation("syncular_delta");
4564 self.subs[sub_index].effective = previous_effective;
4565 self.subs[sub_index].cursor = previous_cursor;
4566 self.overlay_dirty.set(true);
4567 self.rebuild_overlay();
4568 dropped = true;
4569 continue;
4570 }
4571 let sub = &mut self.subs[sub_index];
4572 sub.cursor = next_cursor;
4573 self.persist_sub(&self.subs[sub_index].clone());
4574 self.rebuild_overlay_if_dirty();
4575 if self.finish_observation("syncular_delta", batch).is_err() {
4576 self.rollback_observation("syncular_delta");
4577 self.subs[sub_index].effective = previous_effective;
4578 self.subs[sub_index].cursor = previous_cursor;
4579 self.overlay_dirty.set(true);
4580 self.rebuild_overlay();
4581 dropped = true;
4582 continue;
4583 }
4584 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
4585 }
4586 if let Some(cursor) = applied_cursor {
4587 self.reconcile_blob_refcounts(false);
4588 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
4590 let _ = transport.realtime_send(&ack);
4591 } else if !any_covered || dropped {
4592 self.set_sync_needed(true, true);
4594 }
4595 }
4596
4597 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
4601 if !self.realtime_connected {
4602 return;
4603 }
4604 let floor = self
4605 .subs
4606 .iter()
4607 .filter(|s| {
4608 s.state == SubState::Active
4609 && s.bootstrap_state.is_none()
4610 && s.synced_once
4611 && s.cursor >= 0
4612 })
4613 .map(|s| s.cursor)
4614 .min();
4615 if let Some(cursor) = floor {
4616 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
4617 let _ = transport.realtime_send(&ack);
4618 }
4619 }
4620}