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