1use std::cell::{Cell, RefCell};
11use std::collections::HashMap;
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::Connection;
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 ClientLimits, ConflictRecord, LeaseState, Mutation, PresencePeer, RejectionRecord, RowState,
27 SchemaFloor, SubscriptionStateView, SyncOutcome, SyncReport, WindowBase,
28};
29use crate::schema::{parse_schema_json, ClientSchema};
30use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
31use crate::values::{
32 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
33 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
34 scope_map_to_json, sort_scope_map,
35};
36
37const DEFAULT_ACCEPT: u8 = 0b0111;
42const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
43const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
44const ACCEPT_SQLITE: u8 = 1 << 2;
45const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
46
47const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
49const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
54enum SubState {
55 Active,
56 Revoked,
57 Failed,
58}
59
60impl SubState {
61 fn name(self) -> &'static str {
62 match self {
63 SubState::Active => "active",
64 SubState::Revoked => "revoked",
65 SubState::Failed => "failed",
66 }
67 }
68}
69
70#[derive(Debug, Clone)]
71struct Subscription {
72 id: String,
73 table: String,
74 requested: Vec<(String, Vec<String>)>,
75 params: Option<String>,
76 cursor: i64,
77 bootstrap_state: Option<String>,
79 state: SubState,
80 reason_code: Option<String>,
81 effective: Option<Vec<(String, Vec<String>)>>,
84 synced_once: bool,
85}
86
87#[derive(Debug, Clone)]
88struct OutboxOp {
89 upsert: bool,
90 table: String,
91 row_id: String,
92 base_version: Option<i64>,
93 values: Option<Map<String, Value>>,
96}
97
98#[derive(Debug, Clone)]
99struct OutboxCommit {
100 client_commit_id: String,
101 ops: Vec<OutboxOp>,
102}
103
104enum SectionError {
107 FailClosed,
108 Abort(String, String),
109}
110
111struct RequestMeta {
112 pushed_ids: Vec<String>,
113 fresh: Vec<(String, bool)>,
116 accept: u8,
117 deferred_commits: usize,
121}
122
123const PUSH_OPS_PER_REQUEST: usize = 500;
129
130pub struct SyncClient {
131 conn: Connection,
132 schema: ClientSchema,
133 client_id: String,
134 limits: ClientLimits,
135 subs: Vec<Subscription>,
136 outbox: Vec<OutboxCommit>,
137 conflicts: Vec<ConflictRecord>,
138 rejections: Vec<RejectionRecord>,
139 schema_floor: Option<SchemaFloor>,
140 lease_state: Option<LeaseState>,
142 stopped: bool,
144 upgrading: bool,
146 sync_needed: bool,
148 realtime_connected: bool,
149 presence: HashMap<String, HashMap<String, PresencePeer>>,
151 now_ms: Option<i64>,
154 encryption: crate::values::EncryptionConfig,
158 insert_sql: RefCell<HashMap<String, String>>,
164 overlay_dirty: Cell<bool>,
168}
169
170fn quote_ident(name: &str) -> String {
171 format!("\"{}\"", name.replace('"', "\"\""))
172}
173
174fn base_table(name: &str) -> String {
175 quote_ident(&format!("_syncular_base_{name}"))
176}
177
178type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
183
184fn window_base_key(base: &WindowBase) -> String {
185 format!(
186 "{} {} {}",
187 base.table,
188 base.variable,
189 canonical_scope_json(&base.fixed_scopes)
190 )
191}
192
193fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
195 let mut scopes = base.fixed_scopes.clone();
196 scopes.retain(|(k, _)| k != &base.variable);
197 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
198 scopes
199}
200
201fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
205 let canonical = canonical_scope_json(&unit_scopes(base, unit));
206 let digest = Sha256::digest(canonical.as_bytes());
207 let hex = bytes_to_hex(&digest);
208 format!("w:{}:{}", base.table, &hex[..16])
209}
210
211fn visible_table(name: &str) -> String {
212 quote_ident(name)
213}
214
215fn is_synced_table_name(name: &str) -> bool {
218 if name.starts_with("sqlite_") {
219 return false;
220 }
221 if name.starts_with("_syncular_base_") {
222 return true; }
224 !name.starts_with("_syncular_")
226}
227
228fn blob_id_for(bytes: &[u8]) -> String {
230 let digest = Sha256::digest(bytes);
231 format!("sha256:{}", bytes_to_hex(&digest))
232}
233
234enum RowParam<'a> {
238 Cell(&'a Option<ColumnValue>),
239 Version(i64),
240}
241
242impl rusqlite::ToSql for RowParam<'_> {
243 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
244 Ok(match self {
245 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
246 RowParam::Cell(cell) => match cell {
247 None => ToSqlOutput::Owned(SqlValue::Null),
248 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
249 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
250 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
251 Some(ColumnValue::Boolean(b)) => {
252 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
253 }
254 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
255 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
256 }
257 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
259 ToSqlOutput::Borrowed(ValueRef::Blob(b))
260 }
261 },
262 })
263 }
264}
265
266fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
274 use ssp2::segment::ColumnType;
275 let mismatch = || {
276 Err(format!(
277 "image column {:?} holds a value of the wrong type",
278 column.name
279 ))
280 };
281 match value {
282 ValueRef::Null => {
283 if !column.nullable {
284 return Err(format!(
285 "image column {:?} is NULL but not nullable",
286 column.name
287 ));
288 }
289 Ok(ToSqlOutput::Owned(SqlValue::Null))
290 }
291 ValueRef::Integer(i) => match column.ty {
292 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
293 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
294 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
295 _ => mismatch(),
296 },
297 ValueRef::Real(_) => match column.ty {
298 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
299 _ => mismatch(),
300 },
301 ValueRef::Text(t) => {
302 std::str::from_utf8(t)
303 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
304 match column.ty {
305 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
306 Ok(ToSqlOutput::Borrowed(value))
307 }
308 _ => mismatch(),
309 }
310 }
311 ValueRef::Blob(_) => match column.ty {
312 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
314 _ => mismatch(),
315 },
316 }
317}
318
319fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
320 use rusqlite::types::ValueRef;
321 match value {
322 ValueRef::Null => Value::Null,
323 ValueRef::Integer(i) => match column.ty {
324 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
325 ssp2::segment::ColumnType::Float => {
326 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
327 }
328 _ => Value::from(i),
329 },
330 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
331 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
332 ValueRef::Blob(b) => {
333 let mut map = Map::new();
334 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
335 Value::Object(map)
336 }
337 }
338}
339
340fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
343 Ok(match value {
344 Value::Null => SqlValue::Null,
345 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
346 Value::Number(n) => {
347 if let Some(i) = n.as_i64() {
348 SqlValue::Integer(i)
349 } else if let Some(f) = n.as_f64() {
350 SqlValue::Real(f)
351 } else {
352 return Err(format!("query param number {n} is out of range"));
353 }
354 }
355 Value::String(s) => SqlValue::Text(s.clone()),
356 Value::Object(_) => {
357 let hex = value
358 .get("$bytes")
359 .and_then(Value::as_str)
360 .ok_or_else(|| "query object param must be a {\"$bytes\": hex} value".to_owned())?;
361 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
362 }
363 Value::Array(_) => return Err("query array params are not supported".to_owned()),
364 })
365}
366
367fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
372 use rusqlite::types::ValueRef;
373 match value {
374 ValueRef::Null => Value::Null,
375 ValueRef::Integer(i) => Value::from(i),
376 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
377 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
378 ValueRef::Blob(b) => {
379 let mut map = Map::new();
380 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
381 Value::Object(map)
382 }
383 }
384}
385
386impl SyncClient {
387 pub fn new(
388 client_id: String,
389 schema_json: &Value,
390 limits: ClientLimits,
391 ) -> Result<Self, String> {
392 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
393 Self::with_connection(client_id, schema_json, limits, conn)
394 }
395
396 pub fn open_path(
402 client_id: String,
403 schema_json: &Value,
404 limits: ClientLimits,
405 path: &str,
406 ) -> Result<Self, String> {
407 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
408 Self::with_connection(client_id, schema_json, limits, conn)
409 }
410
411 pub fn with_connection(
418 client_id: String,
419 schema_json: &Value,
420 limits: ClientLimits,
421 conn: Connection,
422 ) -> Result<Self, String> {
423 let schema = parse_schema_json(schema_json)?;
424 let client = SyncClient {
425 conn,
426 schema,
427 client_id,
428 limits,
429 subs: Vec::new(),
430 outbox: Vec::new(),
431 conflicts: Vec::new(),
432 rejections: Vec::new(),
433 schema_floor: None,
434 lease_state: None,
435 stopped: false,
436 upgrading: false,
437 sync_needed: false,
438 realtime_connected: false,
439 presence: HashMap::new(),
440 now_ms: None,
441 encryption: crate::values::EncryptionConfig::default(),
442 insert_sql: RefCell::new(HashMap::new()),
443 overlay_dirty: Cell::new(false),
444 };
445 client
449 .conn
450 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
451 client.create_tables()?;
452 Ok(client)
453 }
454
455 pub fn set_now_ms(&mut self, now_ms: i64) {
458 self.now_ms = Some(now_ms);
459 }
460
461 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
465 self.encryption = encryption;
466 }
467
468 fn clock_now_ms(&self) -> i64 {
469 self.now_ms.unwrap_or_else(|| {
470 std::time::SystemTime::now()
471 .duration_since(std::time::UNIX_EPOCH)
472 .map(|d| d.as_millis() as i64)
473 .unwrap_or(0)
474 })
475 }
476
477 fn create_tables(&self) -> Result<(), String> {
478 self.create_synced_tables()?;
479 self.conn
481 .execute_batch(
482 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
483 seq INTEGER PRIMARY KEY AUTOINCREMENT,
484 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
485 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
486 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
487 CREATE TABLE IF NOT EXISTS _syncular_meta (
488 key TEXT PRIMARY KEY, value TEXT NOT NULL);
489 CREATE TABLE IF NOT EXISTS _syncular_windows (
490 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
491 PRIMARY KEY (base, unit));
492 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
493 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
494 effective_scopes TEXT NOT NULL);",
495 )
496 .map_err(|e| e.to_string())?;
497 if self.get_meta(LOCAL_SCHEMA_VERSION_KEY).is_none() {
500 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
501 }
502 if self.schema_has_blobs() {
507 self.conn
508 .execute_batch(
509 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
510 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
511 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
512 created_at_ms INTEGER NOT NULL,
513 last_used_ms INTEGER NOT NULL DEFAULT 0);
514 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
515 media_type TEXT, created_at_ms INTEGER NOT NULL);",
516 )
517 .map_err(|e| e.to_string())?;
518 let _ = self.conn.execute_batch(
522 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
523 );
524 }
525 Ok(())
526 }
527
528 fn schema_has_blobs(&self) -> bool {
530 self.schema
531 .tables
532 .iter()
533 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
534 }
535
536 fn get_meta(&self, key: &str) -> Option<String> {
539 self.conn
540 .query_row(
541 "SELECT value FROM _syncular_meta WHERE key = ?1",
542 rusqlite::params![key],
543 |row| row.get::<_, String>(0),
544 )
545 .ok()
546 }
547
548 fn set_meta(&self, key: &str, value: &str) {
549 let _ = self.conn.execute(
550 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
551 rusqlite::params![key, value],
552 );
553 }
554
555 pub fn upgrading(&self) -> bool {
557 self.upgrading
558 }
559
560 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
566 let new_schema = parse_schema_json(schema_json)?;
567 let marker: Option<i32> = self
568 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
569 .and_then(|v| v.parse().ok());
570 self.schema = new_schema;
571 if marker == Some(self.schema.version) {
572 return Ok(());
574 }
575 self.run_schema_reset()
576 }
577
578 fn run_schema_reset(&mut self) -> Result<(), String> {
584 self.upgrading = true;
585 self.insert_sql.borrow_mut().clear();
587 self.overlay_dirty.set(true);
588 let existing: Vec<String> = {
594 let mut stmt = self
595 .conn
596 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
597 .map_err(|e| e.to_string())?;
598 let rows = stmt
599 .query_map([], |row| row.get::<_, String>(0))
600 .map_err(|e| e.to_string())?;
601 rows.filter_map(Result::ok)
602 .filter(|name| is_synced_table_name(name))
603 .collect()
604 };
605 for name in existing {
606 let _ = self
607 .conn
608 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), []);
609 }
610 self.create_synced_tables()?;
612 for sub in &mut self.subs {
614 sub.cursor = -1;
615 sub.bootstrap_state = None;
616 sub.effective = None;
617 sub.state = SubState::Active;
618 sub.reason_code = None;
619 sub.synced_once = false;
620 }
621 let subs = self.subs.clone();
622 for sub in &subs {
623 self.persist_sub(sub);
624 }
625 self.stopped = false;
627 self.schema_floor = None;
628 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
630 self.drop_incompatible_outbox();
634 self.rebuild_overlay();
636 Ok(())
637 }
638
639 fn drop_incompatible_outbox(&mut self) {
643 let schema = &self.schema;
644 let mut incompatible: Vec<String> = Vec::new();
645 self.outbox.retain(|commit| {
646 let bad = commit.ops.iter().any(|op| {
647 if !op.upsert {
648 return false;
649 }
650 match schema.table(&op.table) {
651 None => true,
652 Some(table) => op.values.as_ref().is_some_and(|values| {
653 values
654 .keys()
655 .any(|key| !table.columns.iter().any(|c| &c.name == key))
656 }),
657 }
658 });
659 if bad {
660 incompatible.push(commit.client_commit_id.clone());
661 }
662 !bad
663 });
664 for client_commit_id in incompatible {
665 self.persist_outbox_delete(&client_commit_id);
666 self.rejections.push(RejectionRecord {
667 client_commit_id,
668 op_index: 0,
669 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
670 retryable: false,
671 });
672 }
673 }
674
675 fn create_synced_tables(&self) -> Result<(), String> {
678 for table in &self.schema.tables {
679 for (full, index_prefix) in [
683 (base_table(&table.name), "_syncular_base_"),
684 (visible_table(&table.name), ""),
685 ] {
686 let mut cols: Vec<String> =
687 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
688 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
689 let sql = format!(
690 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
691 cols.join(", "),
692 quote_ident(&table.primary_key)
693 );
694 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
695 for index in &table.indexes {
699 let unique = if index.unique { "UNIQUE " } else { "" };
700 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
701 let cols_sql = index
702 .columns
703 .iter()
704 .map(|c| quote_ident(c))
705 .collect::<Vec<_>>()
706 .join(", ");
707 let index_sql = format!(
708 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
709 );
710 self.conn
711 .execute(&index_sql, [])
712 .map_err(|e| e.to_string())?;
713 }
714 }
715 }
716 Ok(())
717 }
718
719 fn persist_sub(&self, sub: &Subscription) {
722 let state = serde_json::json!({
723 "requested": scope_map_to_json(&sub.requested),
724 "params": sub.params,
725 "cursor": sub.cursor,
726 "bootstrapState": sub.bootstrap_state,
727 "status": sub.state.name(),
728 "reasonCode": sub.reason_code,
729 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
730 "syncedOnce": sub.synced_once,
731 });
732 let _ = self.conn.execute(
733 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
734 rusqlite::params![sub.id, sub.table, state.to_string()],
735 );
736 }
737
738 fn persist_outbox_insert(&self, commit: &OutboxCommit) {
739 let ops: Vec<Value> = commit
740 .ops
741 .iter()
742 .map(|op| {
743 serde_json::json!({
744 "op": if op.upsert { "upsert" } else { "delete" },
745 "table": op.table,
746 "rowId": op.row_id,
747 "baseVersion": op.base_version,
748 "values": op.values.clone().map(Value::Object),
749 })
750 })
751 .collect();
752 let _ = self.conn.execute(
753 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
754 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
755 );
756 }
757
758 fn persist_outbox_delete(&self, client_commit_id: &str) {
759 let _ = self.conn.execute(
760 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
761 rusqlite::params![client_commit_id],
762 );
763 }
764
765 pub fn subscribe(
768 &mut self,
769 id: String,
770 table: String,
771 scopes: Vec<(String, Vec<String>)>,
772 params: Option<String>,
773 ) -> Result<(), String> {
774 if self.schema.table(&table).is_none() {
775 return Err(format!("unknown table {table:?}"));
776 }
777 let sub = Subscription {
778 id: id.clone(),
779 table,
780 requested: scopes,
781 params,
782 cursor: -1,
783 bootstrap_state: None,
784 state: SubState::Active,
785 reason_code: None,
786 effective: None,
787 synced_once: false,
788 };
789 self.persist_sub(&sub);
790 if let Some(existing) = self.subs.iter_mut().find(|s| s.id == id) {
791 *existing = sub;
792 } else {
793 self.subs.push(sub);
794 }
795 Ok(())
796 }
797
798 pub fn unsubscribe(&mut self, id: &str) {
799 self.subs.retain(|s| s.id != id);
800 let _ = self.conn.execute(
801 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
802 rusqlite::params![id],
803 );
804 }
805
806 pub fn set_window(&mut self, base: &WindowBase, units: &[String]) -> Result<(), String> {
814 let table = self
815 .schema
816 .table(&base.table)
817 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
818 if table.scope_column(&base.variable).is_none() {
819 return Err(format!(
820 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
821 base.table, base.variable
822 ));
823 }
824 let base_key = window_base_key(base);
825 let wanted: std::collections::HashSet<&String> = units.iter().collect();
826 let live = self.load_window_units(&base_key);
827
828 for unit in units {
830 if live.iter().any(|(u, _)| u == unit) {
831 continue;
832 }
833 let sub_id = derive_sub_id(base, unit);
834 self.delete_pending_evict(&sub_id);
835 self.insert_window_unit(&base_key, unit, &sub_id);
836 self.subscribe(
837 sub_id,
838 base.table.clone(),
839 unit_scopes(base, unit),
840 base.params.clone(),
841 )?;
842 }
843
844 for (unit, sub_id) in live {
846 if wanted.contains(&unit) {
847 continue;
848 }
849 self.evict_unit(&base_key, base, &unit, &sub_id);
850 }
851 Ok(())
852 }
853
854 pub fn window_state(&self, base: &WindowBase) -> Vec<String> {
856 self.load_window_units(&window_base_key(base))
857 .into_iter()
858 .map(|(unit, _)| unit)
859 .collect()
860 }
861
862 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
863 let mut stmt = match self
864 .conn
865 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
866 {
867 Ok(stmt) => stmt,
868 Err(_) => return Vec::new(),
869 };
870 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
871 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
872 });
873 match rows {
874 Ok(rows) => rows.filter_map(Result::ok).collect(),
875 Err(_) => Vec::new(),
876 }
877 }
878
879 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
880 let _ = self.conn.execute(
881 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
882 rusqlite::params![base_key, unit, sub_id],
883 );
884 }
885
886 fn delete_window_unit(&self, base_key: &str, unit: &str) {
887 let _ = self.conn.execute(
888 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
889 rusqlite::params![base_key, unit],
890 );
891 }
892
893 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
900 let effective = self
901 .subs
902 .iter()
903 .find(|s| s.id == sub_id)
904 .and_then(|s| s.effective.clone())
905 .unwrap_or_else(|| unit_scopes(base, unit));
906 let pinned = self.pinned_row_ids(&base.table);
907 let deferred = self
908 .evict_scope_rows(&base.table, &effective, &pinned)
909 .unwrap_or(false);
910 self.delete_window_unit(base_key, unit);
911 self.unsubscribe(sub_id);
912 if deferred {
913 self.save_pending_evict(sub_id, &base.table, &effective);
914 } else {
915 self.delete_pending_evict(sub_id);
916 }
917 self.rebuild_overlay();
918 }
919
920 fn evict_scope_rows(
924 &mut self,
925 table_name: &str,
926 effective: &[(String, Vec<String>)],
927 pinned: &std::collections::HashSet<String>,
928 ) -> Result<bool, ()> {
929 if effective.is_empty() {
930 return Ok(false);
931 }
932 let table = self.schema.table(table_name).ok_or(())?.clone();
933 let mut clauses = Vec::new();
934 let mut params: Vec<SqlValue> = Vec::new();
935 for (variable, values) in effective {
936 let column = table.scope_column(variable).ok_or(())?;
937 let placeholders: Vec<String> = values
938 .iter()
939 .map(|v| {
940 params.push(SqlValue::Text(v.clone()));
941 "?".to_owned()
942 })
943 .collect();
944 clauses.push(format!(
945 "{} IN ({})",
946 quote_ident(column),
947 placeholders.join(", ")
948 ));
949 }
950 let mut sql = format!(
951 "DELETE FROM {} WHERE {}",
952 base_table(table_name),
953 clauses.join(" AND ")
954 );
955 if !pinned.is_empty() {
956 let pk = quote_ident(&table.primary_key);
957 let holes: Vec<String> = pinned
958 .iter()
959 .map(|id| {
960 params.push(SqlValue::Text(id.clone()));
961 "?".to_owned()
962 })
963 .collect();
964 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
965 }
966 self.overlay_dirty.set(true);
967 self.conn
968 .execute(&sql, rusqlite::params_from_iter(params))
969 .map_err(|_| ())?;
970 if pinned.is_empty() {
971 return Ok(false);
972 }
973 let mut where_params: Vec<SqlValue> = Vec::new();
976 let mut where_clauses = Vec::new();
977 for (variable, values) in effective {
978 let column = table.scope_column(variable).ok_or(())?;
979 let placeholders: Vec<String> = values
980 .iter()
981 .map(|v| {
982 where_params.push(SqlValue::Text(v.clone()));
983 "?".to_owned()
984 })
985 .collect();
986 where_clauses.push(format!(
987 "{} IN ({})",
988 quote_ident(column),
989 placeholders.join(", ")
990 ));
991 }
992 let pk = quote_ident(&table.primary_key);
993 let select = format!(
994 "SELECT {} FROM {} WHERE {}",
995 pk,
996 base_table(table_name),
997 where_clauses.join(" AND ")
998 );
999 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
1000 let survivors: Vec<String> = stmt
1001 .query_map(rusqlite::params_from_iter(where_params), |row| {
1002 row.get::<_, String>(0)
1003 })
1004 .map_err(|_| ())?
1005 .filter_map(Result::ok)
1006 .collect();
1007 Ok(survivors.iter().any(|id| pinned.contains(id)))
1008 }
1009
1010 fn drain_pending_evictions(&mut self) {
1014 let pending = self.load_pending_evictions();
1015 if pending.is_empty() {
1016 return;
1017 }
1018 for (sub_id, table_name, effective) in pending {
1019 if self.schema.table(&table_name).is_none() {
1020 self.delete_pending_evict(&sub_id);
1021 continue;
1022 }
1023 let pinned = self.pinned_row_ids(&table_name);
1024 let deferred = self
1025 .evict_scope_rows(&table_name, &effective, &pinned)
1026 .unwrap_or(false);
1027 if !deferred {
1028 self.delete_pending_evict(&sub_id);
1029 }
1030 }
1031 self.rebuild_overlay_if_dirty();
1032 }
1033
1034 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
1037 let mut pinned = std::collections::HashSet::new();
1038 for commit in &self.outbox {
1039 for op in &commit.ops {
1040 if op.table == table {
1041 pinned.insert(op.row_id.clone());
1042 }
1043 }
1044 }
1045 pinned
1046 }
1047
1048 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
1049 let _ = self.conn.execute(
1050 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
1051 VALUES (?1, ?2, ?3)",
1052 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
1053 );
1054 }
1055
1056 fn delete_pending_evict(&self, sub_id: &str) {
1057 let _ = self.conn.execute(
1058 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
1059 rusqlite::params![sub_id],
1060 );
1061 }
1062
1063 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
1064 let mut stmt = match self
1065 .conn
1066 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
1067 {
1068 Ok(stmt) => stmt,
1069 Err(_) => return Vec::new(),
1070 };
1071 let rows = stmt.query_map([], |row| {
1072 Ok((
1073 row.get::<_, String>(0)?,
1074 row.get::<_, String>(1)?,
1075 row.get::<_, String>(2)?,
1076 ))
1077 });
1078 let mut out = Vec::new();
1079 if let Ok(rows) = rows {
1080 for entry in rows.filter_map(Result::ok) {
1081 let (sub_id, table, json) = entry;
1082 if let Ok(value) = serde_json::from_str::<Value>(&json) {
1083 if let Ok(effective) = json_to_scope_map(&value) {
1084 out.push((sub_id, table, effective));
1085 }
1086 }
1087 }
1088 }
1089 out
1090 }
1091
1092 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
1094 if mutations.is_empty() {
1095 return Err("a commit must contain at least one operation (§6.1)".to_owned());
1096 }
1097 let mut ops = Vec::with_capacity(mutations.len());
1098 for mutation in mutations {
1099 match mutation {
1100 Mutation::Upsert {
1101 table,
1102 values,
1103 base_version,
1104 } => {
1105 let schema_table = self
1106 .schema
1107 .table(&table)
1108 .ok_or_else(|| format!("unknown table {table:?}"))?;
1109 let values = normalize_values_casing(schema_table, values)?;
1113 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
1114 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
1117 ops.push(OutboxOp {
1118 upsert: true,
1119 table,
1120 row_id,
1121 base_version,
1122 values: Some(values),
1123 });
1124 }
1125 Mutation::Delete {
1126 table,
1127 row_id,
1128 base_version,
1129 } => {
1130 if self.schema.table(&table).is_none() {
1131 return Err(format!("unknown table {table:?}"));
1132 }
1133 ops.push(OutboxOp {
1134 upsert: false,
1135 table,
1136 row_id,
1137 base_version,
1138 values: None,
1139 });
1140 }
1141 }
1142 }
1143 let commit = OutboxCommit {
1144 client_commit_id: uuid::Uuid::new_v4().to_string(),
1145 ops,
1146 };
1147 self.persist_outbox_insert(&commit);
1148 let id = commit.client_commit_id.clone();
1149 self.outbox.push(commit);
1150 self.overlay_dirty.set(true);
1151 self.rebuild_overlay();
1152 Ok(id)
1153 }
1154
1155 pub fn pending_commit_ids(&self) -> Vec<String> {
1156 self.outbox
1157 .iter()
1158 .map(|c| c.client_commit_id.clone())
1159 .collect()
1160 }
1161
1162 pub fn conflicts(&self) -> &[ConflictRecord] {
1163 &self.conflicts
1164 }
1165
1166 pub fn rejections(&self) -> &[RejectionRecord] {
1167 &self.rejections
1168 }
1169
1170 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
1171 self.schema_floor.as_ref()
1172 }
1173
1174 pub fn lease_state(&self) -> Option<&LeaseState> {
1176 self.lease_state.as_ref()
1177 }
1178
1179 fn record_lease_error(&mut self, code: &str) {
1182 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
1183 return;
1184 }
1185 let mut next = self.lease_state.clone().unwrap_or_default();
1186 next.error_code = Some(code.to_owned());
1187 self.lease_state = Some(next);
1188 }
1189
1190 pub fn sync_needed(&self) -> bool {
1191 self.sync_needed
1192 }
1193
1194 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
1195 let sub = self.subs.iter().find(|s| s.id == id)?;
1196 Some(SubscriptionStateView {
1197 id: sub.id.clone(),
1198 table: sub.table.clone(),
1199 status: sub.state.name().to_owned(),
1200 cursor: sub.cursor,
1201 has_resume_token: sub.bootstrap_state.is_some(),
1202 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1203 reason_code: sub.reason_code.clone(),
1204 })
1205 }
1206
1207 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
1208 let schema_table = self
1209 .schema
1210 .table(table)
1211 .ok_or_else(|| format!("unknown table {table:?}"))?;
1212 let sql = format!(
1213 "SELECT * FROM {} ORDER BY {} ASC",
1214 visible_table(table),
1215 quote_ident(&schema_table.primary_key)
1216 );
1217 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
1218 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
1219 let mut out = Vec::new();
1220 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
1221 let mut values = Map::new();
1222 for (i, column) in schema_table.columns.iter().enumerate() {
1223 let value = row.get_ref(i).map_err(|e| e.to_string())?;
1224 values.insert(column.name.clone(), sql_ref_to_json(column, value));
1225 }
1226 let version: i64 = row
1227 .get(schema_table.columns.len())
1228 .map_err(|e| e.to_string())?;
1229 let row_id = match values.get(&schema_table.primary_key) {
1230 Some(Value::String(s)) => s.clone(),
1231 Some(Value::Number(n)) => n.to_string(),
1232 Some(Value::Bool(b)) => b.to_string(),
1233 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
1234 };
1235 out.push(RowState {
1236 row_id,
1237 version,
1238 values,
1239 });
1240 }
1241 Ok(out)
1242 }
1243
1244 #[cfg(feature = "crdt-yjs")]
1260 fn crdt_column_bytes(
1261 &self,
1262 table: &str,
1263 row_id: &str,
1264 column: &str,
1265 ) -> Result<Option<Vec<u8>>, String> {
1266 let schema_table = self
1267 .schema
1268 .table(table)
1269 .ok_or_else(|| format!("unknown table {table:?}"))?;
1270 let col = schema_table
1271 .columns
1272 .iter()
1273 .find(|c| c.name == column)
1274 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
1275 if col.ty != ColumnType::Crdt {
1276 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
1277 }
1278 let sql = format!(
1279 "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
1280 quote_ident(column),
1281 visible_table(table),
1282 quote_ident(&schema_table.primary_key)
1283 );
1284 let bytes: Option<Vec<u8>> = self
1285 .conn
1286 .query_row(&sql, rusqlite::params![row_id], |row| {
1287 row.get::<_, Option<Vec<u8>>>(0)
1288 })
1289 .map_err(|e| match e {
1290 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
1291 other => other.to_string(),
1292 })?;
1293 Ok(bytes)
1294 }
1295
1296 #[cfg(feature = "crdt-yjs")]
1300 pub fn crdt_text(
1301 &self,
1302 table: &str,
1303 row_id: &str,
1304 column: &str,
1305 name: &str,
1306 ) -> Result<String, String> {
1307 let bytes = self
1308 .crdt_column_bytes(table, row_id, column)?
1309 .unwrap_or_default();
1310 crate::crdt::text(&bytes, name)
1311 }
1312
1313 #[cfg(feature = "crdt-yjs")]
1317 pub fn crdt_insert_text(
1318 &mut self,
1319 table: &str,
1320 row_id: &str,
1321 column: &str,
1322 name: &str,
1323 index: u32,
1324 value: &str,
1325 ) -> Result<String, String> {
1326 let current = self
1327 .crdt_column_bytes(table, row_id, column)?
1328 .unwrap_or_default();
1329 let update = crate::crdt::insert_text(¤t, name, index, value)?;
1330 self.crdt_push_update(table, row_id, column, &update)
1331 }
1332
1333 #[cfg(feature = "crdt-yjs")]
1336 pub fn crdt_delete_text(
1337 &mut self,
1338 table: &str,
1339 row_id: &str,
1340 column: &str,
1341 name: &str,
1342 index: u32,
1343 len: u32,
1344 ) -> Result<String, String> {
1345 let current = self
1346 .crdt_column_bytes(table, row_id, column)?
1347 .unwrap_or_default();
1348 let update = crate::crdt::delete_text(¤t, name, index, len)?;
1349 self.crdt_push_update(table, row_id, column, &update)
1350 }
1351
1352 #[cfg(feature = "crdt-yjs")]
1357 pub fn crdt_apply_update(
1358 &mut self,
1359 table: &str,
1360 row_id: &str,
1361 column: &str,
1362 update: &[u8],
1363 ) -> Result<String, String> {
1364 let current = self
1365 .crdt_column_bytes(table, row_id, column)?
1366 .unwrap_or_default();
1367 let next = crate::crdt::apply_update(¤t, update)?;
1368 self.crdt_push_update(table, row_id, column, &next)
1369 }
1370
1371 #[cfg(feature = "crdt-yjs")]
1377 fn crdt_push_update(
1378 &mut self,
1379 table: &str,
1380 row_id: &str,
1381 column: &str,
1382 crdt_bytes: &[u8],
1383 ) -> Result<String, String> {
1384 let schema_table = self
1385 .schema
1386 .table(table)
1387 .ok_or_else(|| format!("unknown table {table:?}"))?
1388 .clone();
1389 let mut values: Map<String, Value> = self
1392 .read_rows(table)?
1393 .into_iter()
1394 .find(|r| r.row_id == row_id)
1395 .map(|r| r.values)
1396 .unwrap_or_else(|| {
1397 let mut map = Map::new();
1398 map.insert(
1399 schema_table.primary_key.clone(),
1400 Value::from(row_id.to_owned()),
1401 );
1402 map
1403 });
1404 let mut bytes_obj = Map::new();
1406 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
1407 values.insert(column.to_owned(), Value::Object(bytes_obj));
1408 self.mutate(vec![Mutation::Upsert {
1409 table: table.to_owned(),
1410 values,
1411 base_version: None,
1412 }])
1413 }
1414
1415 pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
1430 crate::query_guard::assert_read_only_query(sql)?;
1431 let bound: Vec<SqlValue> = params
1432 .iter()
1433 .map(json_param_to_sql)
1434 .collect::<Result<_, _>>()?;
1435 let mut stmt = self.conn.prepare(sql).map_err(|e| e.to_string())?;
1436 let column_names: Vec<String> =
1437 stmt.column_names().into_iter().map(str::to_owned).collect();
1438 let bound_refs: Vec<&dyn rusqlite::ToSql> =
1439 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1440 let mut sql_rows = stmt
1441 .query(bound_refs.as_slice())
1442 .map_err(|e| e.to_string())?;
1443 let mut out = Vec::new();
1444 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
1445 let mut record = Map::new();
1446 for (i, name) in column_names.iter().enumerate() {
1447 let value = row.get_ref(i).map_err(|e| e.to_string())?;
1448 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1449 }
1450 out.push(record);
1451 }
1452 Ok(out)
1453 }
1454
1455 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
1458 let mut frames = vec![Frame::ReqHeader {
1459 client_id: self.client_id.clone(),
1460 schema_version: self.schema.version,
1461 }];
1462 let mut pushed_ids = Vec::new();
1463 let mut ops_in_request = 0usize;
1464 let mut deferred_commits = 0usize;
1465 for (index, commit) in self.outbox.iter().enumerate() {
1466 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
1471 deferred_commits = self.outbox.len() - index;
1472 break;
1473 }
1474 ops_in_request += commit.ops.len();
1475 let operations = commit
1476 .ops
1477 .iter()
1478 .map(|op| {
1479 let payload = op.values.as_ref().and_then(|values| {
1480 let table = self.schema.table(&op.table)?;
1481 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
1485 });
1486 ssp2::model::Operation {
1487 table: op.table.clone(),
1488 row_id: op.row_id.clone(),
1489 op: if op.upsert { Op::Upsert } else { Op::Delete },
1490 base_version: op.base_version,
1491 payload,
1492 }
1493 })
1494 .collect();
1495 frames.push(Frame::PushCommit {
1496 client_commit_id: commit.client_commit_id.clone(),
1497 operations,
1498 });
1499 pushed_ids.push(commit.client_commit_id.clone());
1500 }
1501 let accept = self.limits.accept.unwrap_or(if url_capable {
1504 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
1505 } else {
1506 DEFAULT_ACCEPT
1507 });
1508 frames.push(Frame::PullHeader {
1509 limit_commits: self.limits.limit_commits.unwrap_or(0),
1510 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
1511 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
1512 accept,
1513 });
1514 let mut fresh = Vec::new();
1515 for sub in &self.subs {
1516 if sub.state != SubState::Active {
1517 continue;
1518 }
1519 let mut scopes = sub.requested.clone();
1520 sort_scope_map(&mut scopes);
1521 frames.push(Frame::Subscription {
1522 id: sub.id.clone(),
1523 table: sub.table.clone(),
1524 scopes,
1525 params: sub.params.clone().map(RawJson),
1526 cursor: sub.cursor,
1527 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
1528 });
1529 fresh.push((
1530 sub.id.clone(),
1531 sub.cursor < 0 && sub.bootstrap_state.is_none(),
1532 ));
1533 }
1534 let message = Message {
1535 msg_kind: MsgKind::Request,
1536 frames,
1537 };
1538 (
1539 message,
1540 RequestMeta {
1541 pushed_ids,
1542 fresh,
1543 accept,
1544 deferred_commits,
1545 },
1546 )
1547 }
1548
1549 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
1552 if self.stopped {
1553 return SyncOutcome::Ok(SyncReport {
1556 schema_floor: self.schema_floor.clone(),
1557 ..SyncReport::default()
1558 });
1559 }
1560 self.sync_needed = false;
1563 if self.schema_has_blobs() {
1566 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
1567 return SyncOutcome::Failed {
1568 error_code: code,
1569 message,
1570 };
1571 }
1572 }
1573 let (message, meta) = self.build_request(transport.supports_url_fetch());
1574 let request_bytes = encode_message(&message);
1575 let round = if self.realtime_connected {
1579 transport.realtime_sync(&request_bytes)
1580 } else {
1581 transport.sync(&request_bytes)
1582 };
1583 let response_bytes = match round {
1584 Ok(bytes) => bytes,
1585 Err(TransportError { code, message }) => {
1586 self.record_lease_error(&code);
1589 return SyncOutcome::Failed {
1590 error_code: code,
1591 message,
1592 };
1593 }
1594 };
1595 let response = match decode_message(&response_bytes) {
1596 Ok(message) => message,
1597 Err(error) => {
1598 return SyncOutcome::Failed {
1601 error_code: error.code.as_str().to_owned(),
1602 message: error.detail,
1603 };
1604 }
1605 };
1606 if response.msg_kind != MsgKind::Response {
1607 return SyncOutcome::Failed {
1608 error_code: "sync.invalid_request".to_owned(),
1609 message: "expected a response message".to_owned(),
1610 };
1611 }
1612 let outcome = self.process_response(transport, response, &meta);
1613 if meta.deferred_commits > 0 {
1614 self.sync_needed = true;
1617 }
1618 outcome
1619 }
1620
1621 pub fn sync_until_idle(
1622 &mut self,
1623 transport: &mut dyn Transport,
1624 max_rounds: Option<u32>,
1625 ) -> SyncOutcome {
1626 let rounds = max_rounds.unwrap_or(12).max(1);
1627 let mut aggregate = SyncReport::default();
1628 for _ in 0..rounds {
1629 match self.sync(transport) {
1630 SyncOutcome::Failed {
1631 error_code,
1632 message,
1633 } => {
1634 return SyncOutcome::Failed {
1635 error_code,
1636 message,
1637 };
1638 }
1639 SyncOutcome::Ok(report) => {
1640 aggregate.pushed += report.pushed;
1641 aggregate.applied.extend(report.applied.iter().cloned());
1642 aggregate.rejected.extend(report.rejected.iter().cloned());
1643 aggregate.retryable.extend(report.retryable.iter().cloned());
1644 aggregate.conflicts += report.conflicts;
1645 aggregate.commits_applied += report.commits_applied;
1646 aggregate.segment_rows_applied += report.segment_rows_applied;
1647 aggregate.bootstrapping = report.bootstrapping.clone();
1648 aggregate.resets.extend(report.resets.iter().cloned());
1649 aggregate.revoked.extend(report.revoked.iter().cloned());
1650 aggregate.failed.extend(report.failed.iter().cloned());
1651 if report.schema_floor.is_some() {
1652 aggregate.schema_floor = report.schema_floor.clone();
1653 }
1654 let more = !report.bootstrapping.is_empty()
1660 || report.commits_applied > 0
1661 || report.segment_rows_applied > 0
1662 || !report.resets.is_empty()
1663 || self.sync_needed;
1664 if !more {
1665 break;
1666 }
1667 }
1668 }
1669 }
1670 SyncOutcome::Ok(aggregate)
1671 }
1672
1673 fn process_response(
1674 &mut self,
1675 transport: &mut dyn Transport,
1676 response: Message,
1677 meta: &RequestMeta,
1678 ) -> SyncOutcome {
1679 let mut report = SyncReport {
1680 pushed: meta.pushed_ids.len() as u32,
1681 ..SyncReport::default()
1682 };
1683 let mut frames = response.frames.into_iter();
1684 match frames.next() {
1685 Some(Frame::RespHeader {
1686 required_schema_version,
1687 latest_schema_version,
1688 }) => {
1689 if let Some(required) = required_schema_version {
1690 let floor = SchemaFloor {
1693 required_schema_version: Some(required),
1694 latest_schema_version,
1695 };
1696 self.schema_floor = Some(floor.clone());
1697 self.stopped = true;
1698 report.schema_floor = Some(floor);
1699 return SyncOutcome::Ok(report);
1700 }
1701 }
1702 _ => {
1703 return SyncOutcome::Failed {
1704 error_code: "sync.invalid_request".to_owned(),
1705 message: "response does not start with RESP_HEADER".to_owned(),
1706 };
1707 }
1708 }
1709
1710 let mut failure: Option<(String, String)> = None;
1711 while let Some(frame) = frames.next() {
1712 match frame {
1713 Frame::PushResult {
1714 client_commit_id,
1715 status,
1716 commit_seq: _,
1717 results,
1718 } => {
1719 self.handle_push_result(&client_commit_id, status, &results, &mut report);
1720 }
1721 Frame::SubStart {
1722 id,
1723 status,
1724 reason_code,
1725 effective_scopes,
1726 bootstrap: _,
1727 } => {
1728 let mut body = Vec::new();
1729 let mut sub_end: Option<(i64, Option<String>)> = None;
1730 for inner in frames.by_ref() {
1731 match inner {
1732 Frame::SubEnd {
1733 next_cursor,
1734 bootstrap_state,
1735 } => {
1736 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
1737 break;
1738 }
1739 Frame::Unknown { .. } => {}
1740 other => body.push(other),
1741 }
1742 }
1743 let Some((next_cursor, bootstrap_state)) = sub_end else {
1744 failure = Some((
1745 "sync.invalid_request".to_owned(),
1746 "subscription section without SUB_END".to_owned(),
1747 ));
1748 break;
1749 };
1750 if let Err(SectionError::Abort(code, message)) = self.process_section(
1751 transport,
1752 &id,
1753 status,
1754 &reason_code,
1755 effective_scopes,
1756 body,
1757 next_cursor,
1758 bootstrap_state,
1759 meta,
1760 &mut report,
1761 ) {
1762 failure = Some((code, message));
1763 break;
1764 }
1765 }
1766 Frame::Lease {
1767 lease_id,
1768 expires_at_ms,
1769 } => {
1770 self.lease_state = Some(LeaseState {
1773 lease_id: Some(lease_id),
1774 expires_at_ms: Some(expires_at_ms),
1775 error_code: None,
1776 });
1777 }
1778 Frame::Error { code, message, .. } => {
1779 failure = Some((code, message));
1782 break;
1783 }
1784 Frame::Unknown { .. } => {}
1785 _ => {
1786 failure = Some((
1787 "sync.invalid_request".to_owned(),
1788 "unexpected frame in response".to_owned(),
1789 ));
1790 break;
1791 }
1792 }
1793 }
1794
1795 if self.overlay_dirty.get() {
1800 self.rebuild_overlay();
1801 self.reconcile_blob_refcounts(false);
1804 }
1805
1806 if let Some((error_code, message)) = failure {
1807 return SyncOutcome::Failed {
1808 error_code,
1809 message,
1810 };
1811 }
1812 self.drain_pending_evictions();
1815 self.ack_after_pull(transport);
1816 if self.upgrading && report.bootstrapping.is_empty() {
1819 self.upgrading = false;
1820 }
1821 SyncOutcome::Ok(report)
1822 }
1823
1824 fn handle_push_result(
1827 &mut self,
1828 client_commit_id: &str,
1829 status: PushStatus,
1830 results: &[OpResult],
1831 report: &mut SyncReport,
1832 ) {
1833 let Some(index) = self
1834 .outbox
1835 .iter()
1836 .position(|c| c.client_commit_id == client_commit_id)
1837 else {
1838 return;
1839 };
1840 self.overlay_dirty.set(true);
1843 match status {
1844 PushStatus::Applied | PushStatus::Cached => {
1845 report.applied.push(client_commit_id.to_owned());
1848 self.outbox.remove(index);
1849 self.persist_outbox_delete(client_commit_id);
1850 }
1851 PushStatus::Rejected => {
1852 let terminating = results
1853 .iter()
1854 .find(|r| !matches!(r, OpResult::Applied { .. }));
1855 match terminating {
1856 Some(OpResult::Conflict {
1857 op_index,
1858 code,
1859 message: _,
1860 server_version,
1861 server_row,
1862 }) => {
1863 let commit = &self.outbox[index];
1864 let (table, row_id) = commit
1865 .ops
1866 .get(*op_index as usize)
1867 .map(|op| (op.table.clone(), op.row_id.clone()))
1868 .unwrap_or_default();
1869 let server_row_json = self
1870 .schema
1871 .table(&table)
1872 .and_then(|t| {
1873 decode_row_bytes(t, server_row, &self.encryption)
1874 .ok()
1875 .map(|row| (t, row))
1876 })
1877 .map(|(t, row)| {
1878 let mut map = Map::new();
1879 for (i, column) in t.columns.iter().enumerate() {
1880 map.insert(
1881 column.name.clone(),
1882 column_value_to_json(row.get(i).unwrap_or(&None)),
1883 );
1884 }
1885 map
1886 })
1887 .unwrap_or_default();
1888 self.conflicts.push(ConflictRecord {
1889 client_commit_id: client_commit_id.to_owned(),
1890 op_index: *op_index,
1891 table,
1892 row_id,
1893 code: code.clone(),
1894 server_version: *server_version,
1895 server_row: server_row_json,
1896 });
1897 report.conflicts += 1;
1898 report.rejected.push(client_commit_id.to_owned());
1899 self.outbox.remove(index);
1900 self.persist_outbox_delete(client_commit_id);
1901 }
1902 Some(OpResult::Error {
1903 op_index,
1904 code,
1905 message: _,
1906 retryable,
1907 }) => {
1908 if code == "sync.idempotency_cache_miss" {
1909 report.retryable.push(client_commit_id.to_owned());
1912 } else {
1913 self.rejections.push(RejectionRecord {
1914 client_commit_id: client_commit_id.to_owned(),
1915 op_index: *op_index,
1916 code: code.clone(),
1917 retryable: *retryable,
1918 });
1919 report.rejected.push(client_commit_id.to_owned());
1920 self.outbox.remove(index);
1921 self.persist_outbox_delete(client_commit_id);
1922 }
1923 }
1924 _ => {
1925 report.rejected.push(client_commit_id.to_owned());
1926 self.outbox.remove(index);
1927 self.persist_outbox_delete(client_commit_id);
1928 }
1929 }
1930 }
1931 }
1932 }
1933
1934 #[allow(clippy::too_many_arguments)]
1937 fn process_section(
1938 &mut self,
1939 transport: &mut dyn Transport,
1940 id: &str,
1941 status: SubStatus,
1942 reason_code: &str,
1943 effective_scopes: Vec<(String, Vec<String>)>,
1944 body: Vec<Frame>,
1945 next_cursor: i64,
1946 bootstrap_state: Option<String>,
1947 meta: &RequestMeta,
1948 report: &mut SyncReport,
1949 ) -> Result<(), SectionError> {
1950 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
1951 return Ok(()); };
1953 match status {
1954 SubStatus::Revoked => {
1955 let (table, effective) = {
1957 let sub = &self.subs[sub_index];
1958 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
1959 };
1960 let purged = self.purge_scope_rows(&table, &effective);
1961 let sub = &mut self.subs[sub_index];
1962 match purged {
1963 Ok(()) => {
1964 sub.state = SubState::Revoked;
1965 sub.reason_code = Some(if reason_code.is_empty() {
1966 "sync.scope_revoked".to_owned()
1967 } else {
1968 reason_code.to_owned()
1969 });
1970 report.revoked.push(id.to_owned());
1971 let doomed_effective = effective;
1972 let sub_table = table;
1973 self.persist_sub(&self.subs[sub_index].clone());
1974 self.drop_doomed_outbox(&sub_table, &doomed_effective);
1975 self.reconcile_blob_refcounts(true);
1978 }
1979 Err(()) => {
1980 sub.state = SubState::Failed;
1983 sub.reason_code = Some("sync.scope_revoked".to_owned());
1984 report.failed.push(id.to_owned());
1985 self.persist_sub(&self.subs[sub_index].clone());
1986 }
1987 }
1988 Ok(())
1989 }
1990 SubStatus::Reset => {
1991 let sub = &mut self.subs[sub_index];
1994 sub.cursor = -1;
1995 sub.bootstrap_state = None;
1996 report.resets.push(id.to_owned());
1997 self.persist_sub(&self.subs[sub_index].clone());
1998 Ok(())
1999 }
2000 SubStatus::Active => {
2001 let fresh = meta
2002 .fresh
2003 .iter()
2004 .find(|(fid, _)| fid == id)
2005 .map(|(_, f)| *f)
2006 .unwrap_or(false);
2007 self.subs[sub_index].effective = Some(effective_scopes);
2009 self.exec("SAVEPOINT syncular_section");
2010 let outcome =
2011 self.apply_section_body(transport, sub_index, body, fresh, meta, report);
2012 match outcome {
2013 Ok(()) => {
2014 self.exec("RELEASE syncular_section");
2015 let sub = &mut self.subs[sub_index];
2016 sub.cursor = next_cursor;
2019 sub.bootstrap_state = bootstrap_state;
2020 sub.synced_once = true;
2021 if sub.bootstrap_state.is_some() {
2022 report.bootstrapping.push(id.to_owned());
2023 }
2024 self.persist_sub(&self.subs[sub_index].clone());
2025 Ok(())
2026 }
2027 Err(SectionError::FailClosed) => {
2028 self.exec("ROLLBACK TO syncular_section");
2031 self.exec("RELEASE syncular_section");
2032 let sub = &mut self.subs[sub_index];
2033 sub.state = SubState::Failed;
2034 sub.reason_code = Some("sync.scope_revoked".to_owned());
2035 report.failed.push(id.to_owned());
2036 self.persist_sub(&self.subs[sub_index].clone());
2037 Ok(())
2038 }
2039 Err(SectionError::Abort(code, message)) => {
2040 self.exec("ROLLBACK TO syncular_section");
2043 self.exec("RELEASE syncular_section");
2044 Err(SectionError::Abort(code, message))
2045 }
2046 }
2047 }
2048 }
2049 }
2050
2051 fn apply_section_body(
2052 &mut self,
2053 transport: &mut dyn Transport,
2054 sub_index: usize,
2055 body: Vec<Frame>,
2056 fresh: bool,
2057 meta: &RequestMeta,
2058 report: &mut SyncReport,
2059 ) -> Result<(), SectionError> {
2060 let mut saw_segment = false;
2061 for frame in body {
2062 match frame {
2063 Frame::Commit {
2064 tables, changes, ..
2065 } => {
2066 self.apply_commit_changes(&tables, &changes)
2067 .map_err(|(c, m)| SectionError::Abort(c, m))?;
2068 report.commits_applied += 1;
2069 }
2070 Frame::SegmentInline { payload } => {
2071 let segment = decode_rows_segment(&payload)
2072 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
2073 let first = !saw_segment;
2074 saw_segment = true;
2075 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2076 report.segment_rows_applied += applied;
2077 }
2078 Frame::SegmentRef {
2079 segment_id,
2080 media_type,
2081 table,
2082 row_count,
2083 as_of_commit_seq,
2084 scope_digest,
2085 row_cursor,
2086 next_row_cursor,
2087 url,
2088 url_expires_at_ms,
2089 ..
2090 } => {
2091 let advertised = match media_type {
2094 MediaType::Rows => {
2095 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
2096 || meta.accept & ACCEPT_INLINE_ROWS != 0
2097 }
2098 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
2099 };
2100 if !advertised {
2101 return Err(SectionError::Abort(
2102 "sync.invalid_request".to_owned(),
2103 format!(
2104 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
2105 media_type.name()
2106 ),
2107 ));
2108 }
2109 let bytes = if let Some(url) = url {
2110 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
2115 return Err(SectionError::Abort(
2116 "sync.invalid_request".to_owned(),
2117 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
2118 .to_owned(),
2119 ));
2120 }
2121 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2123 return Err(SectionError::Abort(
2124 "sync.segment_expired".to_owned(),
2125 format!(
2126 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
2127 ),
2128 ));
2129 }
2130 transport
2131 .fetch_url(&url)
2132 .map_err(|e| SectionError::Abort(e.code, e.message))?
2133 } else {
2134 let requested_scopes_json =
2135 canonical_scope_json(&self.subs[sub_index].requested);
2136 transport
2137 .download_segment(&SegmentRequest {
2138 segment_id: segment_id.clone(),
2139 table,
2140 requested_scopes_json,
2141 })
2142 .map_err(|e| SectionError::Abort(e.code, e.message))?
2143 };
2144 let digest = Sha256::digest(&bytes);
2146 let expected = segment_id
2147 .strip_prefix("sha256:")
2148 .unwrap_or(segment_id.as_str());
2149 if bytes_to_hex(&digest) != expected {
2150 return Err(SectionError::Abort(
2151 "sync.invalid_request".to_owned(),
2152 "segment bytes do not match the content address (§5.1)".to_owned(),
2153 ));
2154 }
2155 if media_type == MediaType::Sqlite {
2156 if row_cursor.is_some() || next_row_cursor.is_some() {
2159 return Err(SectionError::Abort(
2160 "sync.invalid_request".to_owned(),
2161 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
2162 .to_owned(),
2163 ));
2164 }
2165 let first = !saw_segment;
2166 saw_segment = true;
2167 let applied = self.apply_sqlite_segment(
2168 sub_index,
2169 &bytes,
2170 fresh && first,
2171 row_count,
2172 as_of_commit_seq,
2173 &scope_digest,
2174 )?;
2175 report.segment_rows_applied += applied;
2176 } else {
2177 let segment = decode_rows_segment(&bytes).map_err(|e| {
2178 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
2179 })?;
2180 let first = row_cursor.is_none();
2181 saw_segment = true;
2182 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2183 report.segment_rows_applied += applied;
2184 }
2185 }
2186 Frame::Unknown { .. } => {}
2187 _ => {
2188 return Err(SectionError::Abort(
2189 "sync.invalid_request".to_owned(),
2190 "unexpected frame inside a subscription section".to_owned(),
2191 ));
2192 }
2193 }
2194 }
2195 Ok(())
2196 }
2197
2198 fn apply_commit_changes(
2199 &mut self,
2200 tables: &[String],
2201 changes: &[ssp2::model::Change],
2202 ) -> Result<(), (String, String)> {
2203 for change in changes {
2204 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
2205 (
2206 "sync.invalid_request".to_owned(),
2207 "change tableIndex out of range".to_owned(),
2208 )
2209 })?;
2210 let table = self.schema.table(table_name).ok_or_else(|| {
2211 (
2212 "sync.schema_mismatch".to_owned(),
2213 format!("change targets unknown table {table_name:?}"),
2214 )
2215 })?;
2216 match change.op {
2217 Op::Upsert => {
2218 let payload = change.row.as_ref().ok_or_else(|| {
2219 (
2220 "sync.invalid_request".to_owned(),
2221 "upsert change without row payload".to_owned(),
2222 )
2223 })?;
2224 let row = decode_row_bytes(table, payload, &self.encryption)
2226 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2227 let version = change.row_version.unwrap_or(0);
2228 let table_name = table.name.clone();
2229 self.write_base_row(&table_name, &row, version)
2230 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2231 }
2232 Op::Delete => {
2233 self.delete_base_row(table_name, &change.row_id)
2234 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2235 }
2236 }
2237 }
2238 Ok(())
2239 }
2240
2241 fn apply_segment(
2246 &mut self,
2247 sub_index: usize,
2248 segment: &RowsSegment,
2249 first_fresh_page: bool,
2250 ) -> Result<u32, SectionError> {
2251 let (sub_table, effective) = {
2252 let sub = &self.subs[sub_index];
2253 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2254 };
2255 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2256 SectionError::Abort(
2257 "sync.schema_mismatch".to_owned(),
2258 format!("subscription table {sub_table:?} is not in the client schema"),
2259 )
2260 })?;
2261 let matches = segment.table == table.name
2266 && segment.schema_version == self.schema.version
2267 && segment.columns.len() == table.wire_columns.len()
2268 && segment
2269 .columns
2270 .iter()
2271 .zip(table.wire_columns.iter())
2272 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
2273 if !matches {
2274 return Err(SectionError::Abort(
2275 "sync.schema_mismatch".to_owned(),
2276 "segment column table does not match the generated schema (§5.2)".to_owned(),
2277 ));
2278 }
2279 if first_fresh_page {
2280 self.purge_scope_rows(&table.name, &effective)
2284 .map_err(|()| SectionError::FailClosed)?;
2285 }
2286 let mut applied = 0u32;
2287 for block in &segment.blocks {
2288 for row in block {
2289 let decrypted;
2294 let values = if table.has_encrypted_columns() {
2295 let mut values = row.values.clone();
2296 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
2297 .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
2298 decrypted = values;
2299 &decrypted
2300 } else {
2301 &row.values
2302 };
2303 self.write_base_row(&table.name, values, row.server_version)
2306 .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
2307 applied += 1;
2308 }
2309 }
2310 Ok(applied)
2311 }
2312
2313 fn apply_sqlite_segment(
2321 &mut self,
2322 sub_index: usize,
2323 bytes: &[u8],
2324 first_fresh_page: bool,
2325 row_count: i64,
2326 as_of_commit_seq: i64,
2327 scope_digest: &str,
2328 ) -> Result<u32, SectionError> {
2329 let invalid = |detail: &str| {
2330 SectionError::Abort(
2331 "sync.invalid_request".to_owned(),
2332 format!("sqlite segment rejected: {detail} (§5.3)"),
2333 )
2334 };
2335 let (sub_table, effective) = {
2336 let sub = &self.subs[sub_index];
2337 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2338 };
2339 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2340 SectionError::Abort(
2341 "sync.schema_mismatch".to_owned(),
2342 format!("subscription table {sub_table:?} is not in the client schema"),
2343 )
2344 })?;
2345
2346 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
2347 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
2348 let img = match rusqlite::Connection::open_with_flags(
2349 &path,
2350 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2351 ) {
2352 Ok(conn) => conn,
2353 Err(_) => {
2354 let _ = std::fs::remove_file(&path);
2355 return Err(invalid("bytes do not open as a SQLite database"));
2356 }
2357 };
2358 let outcome = self.apply_sqlite_image(
2359 &img,
2360 &table,
2361 first_fresh_page,
2362 &effective,
2363 row_count,
2364 as_of_commit_seq,
2365 scope_digest,
2366 );
2367 drop(img);
2368 let _ = std::fs::remove_file(&path);
2369 outcome
2370 }
2371
2372 #[allow(clippy::too_many_arguments)]
2373 fn apply_sqlite_image(
2374 &mut self,
2375 img: &rusqlite::Connection,
2376 table: &crate::schema::TableSchema,
2377 first_fresh_page: bool,
2378 effective: &[(String, Vec<String>)],
2379 row_count: i64,
2380 as_of_commit_seq: i64,
2381 scope_digest: &str,
2382 ) -> Result<u32, SectionError> {
2383 let invalid = |detail: String| {
2384 SectionError::Abort(
2385 "sync.invalid_request".to_owned(),
2386 format!("sqlite segment rejected: {detail} (§5.3)"),
2387 )
2388 };
2389
2390 type MetaRow = (i64, String, i64, i64, String, i64, i64);
2393 let meta: MetaRow = img
2394 .query_row(
2395 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
2396 \"scopeDigest\", \"rowCount\",
2397 (SELECT count(*) FROM _syncular_segment)
2398 FROM _syncular_segment",
2399 [],
2400 |row| {
2401 Ok((
2402 row.get(0)?,
2403 row.get(1)?,
2404 row.get(2)?,
2405 row.get(3)?,
2406 row.get(4)?,
2407 row.get(5)?,
2408 row.get(6)?,
2409 ))
2410 },
2411 )
2412 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
2413 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
2414 if meta_count != 1 {
2415 return Err(invalid(format!(
2416 "_syncular_segment must contain exactly one row, found {meta_count}"
2417 )));
2418 }
2419 if format != 1 {
2420 return Err(invalid(format!("format {format}")));
2421 }
2422 if meta_table != table.name {
2423 return Err(invalid(format!("image table {meta_table:?}")));
2424 }
2425 if schema_version != i64::from(self.schema.version) {
2426 return Err(invalid(format!("schemaVersion {schema_version}")));
2427 }
2428 if pin != as_of_commit_seq {
2429 return Err(invalid(format!("asOfCommitSeq {pin}")));
2430 }
2431 if digest != scope_digest {
2432 return Err(invalid("scopeDigest mismatch".to_owned()));
2433 }
2434 if meta_rows != row_count {
2435 return Err(invalid(format!("rowCount {meta_rows}")));
2436 }
2437
2438 let mut names: Vec<String> = Vec::new();
2440 {
2441 let mut stmt = img
2442 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
2443 .map_err(|_| invalid("image data table missing".to_owned()))?;
2444 let mut rows = stmt
2445 .query([])
2446 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2447 while let Some(row) = rows
2448 .next()
2449 .map_err(|_| invalid("image data table unreadable".to_owned()))?
2450 {
2451 names.push(
2452 row.get::<_, String>(1)
2453 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
2454 );
2455 }
2456 }
2457 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
2458 expected.push("_syncular_version");
2459 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
2460 return Err(SectionError::Abort(
2461 "sync.schema_mismatch".to_owned(),
2462 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
2463 ));
2464 }
2465
2466 if first_fresh_page {
2469 self.purge_scope_rows(&table.name, effective)
2470 .map_err(|()| SectionError::FailClosed)?;
2471 }
2472 self.overlay_dirty.set(true);
2478 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
2485 table.indexes.iter().filter(|i| !i.unique).collect()
2486 } else {
2487 Vec::new()
2488 };
2489 for index in &bulk_indexes {
2490 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2491 self.conn
2492 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
2493 .map_err(|e| invalid(e.to_string()))?;
2494 }
2495 let insert = self.insert_row_sql(&base_table(&table.name), table);
2496 let applied = {
2497 let mut ins = self
2498 .conn
2499 .prepare_cached(&insert)
2500 .map_err(|e| invalid(e.to_string()))?;
2501 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
2502 let mut stmt = img
2503 .prepare(&format!(
2504 "SELECT {} FROM {}",
2505 column_list.join(", "),
2506 quote_ident(&table.name)
2507 ))
2508 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2509 let mut rows = stmt
2510 .query([])
2511 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2512 let version_index = table.columns.len();
2513 let mut applied = 0u32;
2514 while let Some(row) = rows
2515 .next()
2516 .map_err(|_| invalid("image row unreadable".to_owned()))?
2517 {
2518 for (i, column) in table.columns.iter().enumerate() {
2519 let cell = row
2520 .get_ref(i)
2521 .map_err(|_| invalid("image row unreadable".to_owned()))?;
2522 let param = image_cell_param(column, cell).map_err(&invalid)?;
2523 ins.raw_bind_parameter(i + 1, param)
2524 .map_err(|e| invalid(e.to_string()))?;
2525 }
2526 let version: i64 = row
2527 .get(version_index)
2528 .map_err(|_| invalid("image row unreadable".to_owned()))?;
2529 if version < 1 {
2530 return Err(invalid(format!(
2531 "row _syncular_version must be >= 1, got {version}"
2532 )));
2533 }
2534 ins.raw_bind_parameter(version_index + 1, version)
2535 .map_err(|e| invalid(e.to_string()))?;
2536 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
2537 applied += 1;
2538 }
2539 applied
2540 };
2541 for index in &bulk_indexes {
2542 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2543 let cols_sql = index
2544 .columns
2545 .iter()
2546 .map(|c| quote_ident(c))
2547 .collect::<Vec<_>>()
2548 .join(", ");
2549 self.conn
2550 .execute(
2551 &format!(
2552 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
2553 base_table(&table.name)
2554 ),
2555 [],
2556 )
2557 .map_err(|e| invalid(e.to_string()))?;
2558 }
2559 if i64::from(applied) != row_count {
2560 return Err(invalid(format!(
2561 "image holds {applied} rows, descriptor says {row_count}"
2562 )));
2563 }
2564 Ok(applied)
2565 }
2566
2567 fn purge_scope_rows(
2572 &mut self,
2573 table_name: &str,
2574 effective: &[(String, Vec<String>)],
2575 ) -> Result<(), ()> {
2576 if effective.is_empty() {
2577 return Ok(());
2578 }
2579 let table = self.schema.table(table_name).ok_or(())?.clone();
2580 let mut clauses = Vec::new();
2581 let mut params: Vec<SqlValue> = Vec::new();
2582 for (variable, values) in effective {
2583 let column = table.scope_column(variable).ok_or(())?;
2584 let placeholders: Vec<String> = values
2585 .iter()
2586 .map(|v| {
2587 params.push(SqlValue::Text(v.clone()));
2588 "?".to_owned()
2589 })
2590 .collect();
2591 clauses.push(format!(
2592 "{} IN ({})",
2593 quote_ident(column),
2594 placeholders.join(", ")
2595 ));
2596 }
2597 let sql = format!(
2598 "DELETE FROM {} WHERE {}",
2599 base_table(table_name),
2600 clauses.join(" AND ")
2601 );
2602 self.overlay_dirty.set(true);
2603 self.conn
2604 .execute(&sql, rusqlite::params_from_iter(params))
2605 .map_err(|_| ())?;
2606 Ok(())
2607 }
2608
2609 fn drop_doomed_outbox(&mut self, table_name: &str, effective: &[(String, Vec<String>)]) {
2612 if effective.is_empty() {
2613 return;
2614 }
2615 let Some(table) = self.schema.table(table_name).cloned() else {
2616 return;
2617 };
2618 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
2619 for (variable, values) in effective {
2620 match table.scope_column(variable) {
2621 Some(column) => mappings.push((column, values)),
2622 None => return, }
2624 }
2625 let doomed: Vec<String> = self
2626 .outbox
2627 .iter()
2628 .filter(|commit| {
2629 commit.ops.iter().any(|op| {
2630 op.upsert
2631 && op.table == table_name
2632 && op.values.as_ref().is_some_and(|values| {
2633 mappings
2634 .iter()
2635 .all(|(column, allowed)| match values.get(*column) {
2636 Some(Value::String(s)) => allowed.contains(s),
2637 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
2638 _ => false,
2639 })
2640 })
2641 })
2642 })
2643 .map(|c| c.client_commit_id.clone())
2644 .collect();
2645 for id in doomed {
2646 self.overlay_dirty.set(true);
2647 self.outbox.retain(|c| c.client_commit_id != id);
2648 self.persist_outbox_delete(&id);
2649 }
2650 }
2651
2652 pub fn upload_blob(
2659 &mut self,
2660 bytes: &[u8],
2661 media_type: Option<String>,
2662 name: Option<String>,
2663 ) -> Result<Value, String> {
2664 let blob_id = blob_id_for(bytes);
2665 let now = self.clock_now_ms();
2666 self.conn
2667 .execute(
2668 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
2669 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
2670 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
2671 )
2672 .map_err(|e| e.to_string())?;
2673 self.conn
2674 .execute(
2675 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
2676 rusqlite::params![blob_id, media_type, now],
2677 )
2678 .map_err(|e| e.to_string())?;
2679 self.enforce_blob_cache_cap();
2682 let mut obj = Map::new();
2683 obj.insert("blobId".to_owned(), Value::from(blob_id));
2684 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
2685 if let Some(mt) = media_type {
2686 obj.insert("mediaType".to_owned(), Value::from(mt));
2687 }
2688 if let Some(n) = name {
2689 obj.insert("name".to_owned(), Value::from(n));
2690 }
2691 Ok(Value::Object(obj))
2692 }
2693
2694 pub fn fetch_blob(
2698 &mut self,
2699 transport: &mut dyn Transport,
2700 blob_id_or_ref: &str,
2701 ) -> Result<Value, (String, String)> {
2702 let simple = |m: String| ("client.failed".to_owned(), m);
2703 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
2704 blob_id_or_ref.to_owned()
2705 } else {
2706 let value: Value = serde_json::from_str(blob_id_or_ref)
2707 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
2708 value
2709 .get("blobId")
2710 .and_then(Value::as_str)
2711 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
2712 .to_owned()
2713 };
2714 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
2715 return Ok(cached);
2716 }
2717 let bytes = match transport
2723 .blob_download(&blob_id)
2724 .map_err(|e| (e.code, e.message))?
2725 {
2726 BlobDownload::Bytes(bytes) => bytes,
2727 BlobDownload::Url {
2728 url,
2729 url_expires_at_ms,
2730 } => {
2731 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2733 return Err((
2734 "sync.segment_expired".to_owned(),
2735 format!(
2736 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
2737 ),
2738 ));
2739 }
2740 transport
2741 .fetch_blob_url(&url)
2742 .map_err(|e| (e.code, e.message))?
2743 }
2744 };
2745 if blob_id_for(&bytes) != blob_id {
2747 return Err(simple(format!(
2748 "blob content address mismatch for {blob_id}"
2749 )));
2750 }
2751 let now = self.clock_now_ms();
2752 self.conn
2753 .execute(
2754 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
2755 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
2756 )
2757 .map_err(|e| simple(e.to_string()))?;
2758 self.enforce_blob_cache_cap();
2759 self.get_cached_blob(&blob_id)
2760 .map_err(simple)?
2761 .ok_or_else(|| simple("blob cache write failed".to_owned()))
2762 }
2763
2764 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
2765 let _ = self.conn.execute(
2768 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
2769 rusqlite::params![self.clock_now_ms(), blob_id],
2770 );
2771 let mut stmt = self
2772 .conn
2773 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
2774 .map_err(|e| e.to_string())?;
2775 let mut rows = stmt
2776 .query(rusqlite::params![blob_id])
2777 .map_err(|e| e.to_string())?;
2778 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
2779 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
2780 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
2781 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
2782 let mut obj = Map::new();
2783 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
2784 obj.insert("byteLength".to_owned(), Value::from(byte_length));
2785 let mut bytes_obj = Map::new();
2786 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
2787 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
2788 if let Some(mt) = media_type {
2789 obj.insert("mediaType".to_owned(), Value::from(mt));
2790 }
2791 return Ok(Some(Value::Object(obj)));
2792 }
2793 Ok(None)
2794 }
2795
2796 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
2798 let pending: Vec<(String, Option<String>)> = {
2799 let mut stmt = self
2800 .conn
2801 .prepare(
2802 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
2803 )
2804 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2805 let rows = stmt
2806 .query_map([], |row| {
2807 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
2808 })
2809 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2810 rows.filter_map(Result::ok).collect()
2811 };
2812 for (blob_id, media_type) in pending {
2813 let bytes: Option<Vec<u8>> = self
2814 .conn
2815 .query_row(
2816 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
2817 rusqlite::params![blob_id],
2818 |row| row.get(0),
2819 )
2820 .ok();
2821 if let Some(bytes) = bytes {
2822 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
2823 }
2824 let _ = self.conn.execute(
2825 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
2826 rusqlite::params![blob_id],
2827 );
2828 }
2829 Ok(())
2830 }
2831
2832 fn upload_one(
2839 &self,
2840 transport: &mut dyn Transport,
2841 blob_id: &str,
2842 bytes: &[u8],
2843 media_type: Option<&str>,
2844 ) -> Result<(), TransportError> {
2845 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
2846 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
2848 url,
2849 url_expires_at_ms,
2850 } => {
2851 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
2852 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
2853 return Ok(());
2854 }
2855 }
2857 BlobUploadGrant::None => {
2858 }
2860 }
2861 transport.blob_upload(blob_id, bytes, media_type)
2862 }
2863
2864 fn enforce_blob_cache_cap(&self) {
2873 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
2874 return;
2875 };
2876 let mut total: i64 = self
2877 .conn
2878 .query_row(
2879 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
2880 [],
2881 |row| row.get(0),
2882 )
2883 .unwrap_or(0);
2884 if total <= max_bytes {
2885 return;
2886 }
2887 let candidates: Vec<(String, i64)> = {
2888 let Ok(mut stmt) = self.conn.prepare(
2889 "SELECT blob_id, byte_length FROM _syncular_blobs
2890 WHERE refcount = 0
2891 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
2892 ORDER BY last_used_ms ASC, created_at_ms ASC",
2893 ) else {
2894 return;
2895 };
2896 let Ok(rows) = stmt.query_map([], |row| {
2897 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2898 }) else {
2899 return;
2900 };
2901 rows.filter_map(Result::ok).collect()
2902 };
2903 for (blob_id, byte_length) in candidates {
2904 if total <= max_bytes {
2905 break;
2906 }
2907 let _ = self.conn.execute(
2908 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
2909 rusqlite::params![blob_id],
2910 );
2911 total -= byte_length;
2912 }
2913 }
2914
2915 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
2919 if !self.schema_has_blobs() {
2920 return;
2921 }
2922 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
2923 for table in self.schema.tables.clone() {
2924 let blob_cols: Vec<String> = table
2925 .columns
2926 .iter()
2927 .filter(|c| c.ty == ColumnType::BlobRef)
2928 .map(|c| c.name.clone())
2929 .collect();
2930 for column in blob_cols {
2931 let sql = format!(
2932 "SELECT {} FROM {} WHERE {} IS NOT NULL",
2933 quote_ident(&column),
2934 base_table(&table.name),
2935 quote_ident(&column)
2936 );
2937 let Ok(mut stmt) = self.conn.prepare(&sql) else {
2938 continue;
2939 };
2940 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
2941 continue;
2942 };
2943 for raw in rows.flatten().flatten() {
2944 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
2945 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
2946 *counts.entry(id.to_owned()).or_insert(0) += 1;
2947 }
2948 }
2949 }
2950 }
2951 }
2952 let _ = self
2953 .conn
2954 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
2955 for (blob_id, count) in &counts {
2956 let _ = self.conn.execute(
2957 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
2958 rusqlite::params![count, blob_id],
2959 );
2960 }
2961 if delete_orphans {
2962 let _ = self.conn.execute(
2963 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
2964 [],
2965 );
2966 }
2967 }
2968
2969 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
2974 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
2975 return sql.clone();
2976 }
2977 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
2978 columns.push(quote_ident("_syncular_version"));
2979 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
2980 let sql = format!(
2981 "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
2982 columns.join(", "),
2983 placeholders.join(", ")
2984 );
2985 self.insert_sql
2986 .borrow_mut()
2987 .insert(full_table.to_owned(), sql.clone());
2988 sql
2989 }
2990
2991 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
2992 self.overlay_dirty.set(true);
2993 self.write_row(&base_table(table_name), table_name, row, version)
2994 }
2995
2996 fn write_row(
2997 &self,
2998 full_table: &str,
2999 table_name: &str,
3000 row: &Row,
3001 version: i64,
3002 ) -> Result<(), String> {
3003 let table = self
3004 .schema
3005 .table(table_name)
3006 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3007 let sql = self.insert_row_sql(full_table, table);
3008 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3009 let params = row
3010 .iter()
3011 .map(RowParam::Cell)
3012 .chain(std::iter::once(RowParam::Version(version)));
3013 stmt.execute(rusqlite::params_from_iter(params))
3014 .map_err(|e| e.to_string())?;
3015 Ok(())
3016 }
3017
3018 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
3019 let table = self
3020 .schema
3021 .table(table_name)
3022 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3023 self.overlay_dirty.set(true);
3024 let sql = format!(
3025 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3026 base_table(table_name),
3027 quote_ident(&table.primary_key)
3028 );
3029 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3030 stmt.execute(rusqlite::params![row_id])
3031 .map_err(|e| e.to_string())?;
3032 Ok(())
3033 }
3034
3035 fn rebuild_overlay_if_dirty(&mut self) {
3038 if self.overlay_dirty.get() {
3039 self.rebuild_overlay();
3040 }
3041 }
3042
3043 fn rebuild_overlay(&mut self) {
3047 self.exec("SAVEPOINT syncular_overlay");
3048 for table in self.schema.tables.clone() {
3049 let visible = visible_table(&table.name);
3050 let base = base_table(&table.name);
3051 self.exec(&format!("DELETE FROM {visible}"));
3052 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
3053 }
3054 for commit in self.outbox.clone() {
3055 for op in &commit.ops {
3056 let Some(table) = self.schema.table(&op.table).cloned() else {
3057 continue;
3058 };
3059 if op.upsert {
3060 let Some(values) = op.values.as_ref() else {
3061 continue;
3062 };
3063 let mut row: Row = Vec::with_capacity(table.columns.len());
3064 let mut ok = true;
3065 for column in &table.columns {
3066 match json_to_column_value(column, values.get(&column.name)) {
3067 Ok(v) => row.push(v),
3068 Err(_) => {
3069 ok = false;
3070 break;
3071 }
3072 }
3073 }
3074 if ok {
3075 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
3076 }
3077 } else {
3078 let sql = format!(
3079 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3080 visible_table(&table.name),
3081 quote_ident(&table.primary_key)
3082 );
3083 let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
3084 }
3085 }
3086 }
3087 self.exec("RELEASE syncular_overlay");
3088 self.overlay_dirty.set(false);
3089 }
3090
3091 fn exec(&self, sql: &str) {
3092 let _ = self.conn.execute_batch(sql);
3093 }
3094
3095 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
3098 transport
3099 .realtime_connect()
3100 .map_err(|e| format!("{}: {}", e.code, e.message))?;
3101 self.realtime_connected = true;
3102 Ok(())
3103 }
3104
3105 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
3106 let _ = transport.realtime_close();
3107 self.realtime_connected = false;
3108 self.presence.clear(); }
3110
3111 pub fn set_presence(
3117 &mut self,
3118 transport: &mut dyn Transport,
3119 scope_key: &str,
3120 doc: Option<&Value>,
3121 ) -> Result<(), String> {
3122 if !self.realtime_connected {
3123 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
3124 }
3125 let text = encode_presence_publish(scope_key, doc);
3126 transport
3127 .realtime_send(&text)
3128 .map_err(|e| format!("{}: {}", e.code, e.message))
3129 }
3130
3131 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
3133 self.presence
3134 .get(scope_key)
3135 .map(|peers| peers.values().cloned().collect())
3136 .unwrap_or_default()
3137 }
3138
3139 fn apply_presence(
3141 &mut self,
3142 scope_key: String,
3143 kind: Option<PresenceKind>,
3144 actor_id: Option<String>,
3145 client_id: Option<String>,
3146 doc: Option<Value>,
3147 error: Option<String>,
3148 ) {
3149 if error.is_some() {
3152 return;
3153 }
3154 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
3155 return;
3156 };
3157 let peer_key = format!("{actor_id} {client_id}");
3158 match kind {
3159 PresenceKind::Leave => {
3160 if let Some(peers) = self.presence.get_mut(&scope_key) {
3161 peers.remove(&peer_key);
3162 if peers.is_empty() {
3163 self.presence.remove(&scope_key);
3164 }
3165 }
3166 }
3167 _ => {
3168 let doc = match doc {
3169 Some(Value::Object(_)) => doc.unwrap(),
3170 _ => return,
3171 };
3172 self.presence.entry(scope_key).or_default().insert(
3173 peer_key,
3174 PresencePeer {
3175 actor_id,
3176 client_id,
3177 doc,
3178 },
3179 );
3180 }
3181 }
3182 }
3183
3184 pub fn on_realtime_text(&mut self, text: &str) {
3186 match parse_control(text) {
3187 Ok(ControlMessage::Hello { requires_sync, .. }) => {
3188 if requires_sync {
3189 self.sync_needed = true;
3191 }
3192 }
3193 Ok(ControlMessage::Presence {
3194 scope_key,
3195 kind,
3196 actor_id,
3197 client_id,
3198 doc,
3199 error,
3200 ..
3201 }) => {
3202 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
3203 }
3204 Ok(ControlMessage::Wake { .. }) => {
3205 self.sync_needed = true;
3207 }
3208 _ => {}
3209 }
3210 }
3211
3212 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
3215 if self.stopped {
3216 return;
3217 }
3218 let message = match decode_message(bytes) {
3219 Ok(m) if m.msg_kind == MsgKind::Response => m,
3220 _ => {
3221 self.sync_needed = true;
3222 return;
3223 }
3224 };
3225 let mut frames = message.frames.into_iter();
3226 let mut applied_cursor: Option<i64> = None;
3227 let mut any_covered = false;
3228 let mut dropped = false;
3229 while let Some(frame) = frames.next() {
3230 let Frame::SubStart {
3231 id,
3232 status,
3233 effective_scopes,
3234 ..
3235 } = frame
3236 else {
3237 continue;
3238 };
3239 let mut body = Vec::new();
3240 let mut next_cursor: Option<i64> = None;
3241 for inner in frames.by_ref() {
3242 match inner {
3243 Frame::SubEnd {
3244 next_cursor: nc, ..
3245 } => {
3246 next_cursor = Some(nc);
3247 break;
3248 }
3249 Frame::Unknown { .. } => {}
3250 other => body.push(other),
3251 }
3252 }
3253 let Some(next_cursor) = next_cursor else {
3254 dropped = true;
3255 break;
3256 };
3257 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3258 dropped = true;
3259 continue;
3260 };
3261 let sub = &self.subs[sub_index];
3262 if status != SubStatus::Active
3265 || sub.state != SubState::Active
3266 || sub.bootstrap_state.is_some()
3267 || !sub.synced_once
3268 {
3269 dropped = true;
3270 continue;
3271 }
3272 if next_cursor <= sub.cursor {
3273 any_covered = true;
3275 continue;
3276 }
3277 self.subs[sub_index].effective = Some(effective_scopes);
3278 self.exec("SAVEPOINT syncular_delta");
3279 let mut failed = false;
3280 for inner in body {
3281 if let Frame::Commit {
3282 tables, changes, ..
3283 } = inner
3284 {
3285 if self.apply_commit_changes(&tables, &changes).is_err() {
3286 failed = true;
3287 break;
3288 }
3289 }
3290 }
3291 if failed {
3292 self.exec("ROLLBACK TO syncular_delta");
3293 self.exec("RELEASE syncular_delta");
3294 dropped = true;
3295 continue;
3296 }
3297 self.exec("RELEASE syncular_delta");
3298 let sub = &mut self.subs[sub_index];
3299 sub.cursor = next_cursor;
3300 self.persist_sub(&self.subs[sub_index].clone());
3301 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
3302 }
3303 if let Some(cursor) = applied_cursor {
3304 self.rebuild_overlay();
3305 self.reconcile_blob_refcounts(false);
3306 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3308 let _ = transport.realtime_send(&ack);
3309 } else if !any_covered || dropped {
3310 self.sync_needed = true;
3312 }
3313 }
3314
3315 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
3319 if !self.realtime_connected {
3320 return;
3321 }
3322 let floor = self
3323 .subs
3324 .iter()
3325 .filter(|s| {
3326 s.state == SubState::Active
3327 && s.bootstrap_state.is_none()
3328 && s.synced_once
3329 && s.cursor >= 0
3330 })
3331 .map(|s| s.cursor)
3332 .min();
3333 if let Some(cursor) = floor {
3334 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3335 let _ = transport.realtime_send(&ack);
3336 }
3337 }
3338}