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, WindowState,
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) -> WindowState {
859 let mut units = Vec::new();
860 let mut pending = Vec::new();
861 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
862 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
863 Some(sub) => sub.cursor < 0 || sub.bootstrap_state.is_some(),
864 None => true,
865 };
866 if is_pending {
867 pending.push(unit.clone());
868 }
869 units.push(unit);
870 }
871 WindowState { units, pending }
872 }
873
874 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
875 let mut stmt = match self
876 .conn
877 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
878 {
879 Ok(stmt) => stmt,
880 Err(_) => return Vec::new(),
881 };
882 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
883 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
884 });
885 match rows {
886 Ok(rows) => rows.filter_map(Result::ok).collect(),
887 Err(_) => Vec::new(),
888 }
889 }
890
891 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
892 let _ = self.conn.execute(
893 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
894 rusqlite::params![base_key, unit, sub_id],
895 );
896 }
897
898 fn delete_window_unit(&self, base_key: &str, unit: &str) {
899 let _ = self.conn.execute(
900 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
901 rusqlite::params![base_key, unit],
902 );
903 }
904
905 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
912 let effective = self
913 .subs
914 .iter()
915 .find(|s| s.id == sub_id)
916 .and_then(|s| s.effective.clone())
917 .unwrap_or_else(|| unit_scopes(base, unit));
918 let pinned = self.pinned_row_ids(&base.table);
919 let deferred = self
920 .evict_scope_rows(&base.table, &effective, &pinned)
921 .unwrap_or(false);
922 self.delete_window_unit(base_key, unit);
923 self.unsubscribe(sub_id);
924 if deferred {
925 self.save_pending_evict(sub_id, &base.table, &effective);
926 } else {
927 self.delete_pending_evict(sub_id);
928 }
929 self.rebuild_overlay();
930 }
931
932 fn evict_scope_rows(
936 &mut self,
937 table_name: &str,
938 effective: &[(String, Vec<String>)],
939 pinned: &std::collections::HashSet<String>,
940 ) -> Result<bool, ()> {
941 if effective.is_empty() {
942 return Ok(false);
943 }
944 let table = self.schema.table(table_name).ok_or(())?.clone();
945 let mut clauses = Vec::new();
946 let mut params: Vec<SqlValue> = Vec::new();
947 for (variable, values) in effective {
948 let column = table.scope_column(variable).ok_or(())?;
949 let placeholders: Vec<String> = values
950 .iter()
951 .map(|v| {
952 params.push(SqlValue::Text(v.clone()));
953 "?".to_owned()
954 })
955 .collect();
956 clauses.push(format!(
957 "{} IN ({})",
958 quote_ident(column),
959 placeholders.join(", ")
960 ));
961 }
962 let mut sql = format!(
963 "DELETE FROM {} WHERE {}",
964 base_table(table_name),
965 clauses.join(" AND ")
966 );
967 if !pinned.is_empty() {
968 let pk = quote_ident(&table.primary_key);
969 let holes: Vec<String> = pinned
970 .iter()
971 .map(|id| {
972 params.push(SqlValue::Text(id.clone()));
973 "?".to_owned()
974 })
975 .collect();
976 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
977 }
978 self.overlay_dirty.set(true);
979 self.conn
980 .execute(&sql, rusqlite::params_from_iter(params))
981 .map_err(|_| ())?;
982 if pinned.is_empty() {
983 return Ok(false);
984 }
985 let mut where_params: Vec<SqlValue> = Vec::new();
988 let mut where_clauses = Vec::new();
989 for (variable, values) in effective {
990 let column = table.scope_column(variable).ok_or(())?;
991 let placeholders: Vec<String> = values
992 .iter()
993 .map(|v| {
994 where_params.push(SqlValue::Text(v.clone()));
995 "?".to_owned()
996 })
997 .collect();
998 where_clauses.push(format!(
999 "{} IN ({})",
1000 quote_ident(column),
1001 placeholders.join(", ")
1002 ));
1003 }
1004 let pk = quote_ident(&table.primary_key);
1005 let select = format!(
1006 "SELECT {} FROM {} WHERE {}",
1007 pk,
1008 base_table(table_name),
1009 where_clauses.join(" AND ")
1010 );
1011 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
1012 let survivors: Vec<String> = stmt
1013 .query_map(rusqlite::params_from_iter(where_params), |row| {
1014 row.get::<_, String>(0)
1015 })
1016 .map_err(|_| ())?
1017 .filter_map(Result::ok)
1018 .collect();
1019 Ok(survivors.iter().any(|id| pinned.contains(id)))
1020 }
1021
1022 fn drain_pending_evictions(&mut self) {
1026 let pending = self.load_pending_evictions();
1027 if pending.is_empty() {
1028 return;
1029 }
1030 for (sub_id, table_name, effective) in pending {
1031 if self.schema.table(&table_name).is_none() {
1032 self.delete_pending_evict(&sub_id);
1033 continue;
1034 }
1035 let pinned = self.pinned_row_ids(&table_name);
1036 let deferred = self
1037 .evict_scope_rows(&table_name, &effective, &pinned)
1038 .unwrap_or(false);
1039 if !deferred {
1040 self.delete_pending_evict(&sub_id);
1041 }
1042 }
1043 self.rebuild_overlay_if_dirty();
1044 }
1045
1046 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
1049 let mut pinned = std::collections::HashSet::new();
1050 for commit in &self.outbox {
1051 for op in &commit.ops {
1052 if op.table == table {
1053 pinned.insert(op.row_id.clone());
1054 }
1055 }
1056 }
1057 pinned
1058 }
1059
1060 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
1061 let _ = self.conn.execute(
1062 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
1063 VALUES (?1, ?2, ?3)",
1064 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
1065 );
1066 }
1067
1068 fn delete_pending_evict(&self, sub_id: &str) {
1069 let _ = self.conn.execute(
1070 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
1071 rusqlite::params![sub_id],
1072 );
1073 }
1074
1075 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
1076 let mut stmt = match self
1077 .conn
1078 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
1079 {
1080 Ok(stmt) => stmt,
1081 Err(_) => return Vec::new(),
1082 };
1083 let rows = stmt.query_map([], |row| {
1084 Ok((
1085 row.get::<_, String>(0)?,
1086 row.get::<_, String>(1)?,
1087 row.get::<_, String>(2)?,
1088 ))
1089 });
1090 let mut out = Vec::new();
1091 if let Ok(rows) = rows {
1092 for entry in rows.filter_map(Result::ok) {
1093 let (sub_id, table, json) = entry;
1094 if let Ok(value) = serde_json::from_str::<Value>(&json) {
1095 if let Ok(effective) = json_to_scope_map(&value) {
1096 out.push((sub_id, table, effective));
1097 }
1098 }
1099 }
1100 }
1101 out
1102 }
1103
1104 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
1106 if mutations.is_empty() {
1107 return Err("a commit must contain at least one operation (§6.1)".to_owned());
1108 }
1109 let mut ops = Vec::with_capacity(mutations.len());
1110 for mutation in mutations {
1111 match mutation {
1112 Mutation::Upsert {
1113 table,
1114 values,
1115 base_version,
1116 } => {
1117 let schema_table = self
1118 .schema
1119 .table(&table)
1120 .ok_or_else(|| format!("unknown table {table:?}"))?;
1121 let values = normalize_values_casing(schema_table, values)?;
1125 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
1126 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
1129 ops.push(OutboxOp {
1130 upsert: true,
1131 table,
1132 row_id,
1133 base_version,
1134 values: Some(values),
1135 });
1136 }
1137 Mutation::Delete {
1138 table,
1139 row_id,
1140 base_version,
1141 } => {
1142 if self.schema.table(&table).is_none() {
1143 return Err(format!("unknown table {table:?}"));
1144 }
1145 ops.push(OutboxOp {
1146 upsert: false,
1147 table,
1148 row_id,
1149 base_version,
1150 values: None,
1151 });
1152 }
1153 }
1154 }
1155 let commit = OutboxCommit {
1156 client_commit_id: uuid::Uuid::new_v4().to_string(),
1157 ops,
1158 };
1159 self.persist_outbox_insert(&commit);
1160 let id = commit.client_commit_id.clone();
1161 self.outbox.push(commit);
1162 self.overlay_dirty.set(true);
1163 self.rebuild_overlay();
1164 Ok(id)
1165 }
1166
1167 pub fn pending_commit_ids(&self) -> Vec<String> {
1168 self.outbox
1169 .iter()
1170 .map(|c| c.client_commit_id.clone())
1171 .collect()
1172 }
1173
1174 pub fn conflicts(&self) -> &[ConflictRecord] {
1175 &self.conflicts
1176 }
1177
1178 pub fn rejections(&self) -> &[RejectionRecord] {
1179 &self.rejections
1180 }
1181
1182 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
1183 self.schema_floor.as_ref()
1184 }
1185
1186 pub fn lease_state(&self) -> Option<&LeaseState> {
1188 self.lease_state.as_ref()
1189 }
1190
1191 fn record_lease_error(&mut self, code: &str) {
1194 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
1195 return;
1196 }
1197 let mut next = self.lease_state.clone().unwrap_or_default();
1198 next.error_code = Some(code.to_owned());
1199 self.lease_state = Some(next);
1200 }
1201
1202 pub fn sync_needed(&self) -> bool {
1203 self.sync_needed
1204 }
1205
1206 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
1207 let sub = self.subs.iter().find(|s| s.id == id)?;
1208 Some(SubscriptionStateView {
1209 id: sub.id.clone(),
1210 table: sub.table.clone(),
1211 status: sub.state.name().to_owned(),
1212 cursor: sub.cursor,
1213 has_resume_token: sub.bootstrap_state.is_some(),
1214 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
1215 reason_code: sub.reason_code.clone(),
1216 })
1217 }
1218
1219 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
1220 let schema_table = self
1221 .schema
1222 .table(table)
1223 .ok_or_else(|| format!("unknown table {table:?}"))?;
1224 let sql = format!(
1225 "SELECT * FROM {} ORDER BY {} ASC",
1226 visible_table(table),
1227 quote_ident(&schema_table.primary_key)
1228 );
1229 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
1230 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
1231 let mut out = Vec::new();
1232 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
1233 let mut values = Map::new();
1234 for (i, column) in schema_table.columns.iter().enumerate() {
1235 let value = row.get_ref(i).map_err(|e| e.to_string())?;
1236 values.insert(column.name.clone(), sql_ref_to_json(column, value));
1237 }
1238 let version: i64 = row
1239 .get(schema_table.columns.len())
1240 .map_err(|e| e.to_string())?;
1241 let row_id = match values.get(&schema_table.primary_key) {
1242 Some(Value::String(s)) => s.clone(),
1243 Some(Value::Number(n)) => n.to_string(),
1244 Some(Value::Bool(b)) => b.to_string(),
1245 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
1246 };
1247 out.push(RowState {
1248 row_id,
1249 version,
1250 values,
1251 });
1252 }
1253 Ok(out)
1254 }
1255
1256 #[cfg(feature = "crdt-yjs")]
1272 fn crdt_column_bytes(
1273 &self,
1274 table: &str,
1275 row_id: &str,
1276 column: &str,
1277 ) -> Result<Option<Vec<u8>>, String> {
1278 let schema_table = self
1279 .schema
1280 .table(table)
1281 .ok_or_else(|| format!("unknown table {table:?}"))?;
1282 let col = schema_table
1283 .columns
1284 .iter()
1285 .find(|c| c.name == column)
1286 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
1287 if col.ty != ColumnType::Crdt {
1288 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
1289 }
1290 let sql = format!(
1291 "SELECT {} FROM {} WHERE CAST({} AS TEXT) = ?1",
1292 quote_ident(column),
1293 visible_table(table),
1294 quote_ident(&schema_table.primary_key)
1295 );
1296 let bytes: Option<Vec<u8>> = self
1297 .conn
1298 .query_row(&sql, rusqlite::params![row_id], |row| {
1299 row.get::<_, Option<Vec<u8>>>(0)
1300 })
1301 .map_err(|e| match e {
1302 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
1303 other => other.to_string(),
1304 })?;
1305 Ok(bytes)
1306 }
1307
1308 #[cfg(feature = "crdt-yjs")]
1312 pub fn crdt_text(
1313 &self,
1314 table: &str,
1315 row_id: &str,
1316 column: &str,
1317 name: &str,
1318 ) -> Result<String, String> {
1319 let bytes = self
1320 .crdt_column_bytes(table, row_id, column)?
1321 .unwrap_or_default();
1322 crate::crdt::text(&bytes, name)
1323 }
1324
1325 #[cfg(feature = "crdt-yjs")]
1329 pub fn crdt_insert_text(
1330 &mut self,
1331 table: &str,
1332 row_id: &str,
1333 column: &str,
1334 name: &str,
1335 index: u32,
1336 value: &str,
1337 ) -> Result<String, String> {
1338 let current = self
1339 .crdt_column_bytes(table, row_id, column)?
1340 .unwrap_or_default();
1341 let update = crate::crdt::insert_text(¤t, name, index, value)?;
1342 self.crdt_push_update(table, row_id, column, &update)
1343 }
1344
1345 #[cfg(feature = "crdt-yjs")]
1348 pub fn crdt_delete_text(
1349 &mut self,
1350 table: &str,
1351 row_id: &str,
1352 column: &str,
1353 name: &str,
1354 index: u32,
1355 len: u32,
1356 ) -> Result<String, String> {
1357 let current = self
1358 .crdt_column_bytes(table, row_id, column)?
1359 .unwrap_or_default();
1360 let update = crate::crdt::delete_text(¤t, name, index, len)?;
1361 self.crdt_push_update(table, row_id, column, &update)
1362 }
1363
1364 #[cfg(feature = "crdt-yjs")]
1369 pub fn crdt_apply_update(
1370 &mut self,
1371 table: &str,
1372 row_id: &str,
1373 column: &str,
1374 update: &[u8],
1375 ) -> Result<String, String> {
1376 let current = self
1377 .crdt_column_bytes(table, row_id, column)?
1378 .unwrap_or_default();
1379 let next = crate::crdt::apply_update(¤t, update)?;
1380 self.crdt_push_update(table, row_id, column, &next)
1381 }
1382
1383 #[cfg(feature = "crdt-yjs")]
1389 fn crdt_push_update(
1390 &mut self,
1391 table: &str,
1392 row_id: &str,
1393 column: &str,
1394 crdt_bytes: &[u8],
1395 ) -> Result<String, String> {
1396 let schema_table = self
1397 .schema
1398 .table(table)
1399 .ok_or_else(|| format!("unknown table {table:?}"))?
1400 .clone();
1401 let mut values: Map<String, Value> = self
1404 .read_rows(table)?
1405 .into_iter()
1406 .find(|r| r.row_id == row_id)
1407 .map(|r| r.values)
1408 .unwrap_or_else(|| {
1409 let mut map = Map::new();
1410 map.insert(
1411 schema_table.primary_key.clone(),
1412 Value::from(row_id.to_owned()),
1413 );
1414 map
1415 });
1416 let mut bytes_obj = Map::new();
1418 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
1419 values.insert(column.to_owned(), Value::Object(bytes_obj));
1420 self.mutate(vec![Mutation::Upsert {
1421 table: table.to_owned(),
1422 values,
1423 base_version: None,
1424 }])
1425 }
1426
1427 pub fn query(&self, sql: &str, params: &[Value]) -> Result<Vec<Map<String, Value>>, String> {
1442 crate::query_guard::assert_read_only_query(sql)?;
1443 let bound: Vec<SqlValue> = params
1444 .iter()
1445 .map(json_param_to_sql)
1446 .collect::<Result<_, _>>()?;
1447 let mut stmt = self.conn.prepare(sql).map_err(|e| e.to_string())?;
1448 let column_names: Vec<String> =
1449 stmt.column_names().into_iter().map(str::to_owned).collect();
1450 let bound_refs: Vec<&dyn rusqlite::ToSql> =
1451 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
1452 let mut sql_rows = stmt
1453 .query(bound_refs.as_slice())
1454 .map_err(|e| e.to_string())?;
1455 let mut out = Vec::new();
1456 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
1457 let mut record = Map::new();
1458 for (i, name) in column_names.iter().enumerate() {
1459 let value = row.get_ref(i).map_err(|e| e.to_string())?;
1460 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
1461 }
1462 out.push(record);
1463 }
1464 Ok(out)
1465 }
1466
1467 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
1470 let mut frames = vec![Frame::ReqHeader {
1471 client_id: self.client_id.clone(),
1472 schema_version: self.schema.version,
1473 }];
1474 let mut pushed_ids = Vec::new();
1475 let mut ops_in_request = 0usize;
1476 let mut deferred_commits = 0usize;
1477 for (index, commit) in self.outbox.iter().enumerate() {
1478 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
1483 deferred_commits = self.outbox.len() - index;
1484 break;
1485 }
1486 ops_in_request += commit.ops.len();
1487 let operations = commit
1488 .ops
1489 .iter()
1490 .map(|op| {
1491 let payload = op.values.as_ref().and_then(|values| {
1492 let table = self.schema.table(&op.table)?;
1493 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
1497 });
1498 ssp2::model::Operation {
1499 table: op.table.clone(),
1500 row_id: op.row_id.clone(),
1501 op: if op.upsert { Op::Upsert } else { Op::Delete },
1502 base_version: op.base_version,
1503 payload,
1504 }
1505 })
1506 .collect();
1507 frames.push(Frame::PushCommit {
1508 client_commit_id: commit.client_commit_id.clone(),
1509 operations,
1510 });
1511 pushed_ids.push(commit.client_commit_id.clone());
1512 }
1513 let accept = self.limits.accept.unwrap_or(if url_capable {
1516 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
1517 } else {
1518 DEFAULT_ACCEPT
1519 });
1520 frames.push(Frame::PullHeader {
1521 limit_commits: self.limits.limit_commits.unwrap_or(0),
1522 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
1523 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
1524 accept,
1525 });
1526 let mut fresh = Vec::new();
1527 for sub in &self.subs {
1528 if sub.state != SubState::Active {
1529 continue;
1530 }
1531 let mut scopes = sub.requested.clone();
1532 sort_scope_map(&mut scopes);
1533 frames.push(Frame::Subscription {
1534 id: sub.id.clone(),
1535 table: sub.table.clone(),
1536 scopes,
1537 params: sub.params.clone().map(RawJson),
1538 cursor: sub.cursor,
1539 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
1540 });
1541 fresh.push((
1542 sub.id.clone(),
1543 sub.cursor < 0 && sub.bootstrap_state.is_none(),
1544 ));
1545 }
1546 let message = Message {
1547 msg_kind: MsgKind::Request,
1548 frames,
1549 };
1550 (
1551 message,
1552 RequestMeta {
1553 pushed_ids,
1554 fresh,
1555 accept,
1556 deferred_commits,
1557 },
1558 )
1559 }
1560
1561 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
1564 if self.stopped {
1565 return SyncOutcome::Ok(SyncReport {
1568 schema_floor: self.schema_floor.clone(),
1569 ..SyncReport::default()
1570 });
1571 }
1572 self.sync_needed = false;
1575 if self.schema_has_blobs() {
1578 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
1579 return SyncOutcome::Failed {
1580 error_code: code,
1581 message,
1582 };
1583 }
1584 }
1585 let (message, meta) = self.build_request(transport.supports_url_fetch());
1586 let request_bytes = encode_message(&message);
1587 let round = if self.realtime_connected {
1591 transport.realtime_sync(&request_bytes)
1592 } else {
1593 transport.sync(&request_bytes)
1594 };
1595 let response_bytes = match round {
1596 Ok(bytes) => bytes,
1597 Err(TransportError { code, message }) => {
1598 self.record_lease_error(&code);
1601 return SyncOutcome::Failed {
1602 error_code: code,
1603 message,
1604 };
1605 }
1606 };
1607 let response = match decode_message(&response_bytes) {
1608 Ok(message) => message,
1609 Err(error) => {
1610 return SyncOutcome::Failed {
1613 error_code: error.code.as_str().to_owned(),
1614 message: error.detail,
1615 };
1616 }
1617 };
1618 if response.msg_kind != MsgKind::Response {
1619 return SyncOutcome::Failed {
1620 error_code: "sync.invalid_request".to_owned(),
1621 message: "expected a response message".to_owned(),
1622 };
1623 }
1624 let outcome = self.process_response(transport, response, &meta);
1625 if meta.deferred_commits > 0 {
1626 self.sync_needed = true;
1629 }
1630 outcome
1631 }
1632
1633 pub fn sync_until_idle(
1634 &mut self,
1635 transport: &mut dyn Transport,
1636 max_rounds: Option<u32>,
1637 ) -> SyncOutcome {
1638 let rounds = max_rounds.unwrap_or(12).max(1);
1639 let mut aggregate = SyncReport::default();
1640 for _ in 0..rounds {
1641 match self.sync(transport) {
1642 SyncOutcome::Failed {
1643 error_code,
1644 message,
1645 } => {
1646 return SyncOutcome::Failed {
1647 error_code,
1648 message,
1649 };
1650 }
1651 SyncOutcome::Ok(report) => {
1652 aggregate.pushed += report.pushed;
1653 aggregate.applied.extend(report.applied.iter().cloned());
1654 aggregate.rejected.extend(report.rejected.iter().cloned());
1655 aggregate.retryable.extend(report.retryable.iter().cloned());
1656 aggregate.conflicts += report.conflicts;
1657 aggregate.commits_applied += report.commits_applied;
1658 aggregate.segment_rows_applied += report.segment_rows_applied;
1659 aggregate.bootstrapping = report.bootstrapping.clone();
1660 aggregate.resets.extend(report.resets.iter().cloned());
1661 aggregate.revoked.extend(report.revoked.iter().cloned());
1662 aggregate.failed.extend(report.failed.iter().cloned());
1663 if report.schema_floor.is_some() {
1664 aggregate.schema_floor = report.schema_floor.clone();
1665 }
1666 let more = !report.bootstrapping.is_empty()
1672 || report.commits_applied > 0
1673 || report.segment_rows_applied > 0
1674 || !report.resets.is_empty()
1675 || self.sync_needed;
1676 if !more {
1677 break;
1678 }
1679 }
1680 }
1681 }
1682 SyncOutcome::Ok(aggregate)
1683 }
1684
1685 fn process_response(
1686 &mut self,
1687 transport: &mut dyn Transport,
1688 response: Message,
1689 meta: &RequestMeta,
1690 ) -> SyncOutcome {
1691 let mut report = SyncReport {
1692 pushed: meta.pushed_ids.len() as u32,
1693 ..SyncReport::default()
1694 };
1695 let mut frames = response.frames.into_iter();
1696 match frames.next() {
1697 Some(Frame::RespHeader {
1698 required_schema_version,
1699 latest_schema_version,
1700 }) => {
1701 if let Some(required) = required_schema_version {
1702 let floor = SchemaFloor {
1705 required_schema_version: Some(required),
1706 latest_schema_version,
1707 };
1708 self.schema_floor = Some(floor.clone());
1709 self.stopped = true;
1710 report.schema_floor = Some(floor);
1711 return SyncOutcome::Ok(report);
1712 }
1713 }
1714 _ => {
1715 return SyncOutcome::Failed {
1716 error_code: "sync.invalid_request".to_owned(),
1717 message: "response does not start with RESP_HEADER".to_owned(),
1718 };
1719 }
1720 }
1721
1722 let mut failure: Option<(String, String)> = None;
1723 while let Some(frame) = frames.next() {
1724 match frame {
1725 Frame::PushResult {
1726 client_commit_id,
1727 status,
1728 commit_seq: _,
1729 results,
1730 } => {
1731 self.handle_push_result(&client_commit_id, status, &results, &mut report);
1732 }
1733 Frame::SubStart {
1734 id,
1735 status,
1736 reason_code,
1737 effective_scopes,
1738 bootstrap: _,
1739 } => {
1740 let mut body = Vec::new();
1741 let mut sub_end: Option<(i64, Option<String>)> = None;
1742 for inner in frames.by_ref() {
1743 match inner {
1744 Frame::SubEnd {
1745 next_cursor,
1746 bootstrap_state,
1747 } => {
1748 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
1749 break;
1750 }
1751 Frame::Unknown { .. } => {}
1752 other => body.push(other),
1753 }
1754 }
1755 let Some((next_cursor, bootstrap_state)) = sub_end else {
1756 failure = Some((
1757 "sync.invalid_request".to_owned(),
1758 "subscription section without SUB_END".to_owned(),
1759 ));
1760 break;
1761 };
1762 if let Err(SectionError::Abort(code, message)) = self.process_section(
1763 transport,
1764 &id,
1765 status,
1766 &reason_code,
1767 effective_scopes,
1768 body,
1769 next_cursor,
1770 bootstrap_state,
1771 meta,
1772 &mut report,
1773 ) {
1774 failure = Some((code, message));
1775 break;
1776 }
1777 }
1778 Frame::Lease {
1779 lease_id,
1780 expires_at_ms,
1781 } => {
1782 self.lease_state = Some(LeaseState {
1785 lease_id: Some(lease_id),
1786 expires_at_ms: Some(expires_at_ms),
1787 error_code: None,
1788 });
1789 }
1790 Frame::Error { code, message, .. } => {
1791 failure = Some((code, message));
1794 break;
1795 }
1796 Frame::Unknown { .. } => {}
1797 _ => {
1798 failure = Some((
1799 "sync.invalid_request".to_owned(),
1800 "unexpected frame in response".to_owned(),
1801 ));
1802 break;
1803 }
1804 }
1805 }
1806
1807 if self.overlay_dirty.get() {
1812 self.rebuild_overlay();
1813 self.reconcile_blob_refcounts(false);
1816 }
1817
1818 if let Some((error_code, message)) = failure {
1819 return SyncOutcome::Failed {
1820 error_code,
1821 message,
1822 };
1823 }
1824 self.drain_pending_evictions();
1827 self.ack_after_pull(transport);
1828 if self.upgrading && report.bootstrapping.is_empty() {
1831 self.upgrading = false;
1832 }
1833 SyncOutcome::Ok(report)
1834 }
1835
1836 fn handle_push_result(
1839 &mut self,
1840 client_commit_id: &str,
1841 status: PushStatus,
1842 results: &[OpResult],
1843 report: &mut SyncReport,
1844 ) {
1845 let Some(index) = self
1846 .outbox
1847 .iter()
1848 .position(|c| c.client_commit_id == client_commit_id)
1849 else {
1850 return;
1851 };
1852 self.overlay_dirty.set(true);
1855 match status {
1856 PushStatus::Applied | PushStatus::Cached => {
1857 report.applied.push(client_commit_id.to_owned());
1860 self.outbox.remove(index);
1861 self.persist_outbox_delete(client_commit_id);
1862 }
1863 PushStatus::Rejected => {
1864 let terminating = results
1865 .iter()
1866 .find(|r| !matches!(r, OpResult::Applied { .. }));
1867 match terminating {
1868 Some(OpResult::Conflict {
1869 op_index,
1870 code,
1871 message: _,
1872 server_version,
1873 server_row,
1874 }) => {
1875 let commit = &self.outbox[index];
1876 let (table, row_id) = commit
1877 .ops
1878 .get(*op_index as usize)
1879 .map(|op| (op.table.clone(), op.row_id.clone()))
1880 .unwrap_or_default();
1881 let server_row_json = self
1882 .schema
1883 .table(&table)
1884 .and_then(|t| {
1885 decode_row_bytes(t, server_row, &self.encryption)
1886 .ok()
1887 .map(|row| (t, row))
1888 })
1889 .map(|(t, row)| {
1890 let mut map = Map::new();
1891 for (i, column) in t.columns.iter().enumerate() {
1892 map.insert(
1893 column.name.clone(),
1894 column_value_to_json(row.get(i).unwrap_or(&None)),
1895 );
1896 }
1897 map
1898 })
1899 .unwrap_or_default();
1900 self.conflicts.push(ConflictRecord {
1901 client_commit_id: client_commit_id.to_owned(),
1902 op_index: *op_index,
1903 table,
1904 row_id,
1905 code: code.clone(),
1906 server_version: *server_version,
1907 server_row: server_row_json,
1908 });
1909 report.conflicts += 1;
1910 report.rejected.push(client_commit_id.to_owned());
1911 self.outbox.remove(index);
1912 self.persist_outbox_delete(client_commit_id);
1913 }
1914 Some(OpResult::Error {
1915 op_index,
1916 code,
1917 message: _,
1918 retryable,
1919 }) => {
1920 if code == "sync.idempotency_cache_miss" {
1921 report.retryable.push(client_commit_id.to_owned());
1924 } else {
1925 self.rejections.push(RejectionRecord {
1926 client_commit_id: client_commit_id.to_owned(),
1927 op_index: *op_index,
1928 code: code.clone(),
1929 retryable: *retryable,
1930 });
1931 report.rejected.push(client_commit_id.to_owned());
1932 self.outbox.remove(index);
1933 self.persist_outbox_delete(client_commit_id);
1934 }
1935 }
1936 _ => {
1937 report.rejected.push(client_commit_id.to_owned());
1938 self.outbox.remove(index);
1939 self.persist_outbox_delete(client_commit_id);
1940 }
1941 }
1942 }
1943 }
1944 }
1945
1946 #[allow(clippy::too_many_arguments)]
1949 fn process_section(
1950 &mut self,
1951 transport: &mut dyn Transport,
1952 id: &str,
1953 status: SubStatus,
1954 reason_code: &str,
1955 effective_scopes: Vec<(String, Vec<String>)>,
1956 body: Vec<Frame>,
1957 next_cursor: i64,
1958 bootstrap_state: Option<String>,
1959 meta: &RequestMeta,
1960 report: &mut SyncReport,
1961 ) -> Result<(), SectionError> {
1962 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
1963 return Ok(()); };
1965 match status {
1966 SubStatus::Revoked => {
1967 let (table, effective) = {
1969 let sub = &self.subs[sub_index];
1970 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
1971 };
1972 let purged = self.purge_scope_rows(&table, &effective);
1973 let sub = &mut self.subs[sub_index];
1974 match purged {
1975 Ok(()) => {
1976 sub.state = SubState::Revoked;
1977 sub.reason_code = Some(if reason_code.is_empty() {
1978 "sync.scope_revoked".to_owned()
1979 } else {
1980 reason_code.to_owned()
1981 });
1982 report.revoked.push(id.to_owned());
1983 let doomed_effective = effective;
1984 let sub_table = table;
1985 self.persist_sub(&self.subs[sub_index].clone());
1986 self.drop_doomed_outbox(&sub_table, &doomed_effective);
1987 self.reconcile_blob_refcounts(true);
1990 }
1991 Err(()) => {
1992 sub.state = SubState::Failed;
1995 sub.reason_code = Some("sync.scope_revoked".to_owned());
1996 report.failed.push(id.to_owned());
1997 self.persist_sub(&self.subs[sub_index].clone());
1998 }
1999 }
2000 Ok(())
2001 }
2002 SubStatus::Reset => {
2003 let sub = &mut self.subs[sub_index];
2006 sub.cursor = -1;
2007 sub.bootstrap_state = None;
2008 report.resets.push(id.to_owned());
2009 self.persist_sub(&self.subs[sub_index].clone());
2010 Ok(())
2011 }
2012 SubStatus::Active => {
2013 let fresh = meta
2014 .fresh
2015 .iter()
2016 .find(|(fid, _)| fid == id)
2017 .map(|(_, f)| *f)
2018 .unwrap_or(false);
2019 self.subs[sub_index].effective = Some(effective_scopes);
2021 self.exec("SAVEPOINT syncular_section");
2022 let outcome =
2023 self.apply_section_body(transport, sub_index, body, fresh, meta, report);
2024 match outcome {
2025 Ok(()) => {
2026 self.exec("RELEASE syncular_section");
2027 let sub = &mut self.subs[sub_index];
2028 sub.cursor = next_cursor;
2031 sub.bootstrap_state = bootstrap_state;
2032 sub.synced_once = true;
2033 if sub.bootstrap_state.is_some() {
2034 report.bootstrapping.push(id.to_owned());
2035 }
2036 self.persist_sub(&self.subs[sub_index].clone());
2037 Ok(())
2038 }
2039 Err(SectionError::FailClosed) => {
2040 self.exec("ROLLBACK TO syncular_section");
2043 self.exec("RELEASE syncular_section");
2044 let sub = &mut self.subs[sub_index];
2045 sub.state = SubState::Failed;
2046 sub.reason_code = Some("sync.scope_revoked".to_owned());
2047 report.failed.push(id.to_owned());
2048 self.persist_sub(&self.subs[sub_index].clone());
2049 Ok(())
2050 }
2051 Err(SectionError::Abort(code, message)) => {
2052 self.exec("ROLLBACK TO syncular_section");
2055 self.exec("RELEASE syncular_section");
2056 Err(SectionError::Abort(code, message))
2057 }
2058 }
2059 }
2060 }
2061 }
2062
2063 fn apply_section_body(
2064 &mut self,
2065 transport: &mut dyn Transport,
2066 sub_index: usize,
2067 body: Vec<Frame>,
2068 fresh: bool,
2069 meta: &RequestMeta,
2070 report: &mut SyncReport,
2071 ) -> Result<(), SectionError> {
2072 let mut saw_segment = false;
2073 for frame in body {
2074 match frame {
2075 Frame::Commit {
2076 tables, changes, ..
2077 } => {
2078 self.apply_commit_changes(&tables, &changes)
2079 .map_err(|(c, m)| SectionError::Abort(c, m))?;
2080 report.commits_applied += 1;
2081 }
2082 Frame::SegmentInline { payload } => {
2083 let segment = decode_rows_segment(&payload)
2084 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
2085 let first = !saw_segment;
2086 saw_segment = true;
2087 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2088 report.segment_rows_applied += applied;
2089 }
2090 Frame::SegmentRef {
2091 segment_id,
2092 media_type,
2093 table,
2094 row_count,
2095 as_of_commit_seq,
2096 scope_digest,
2097 row_cursor,
2098 next_row_cursor,
2099 url,
2100 url_expires_at_ms,
2101 ..
2102 } => {
2103 let advertised = match media_type {
2106 MediaType::Rows => {
2107 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
2108 || meta.accept & ACCEPT_INLINE_ROWS != 0
2109 }
2110 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
2111 };
2112 if !advertised {
2113 return Err(SectionError::Abort(
2114 "sync.invalid_request".to_owned(),
2115 format!(
2116 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
2117 media_type.name()
2118 ),
2119 ));
2120 }
2121 let bytes = if let Some(url) = url {
2122 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
2127 return Err(SectionError::Abort(
2128 "sync.invalid_request".to_owned(),
2129 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
2130 .to_owned(),
2131 ));
2132 }
2133 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2135 return Err(SectionError::Abort(
2136 "sync.segment_expired".to_owned(),
2137 format!(
2138 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
2139 ),
2140 ));
2141 }
2142 transport
2143 .fetch_url(&url)
2144 .map_err(|e| SectionError::Abort(e.code, e.message))?
2145 } else {
2146 let requested_scopes_json =
2147 canonical_scope_json(&self.subs[sub_index].requested);
2148 transport
2149 .download_segment(&SegmentRequest {
2150 segment_id: segment_id.clone(),
2151 table,
2152 requested_scopes_json,
2153 })
2154 .map_err(|e| SectionError::Abort(e.code, e.message))?
2155 };
2156 let digest = Sha256::digest(&bytes);
2158 let expected = segment_id
2159 .strip_prefix("sha256:")
2160 .unwrap_or(segment_id.as_str());
2161 if bytes_to_hex(&digest) != expected {
2162 return Err(SectionError::Abort(
2163 "sync.invalid_request".to_owned(),
2164 "segment bytes do not match the content address (§5.1)".to_owned(),
2165 ));
2166 }
2167 if media_type == MediaType::Sqlite {
2168 if row_cursor.is_some() || next_row_cursor.is_some() {
2171 return Err(SectionError::Abort(
2172 "sync.invalid_request".to_owned(),
2173 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
2174 .to_owned(),
2175 ));
2176 }
2177 let first = !saw_segment;
2178 saw_segment = true;
2179 let applied = self.apply_sqlite_segment(
2180 sub_index,
2181 &bytes,
2182 fresh && first,
2183 row_count,
2184 as_of_commit_seq,
2185 &scope_digest,
2186 )?;
2187 report.segment_rows_applied += applied;
2188 } else {
2189 let segment = decode_rows_segment(&bytes).map_err(|e| {
2190 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
2191 })?;
2192 let first = row_cursor.is_none();
2193 saw_segment = true;
2194 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
2195 report.segment_rows_applied += applied;
2196 }
2197 }
2198 Frame::Unknown { .. } => {}
2199 _ => {
2200 return Err(SectionError::Abort(
2201 "sync.invalid_request".to_owned(),
2202 "unexpected frame inside a subscription section".to_owned(),
2203 ));
2204 }
2205 }
2206 }
2207 Ok(())
2208 }
2209
2210 fn apply_commit_changes(
2211 &mut self,
2212 tables: &[String],
2213 changes: &[ssp2::model::Change],
2214 ) -> Result<(), (String, String)> {
2215 for change in changes {
2216 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
2217 (
2218 "sync.invalid_request".to_owned(),
2219 "change tableIndex out of range".to_owned(),
2220 )
2221 })?;
2222 let table = self.schema.table(table_name).ok_or_else(|| {
2223 (
2224 "sync.schema_mismatch".to_owned(),
2225 format!("change targets unknown table {table_name:?}"),
2226 )
2227 })?;
2228 match change.op {
2229 Op::Upsert => {
2230 let payload = change.row.as_ref().ok_or_else(|| {
2231 (
2232 "sync.invalid_request".to_owned(),
2233 "upsert change without row payload".to_owned(),
2234 )
2235 })?;
2236 let row = decode_row_bytes(table, payload, &self.encryption)
2238 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2239 let version = change.row_version.unwrap_or(0);
2240 let table_name = table.name.clone();
2241 self.write_base_row(&table_name, &row, version)
2242 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2243 }
2244 Op::Delete => {
2245 self.delete_base_row(table_name, &change.row_id)
2246 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
2247 }
2248 }
2249 }
2250 Ok(())
2251 }
2252
2253 fn apply_segment(
2258 &mut self,
2259 sub_index: usize,
2260 segment: &RowsSegment,
2261 first_fresh_page: bool,
2262 ) -> Result<u32, SectionError> {
2263 let (sub_table, effective) = {
2264 let sub = &self.subs[sub_index];
2265 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2266 };
2267 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2268 SectionError::Abort(
2269 "sync.schema_mismatch".to_owned(),
2270 format!("subscription table {sub_table:?} is not in the client schema"),
2271 )
2272 })?;
2273 let matches = segment.table == table.name
2278 && segment.schema_version == self.schema.version
2279 && segment.columns.len() == table.wire_columns.len()
2280 && segment
2281 .columns
2282 .iter()
2283 .zip(table.wire_columns.iter())
2284 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
2285 if !matches {
2286 return Err(SectionError::Abort(
2287 "sync.schema_mismatch".to_owned(),
2288 "segment column table does not match the generated schema (§5.2)".to_owned(),
2289 ));
2290 }
2291 if first_fresh_page {
2292 self.purge_scope_rows(&table.name, &effective)
2296 .map_err(|()| SectionError::FailClosed)?;
2297 }
2298 let mut applied = 0u32;
2299 for block in &segment.blocks {
2300 for row in block {
2301 let decrypted;
2306 let values = if table.has_encrypted_columns() {
2307 let mut values = row.values.clone();
2308 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
2309 .map_err(|m| SectionError::Abort("client.decrypt_failed".to_owned(), m))?;
2310 decrypted = values;
2311 &decrypted
2312 } else {
2313 &row.values
2314 };
2315 self.write_base_row(&table.name, values, row.server_version)
2318 .map_err(|m| SectionError::Abort("sync.invalid_request".to_owned(), m))?;
2319 applied += 1;
2320 }
2321 }
2322 Ok(applied)
2323 }
2324
2325 fn apply_sqlite_segment(
2333 &mut self,
2334 sub_index: usize,
2335 bytes: &[u8],
2336 first_fresh_page: bool,
2337 row_count: i64,
2338 as_of_commit_seq: i64,
2339 scope_digest: &str,
2340 ) -> Result<u32, SectionError> {
2341 let invalid = |detail: &str| {
2342 SectionError::Abort(
2343 "sync.invalid_request".to_owned(),
2344 format!("sqlite segment rejected: {detail} (§5.3)"),
2345 )
2346 };
2347 let (sub_table, effective) = {
2348 let sub = &self.subs[sub_index];
2349 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
2350 };
2351 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
2352 SectionError::Abort(
2353 "sync.schema_mismatch".to_owned(),
2354 format!("subscription table {sub_table:?} is not in the client schema"),
2355 )
2356 })?;
2357
2358 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
2359 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
2360 let img = match rusqlite::Connection::open_with_flags(
2361 &path,
2362 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
2363 ) {
2364 Ok(conn) => conn,
2365 Err(_) => {
2366 let _ = std::fs::remove_file(&path);
2367 return Err(invalid("bytes do not open as a SQLite database"));
2368 }
2369 };
2370 let outcome = self.apply_sqlite_image(
2371 &img,
2372 &table,
2373 first_fresh_page,
2374 &effective,
2375 row_count,
2376 as_of_commit_seq,
2377 scope_digest,
2378 );
2379 drop(img);
2380 let _ = std::fs::remove_file(&path);
2381 outcome
2382 }
2383
2384 #[allow(clippy::too_many_arguments)]
2385 fn apply_sqlite_image(
2386 &mut self,
2387 img: &rusqlite::Connection,
2388 table: &crate::schema::TableSchema,
2389 first_fresh_page: bool,
2390 effective: &[(String, Vec<String>)],
2391 row_count: i64,
2392 as_of_commit_seq: i64,
2393 scope_digest: &str,
2394 ) -> Result<u32, SectionError> {
2395 let invalid = |detail: String| {
2396 SectionError::Abort(
2397 "sync.invalid_request".to_owned(),
2398 format!("sqlite segment rejected: {detail} (§5.3)"),
2399 )
2400 };
2401
2402 type MetaRow = (i64, String, i64, i64, String, i64, i64);
2405 let meta: MetaRow = img
2406 .query_row(
2407 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
2408 \"scopeDigest\", \"rowCount\",
2409 (SELECT count(*) FROM _syncular_segment)
2410 FROM _syncular_segment",
2411 [],
2412 |row| {
2413 Ok((
2414 row.get(0)?,
2415 row.get(1)?,
2416 row.get(2)?,
2417 row.get(3)?,
2418 row.get(4)?,
2419 row.get(5)?,
2420 row.get(6)?,
2421 ))
2422 },
2423 )
2424 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
2425 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
2426 if meta_count != 1 {
2427 return Err(invalid(format!(
2428 "_syncular_segment must contain exactly one row, found {meta_count}"
2429 )));
2430 }
2431 if format != 1 {
2432 return Err(invalid(format!("format {format}")));
2433 }
2434 if meta_table != table.name {
2435 return Err(invalid(format!("image table {meta_table:?}")));
2436 }
2437 if schema_version != i64::from(self.schema.version) {
2438 return Err(invalid(format!("schemaVersion {schema_version}")));
2439 }
2440 if pin != as_of_commit_seq {
2441 return Err(invalid(format!("asOfCommitSeq {pin}")));
2442 }
2443 if digest != scope_digest {
2444 return Err(invalid("scopeDigest mismatch".to_owned()));
2445 }
2446 if meta_rows != row_count {
2447 return Err(invalid(format!("rowCount {meta_rows}")));
2448 }
2449
2450 let mut names: Vec<String> = Vec::new();
2452 {
2453 let mut stmt = img
2454 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
2455 .map_err(|_| invalid("image data table missing".to_owned()))?;
2456 let mut rows = stmt
2457 .query([])
2458 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2459 while let Some(row) = rows
2460 .next()
2461 .map_err(|_| invalid("image data table unreadable".to_owned()))?
2462 {
2463 names.push(
2464 row.get::<_, String>(1)
2465 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
2466 );
2467 }
2468 }
2469 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
2470 expected.push("_syncular_version");
2471 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
2472 return Err(SectionError::Abort(
2473 "sync.schema_mismatch".to_owned(),
2474 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
2475 ));
2476 }
2477
2478 if first_fresh_page {
2481 self.purge_scope_rows(&table.name, effective)
2482 .map_err(|()| SectionError::FailClosed)?;
2483 }
2484 self.overlay_dirty.set(true);
2490 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
2497 table.indexes.iter().filter(|i| !i.unique).collect()
2498 } else {
2499 Vec::new()
2500 };
2501 for index in &bulk_indexes {
2502 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2503 self.conn
2504 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
2505 .map_err(|e| invalid(e.to_string()))?;
2506 }
2507 let insert = self.insert_row_sql(&base_table(&table.name), table);
2508 let applied = {
2509 let mut ins = self
2510 .conn
2511 .prepare_cached(&insert)
2512 .map_err(|e| invalid(e.to_string()))?;
2513 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
2514 let mut stmt = img
2515 .prepare(&format!(
2516 "SELECT {} FROM {}",
2517 column_list.join(", "),
2518 quote_ident(&table.name)
2519 ))
2520 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2521 let mut rows = stmt
2522 .query([])
2523 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
2524 let version_index = table.columns.len();
2525 let mut applied = 0u32;
2526 while let Some(row) = rows
2527 .next()
2528 .map_err(|_| invalid("image row unreadable".to_owned()))?
2529 {
2530 for (i, column) in table.columns.iter().enumerate() {
2531 let cell = row
2532 .get_ref(i)
2533 .map_err(|_| invalid("image row unreadable".to_owned()))?;
2534 let param = image_cell_param(column, cell).map_err(&invalid)?;
2535 ins.raw_bind_parameter(i + 1, param)
2536 .map_err(|e| invalid(e.to_string()))?;
2537 }
2538 let version: i64 = row
2539 .get(version_index)
2540 .map_err(|_| invalid("image row unreadable".to_owned()))?;
2541 if version < 1 {
2542 return Err(invalid(format!(
2543 "row _syncular_version must be >= 1, got {version}"
2544 )));
2545 }
2546 ins.raw_bind_parameter(version_index + 1, version)
2547 .map_err(|e| invalid(e.to_string()))?;
2548 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
2549 applied += 1;
2550 }
2551 applied
2552 };
2553 for index in &bulk_indexes {
2554 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
2555 let cols_sql = index
2556 .columns
2557 .iter()
2558 .map(|c| quote_ident(c))
2559 .collect::<Vec<_>>()
2560 .join(", ");
2561 self.conn
2562 .execute(
2563 &format!(
2564 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
2565 base_table(&table.name)
2566 ),
2567 [],
2568 )
2569 .map_err(|e| invalid(e.to_string()))?;
2570 }
2571 if i64::from(applied) != row_count {
2572 return Err(invalid(format!(
2573 "image holds {applied} rows, descriptor says {row_count}"
2574 )));
2575 }
2576 Ok(applied)
2577 }
2578
2579 fn purge_scope_rows(
2584 &mut self,
2585 table_name: &str,
2586 effective: &[(String, Vec<String>)],
2587 ) -> Result<(), ()> {
2588 if effective.is_empty() {
2589 return Ok(());
2590 }
2591 let table = self.schema.table(table_name).ok_or(())?.clone();
2592 let mut clauses = Vec::new();
2593 let mut params: Vec<SqlValue> = Vec::new();
2594 for (variable, values) in effective {
2595 let column = table.scope_column(variable).ok_or(())?;
2596 let placeholders: Vec<String> = values
2597 .iter()
2598 .map(|v| {
2599 params.push(SqlValue::Text(v.clone()));
2600 "?".to_owned()
2601 })
2602 .collect();
2603 clauses.push(format!(
2604 "{} IN ({})",
2605 quote_ident(column),
2606 placeholders.join(", ")
2607 ));
2608 }
2609 let sql = format!(
2610 "DELETE FROM {} WHERE {}",
2611 base_table(table_name),
2612 clauses.join(" AND ")
2613 );
2614 self.overlay_dirty.set(true);
2615 self.conn
2616 .execute(&sql, rusqlite::params_from_iter(params))
2617 .map_err(|_| ())?;
2618 Ok(())
2619 }
2620
2621 fn drop_doomed_outbox(&mut self, table_name: &str, effective: &[(String, Vec<String>)]) {
2624 if effective.is_empty() {
2625 return;
2626 }
2627 let Some(table) = self.schema.table(table_name).cloned() else {
2628 return;
2629 };
2630 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
2631 for (variable, values) in effective {
2632 match table.scope_column(variable) {
2633 Some(column) => mappings.push((column, values)),
2634 None => return, }
2636 }
2637 let doomed: Vec<String> = self
2638 .outbox
2639 .iter()
2640 .filter(|commit| {
2641 commit.ops.iter().any(|op| {
2642 op.upsert
2643 && op.table == table_name
2644 && op.values.as_ref().is_some_and(|values| {
2645 mappings
2646 .iter()
2647 .all(|(column, allowed)| match values.get(*column) {
2648 Some(Value::String(s)) => allowed.contains(s),
2649 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
2650 _ => false,
2651 })
2652 })
2653 })
2654 })
2655 .map(|c| c.client_commit_id.clone())
2656 .collect();
2657 for id in doomed {
2658 self.overlay_dirty.set(true);
2659 self.outbox.retain(|c| c.client_commit_id != id);
2660 self.persist_outbox_delete(&id);
2661 }
2662 }
2663
2664 pub fn upload_blob(
2671 &mut self,
2672 bytes: &[u8],
2673 media_type: Option<String>,
2674 name: Option<String>,
2675 ) -> Result<Value, String> {
2676 let blob_id = blob_id_for(bytes);
2677 let now = self.clock_now_ms();
2678 self.conn
2679 .execute(
2680 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
2681 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
2682 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
2683 )
2684 .map_err(|e| e.to_string())?;
2685 self.conn
2686 .execute(
2687 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
2688 rusqlite::params![blob_id, media_type, now],
2689 )
2690 .map_err(|e| e.to_string())?;
2691 self.enforce_blob_cache_cap();
2694 let mut obj = Map::new();
2695 obj.insert("blobId".to_owned(), Value::from(blob_id));
2696 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
2697 if let Some(mt) = media_type {
2698 obj.insert("mediaType".to_owned(), Value::from(mt));
2699 }
2700 if let Some(n) = name {
2701 obj.insert("name".to_owned(), Value::from(n));
2702 }
2703 Ok(Value::Object(obj))
2704 }
2705
2706 pub fn fetch_blob(
2710 &mut self,
2711 transport: &mut dyn Transport,
2712 blob_id_or_ref: &str,
2713 ) -> Result<Value, (String, String)> {
2714 let simple = |m: String| ("client.failed".to_owned(), m);
2715 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
2716 blob_id_or_ref.to_owned()
2717 } else {
2718 let value: Value = serde_json::from_str(blob_id_or_ref)
2719 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
2720 value
2721 .get("blobId")
2722 .and_then(Value::as_str)
2723 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
2724 .to_owned()
2725 };
2726 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
2727 return Ok(cached);
2728 }
2729 let bytes = match transport
2735 .blob_download(&blob_id)
2736 .map_err(|e| (e.code, e.message))?
2737 {
2738 BlobDownload::Bytes(bytes) => bytes,
2739 BlobDownload::Url {
2740 url,
2741 url_expires_at_ms,
2742 } => {
2743 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
2745 return Err((
2746 "sync.segment_expired".to_owned(),
2747 format!(
2748 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
2749 ),
2750 ));
2751 }
2752 transport
2753 .fetch_blob_url(&url)
2754 .map_err(|e| (e.code, e.message))?
2755 }
2756 };
2757 if blob_id_for(&bytes) != blob_id {
2759 return Err(simple(format!(
2760 "blob content address mismatch for {blob_id}"
2761 )));
2762 }
2763 let now = self.clock_now_ms();
2764 self.conn
2765 .execute(
2766 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
2767 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
2768 )
2769 .map_err(|e| simple(e.to_string()))?;
2770 self.enforce_blob_cache_cap();
2771 self.get_cached_blob(&blob_id)
2772 .map_err(simple)?
2773 .ok_or_else(|| simple("blob cache write failed".to_owned()))
2774 }
2775
2776 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
2777 let _ = self.conn.execute(
2780 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
2781 rusqlite::params![self.clock_now_ms(), blob_id],
2782 );
2783 let mut stmt = self
2784 .conn
2785 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
2786 .map_err(|e| e.to_string())?;
2787 let mut rows = stmt
2788 .query(rusqlite::params![blob_id])
2789 .map_err(|e| e.to_string())?;
2790 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
2791 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
2792 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
2793 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
2794 let mut obj = Map::new();
2795 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
2796 obj.insert("byteLength".to_owned(), Value::from(byte_length));
2797 let mut bytes_obj = Map::new();
2798 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(&bytes)));
2799 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
2800 if let Some(mt) = media_type {
2801 obj.insert("mediaType".to_owned(), Value::from(mt));
2802 }
2803 return Ok(Some(Value::Object(obj)));
2804 }
2805 Ok(None)
2806 }
2807
2808 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
2810 let pending: Vec<(String, Option<String>)> = {
2811 let mut stmt = self
2812 .conn
2813 .prepare(
2814 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
2815 )
2816 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2817 let rows = stmt
2818 .query_map([], |row| {
2819 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
2820 })
2821 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
2822 rows.filter_map(Result::ok).collect()
2823 };
2824 for (blob_id, media_type) in pending {
2825 let bytes: Option<Vec<u8>> = self
2826 .conn
2827 .query_row(
2828 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
2829 rusqlite::params![blob_id],
2830 |row| row.get(0),
2831 )
2832 .ok();
2833 if let Some(bytes) = bytes {
2834 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
2835 }
2836 let _ = self.conn.execute(
2837 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
2838 rusqlite::params![blob_id],
2839 );
2840 }
2841 Ok(())
2842 }
2843
2844 fn upload_one(
2851 &self,
2852 transport: &mut dyn Transport,
2853 blob_id: &str,
2854 bytes: &[u8],
2855 media_type: Option<&str>,
2856 ) -> Result<(), TransportError> {
2857 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
2858 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
2860 url,
2861 url_expires_at_ms,
2862 } => {
2863 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
2864 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
2865 return Ok(());
2866 }
2867 }
2869 BlobUploadGrant::None => {
2870 }
2872 }
2873 transport.blob_upload(blob_id, bytes, media_type)
2874 }
2875
2876 fn enforce_blob_cache_cap(&self) {
2885 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
2886 return;
2887 };
2888 let mut total: i64 = self
2889 .conn
2890 .query_row(
2891 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
2892 [],
2893 |row| row.get(0),
2894 )
2895 .unwrap_or(0);
2896 if total <= max_bytes {
2897 return;
2898 }
2899 let candidates: Vec<(String, i64)> = {
2900 let Ok(mut stmt) = self.conn.prepare(
2901 "SELECT blob_id, byte_length FROM _syncular_blobs
2902 WHERE refcount = 0
2903 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
2904 ORDER BY last_used_ms ASC, created_at_ms ASC",
2905 ) else {
2906 return;
2907 };
2908 let Ok(rows) = stmt.query_map([], |row| {
2909 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
2910 }) else {
2911 return;
2912 };
2913 rows.filter_map(Result::ok).collect()
2914 };
2915 for (blob_id, byte_length) in candidates {
2916 if total <= max_bytes {
2917 break;
2918 }
2919 let _ = self.conn.execute(
2920 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
2921 rusqlite::params![blob_id],
2922 );
2923 total -= byte_length;
2924 }
2925 }
2926
2927 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
2931 if !self.schema_has_blobs() {
2932 return;
2933 }
2934 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
2935 for table in self.schema.tables.clone() {
2936 let blob_cols: Vec<String> = table
2937 .columns
2938 .iter()
2939 .filter(|c| c.ty == ColumnType::BlobRef)
2940 .map(|c| c.name.clone())
2941 .collect();
2942 for column in blob_cols {
2943 let sql = format!(
2944 "SELECT {} FROM {} WHERE {} IS NOT NULL",
2945 quote_ident(&column),
2946 base_table(&table.name),
2947 quote_ident(&column)
2948 );
2949 let Ok(mut stmt) = self.conn.prepare(&sql) else {
2950 continue;
2951 };
2952 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
2953 continue;
2954 };
2955 for raw in rows.flatten().flatten() {
2956 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
2957 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
2958 *counts.entry(id.to_owned()).or_insert(0) += 1;
2959 }
2960 }
2961 }
2962 }
2963 }
2964 let _ = self
2965 .conn
2966 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
2967 for (blob_id, count) in &counts {
2968 let _ = self.conn.execute(
2969 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
2970 rusqlite::params![count, blob_id],
2971 );
2972 }
2973 if delete_orphans {
2974 let _ = self.conn.execute(
2975 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
2976 [],
2977 );
2978 }
2979 }
2980
2981 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
2986 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
2987 return sql.clone();
2988 }
2989 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
2990 columns.push(quote_ident("_syncular_version"));
2991 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
2992 let sql = format!(
2993 "INSERT OR REPLACE INTO {full_table} ({}) VALUES ({})",
2994 columns.join(", "),
2995 placeholders.join(", ")
2996 );
2997 self.insert_sql
2998 .borrow_mut()
2999 .insert(full_table.to_owned(), sql.clone());
3000 sql
3001 }
3002
3003 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
3004 self.overlay_dirty.set(true);
3005 self.write_row(&base_table(table_name), table_name, row, version)
3006 }
3007
3008 fn write_row(
3009 &self,
3010 full_table: &str,
3011 table_name: &str,
3012 row: &Row,
3013 version: i64,
3014 ) -> Result<(), String> {
3015 let table = self
3016 .schema
3017 .table(table_name)
3018 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3019 let sql = self.insert_row_sql(full_table, table);
3020 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3021 let params = row
3022 .iter()
3023 .map(RowParam::Cell)
3024 .chain(std::iter::once(RowParam::Version(version)));
3025 stmt.execute(rusqlite::params_from_iter(params))
3026 .map_err(|e| e.to_string())?;
3027 Ok(())
3028 }
3029
3030 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
3031 let table = self
3032 .schema
3033 .table(table_name)
3034 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
3035 self.overlay_dirty.set(true);
3036 let sql = format!(
3037 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3038 base_table(table_name),
3039 quote_ident(&table.primary_key)
3040 );
3041 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
3042 stmt.execute(rusqlite::params![row_id])
3043 .map_err(|e| e.to_string())?;
3044 Ok(())
3045 }
3046
3047 fn rebuild_overlay_if_dirty(&mut self) {
3050 if self.overlay_dirty.get() {
3051 self.rebuild_overlay();
3052 }
3053 }
3054
3055 fn rebuild_overlay(&mut self) {
3059 self.exec("SAVEPOINT syncular_overlay");
3060 for table in self.schema.tables.clone() {
3061 let visible = visible_table(&table.name);
3062 let base = base_table(&table.name);
3063 self.exec(&format!("DELETE FROM {visible}"));
3064 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
3065 }
3066 for commit in self.outbox.clone() {
3067 for op in &commit.ops {
3068 let Some(table) = self.schema.table(&op.table).cloned() else {
3069 continue;
3070 };
3071 if op.upsert {
3072 let Some(values) = op.values.as_ref() else {
3073 continue;
3074 };
3075 let mut row: Row = Vec::with_capacity(table.columns.len());
3076 let mut ok = true;
3077 for column in &table.columns {
3078 match json_to_column_value(column, values.get(&column.name)) {
3079 Ok(v) => row.push(v),
3080 Err(_) => {
3081 ok = false;
3082 break;
3083 }
3084 }
3085 }
3086 if ok {
3087 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
3088 }
3089 } else {
3090 let sql = format!(
3091 "DELETE FROM {} WHERE CAST({} AS TEXT) = ?1",
3092 visible_table(&table.name),
3093 quote_ident(&table.primary_key)
3094 );
3095 let _ = self.conn.execute(&sql, rusqlite::params![op.row_id]);
3096 }
3097 }
3098 }
3099 self.exec("RELEASE syncular_overlay");
3100 self.overlay_dirty.set(false);
3101 }
3102
3103 fn exec(&self, sql: &str) {
3104 let _ = self.conn.execute_batch(sql);
3105 }
3106
3107 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
3110 transport
3111 .realtime_connect()
3112 .map_err(|e| format!("{}: {}", e.code, e.message))?;
3113 self.realtime_connected = true;
3114 Ok(())
3115 }
3116
3117 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
3118 let _ = transport.realtime_close();
3119 self.realtime_connected = false;
3120 self.presence.clear(); }
3122
3123 pub fn set_presence(
3129 &mut self,
3130 transport: &mut dyn Transport,
3131 scope_key: &str,
3132 doc: Option<&Value>,
3133 ) -> Result<(), String> {
3134 if !self.realtime_connected {
3135 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
3136 }
3137 let text = encode_presence_publish(scope_key, doc);
3138 transport
3139 .realtime_send(&text)
3140 .map_err(|e| format!("{}: {}", e.code, e.message))
3141 }
3142
3143 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
3145 self.presence
3146 .get(scope_key)
3147 .map(|peers| peers.values().cloned().collect())
3148 .unwrap_or_default()
3149 }
3150
3151 fn apply_presence(
3153 &mut self,
3154 scope_key: String,
3155 kind: Option<PresenceKind>,
3156 actor_id: Option<String>,
3157 client_id: Option<String>,
3158 doc: Option<Value>,
3159 error: Option<String>,
3160 ) {
3161 if error.is_some() {
3164 return;
3165 }
3166 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
3167 return;
3168 };
3169 let peer_key = format!("{actor_id} {client_id}");
3170 match kind {
3171 PresenceKind::Leave => {
3172 if let Some(peers) = self.presence.get_mut(&scope_key) {
3173 peers.remove(&peer_key);
3174 if peers.is_empty() {
3175 self.presence.remove(&scope_key);
3176 }
3177 }
3178 }
3179 _ => {
3180 let doc = match doc {
3181 Some(Value::Object(_)) => doc.unwrap(),
3182 _ => return,
3183 };
3184 self.presence.entry(scope_key).or_default().insert(
3185 peer_key,
3186 PresencePeer {
3187 actor_id,
3188 client_id,
3189 doc,
3190 },
3191 );
3192 }
3193 }
3194 }
3195
3196 pub fn on_realtime_text(&mut self, text: &str) {
3198 match parse_control(text) {
3199 Ok(ControlMessage::Hello { requires_sync, .. }) => {
3200 if requires_sync {
3201 self.sync_needed = true;
3203 }
3204 }
3205 Ok(ControlMessage::Presence {
3206 scope_key,
3207 kind,
3208 actor_id,
3209 client_id,
3210 doc,
3211 error,
3212 ..
3213 }) => {
3214 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
3215 }
3216 Ok(ControlMessage::Wake { .. }) => {
3217 self.sync_needed = true;
3219 }
3220 _ => {}
3221 }
3222 }
3223
3224 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
3227 if self.stopped {
3228 return;
3229 }
3230 let message = match decode_message(bytes) {
3231 Ok(m) if m.msg_kind == MsgKind::Response => m,
3232 _ => {
3233 self.sync_needed = true;
3234 return;
3235 }
3236 };
3237 let mut frames = message.frames.into_iter();
3238 let mut applied_cursor: Option<i64> = None;
3239 let mut any_covered = false;
3240 let mut dropped = false;
3241 while let Some(frame) = frames.next() {
3242 let Frame::SubStart {
3243 id,
3244 status,
3245 effective_scopes,
3246 ..
3247 } = frame
3248 else {
3249 continue;
3250 };
3251 let mut body = Vec::new();
3252 let mut next_cursor: Option<i64> = None;
3253 for inner in frames.by_ref() {
3254 match inner {
3255 Frame::SubEnd {
3256 next_cursor: nc, ..
3257 } => {
3258 next_cursor = Some(nc);
3259 break;
3260 }
3261 Frame::Unknown { .. } => {}
3262 other => body.push(other),
3263 }
3264 }
3265 let Some(next_cursor) = next_cursor else {
3266 dropped = true;
3267 break;
3268 };
3269 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
3270 dropped = true;
3271 continue;
3272 };
3273 let sub = &self.subs[sub_index];
3274 if status != SubStatus::Active
3277 || sub.state != SubState::Active
3278 || sub.bootstrap_state.is_some()
3279 || !sub.synced_once
3280 {
3281 dropped = true;
3282 continue;
3283 }
3284 if next_cursor <= sub.cursor {
3285 any_covered = true;
3287 continue;
3288 }
3289 self.subs[sub_index].effective = Some(effective_scopes);
3290 self.exec("SAVEPOINT syncular_delta");
3291 let mut failed = false;
3292 for inner in body {
3293 if let Frame::Commit {
3294 tables, changes, ..
3295 } = inner
3296 {
3297 if self.apply_commit_changes(&tables, &changes).is_err() {
3298 failed = true;
3299 break;
3300 }
3301 }
3302 }
3303 if failed {
3304 self.exec("ROLLBACK TO syncular_delta");
3305 self.exec("RELEASE syncular_delta");
3306 dropped = true;
3307 continue;
3308 }
3309 self.exec("RELEASE syncular_delta");
3310 let sub = &mut self.subs[sub_index];
3311 sub.cursor = next_cursor;
3312 self.persist_sub(&self.subs[sub_index].clone());
3313 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
3314 }
3315 if let Some(cursor) = applied_cursor {
3316 self.rebuild_overlay();
3317 self.reconcile_blob_refcounts(false);
3318 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3320 let _ = transport.realtime_send(&ack);
3321 } else if !any_covered || dropped {
3322 self.sync_needed = true;
3324 }
3325 }
3326
3327 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
3331 if !self.realtime_connected {
3332 return;
3333 }
3334 let floor = self
3335 .subs
3336 .iter()
3337 .filter(|s| {
3338 s.state == SubState::Active
3339 && s.bootstrap_state.is_none()
3340 && s.synced_once
3341 && s.cursor >= 0
3342 })
3343 .map(|s| s.cursor)
3344 .min();
3345 if let Some(cursor) = floor {
3346 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
3347 let _ = transport.realtime_send(&ack);
3348 }
3349 }
3350}