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