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