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