1use std::cell::{Cell, RefCell};
11use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
12
13use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
14use rusqlite::{Connection, OpenFlags, OptionalExtension};
15use serde::{Deserialize, Serialize};
16use serde_json::{Map, Value};
17use sha2::{Digest, Sha256};
18use ssp2::decode::WIRE_VERSION;
19use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
20use ssp2::primitives::RawJson;
21use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
22use ssp2::{
23 decode_message, encode_message, encode_presence_publish, parse_control, ControlMessage,
24 PresenceKind,
25};
26
27use crate::api::{
28 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
29 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
30 ClientDiagnosticsStorage, ClientLimits, CommandEffects, CommitOperation,
31 CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery, CommitOutcomeResolution,
32 CommitOutcomeStatus, ConflictRecord, CoverageSnapshot, DiagnosticLastChange,
33 DiagnosticLastRound, DiagnosticRoundCounters, DiagnosticSubscription, LeaseState,
34 LocalDataPurgeInput, LocalDataPurgeResult, LocalDataPurgeTarget, LocalDataRebootstrapInput,
35 LocalDataRebootstrapResult, Mutation, PresencePeer, QueryRow, QuerySnapshot, QueryValue,
36 RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RowState, SchemaFloor,
37 SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport, SyncStatusSnapshot, TableChange,
38 WindowBase, WindowChange, WindowCoverage, WindowState, WindowUnitRef,
39 CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
40};
41#[cfg(feature = "bench-internals")]
42use crate::bench::{Phase, Recorder};
43use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
44use crate::transport::{BlobDownload, BlobUploadGrant, SegmentRequest, Transport, TransportError};
45use crate::values::{
46 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes, encode_row_json,
47 json_to_column_value, json_to_scope_map, normalize_values_casing, render_row_id_json,
48 scope_map_to_json, sort_scope_map,
49};
50
51const DEFAULT_ACCEPT: u8 = 0b0111;
56const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
57const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
58const ACCEPT_SQLITE: u8 = 1 << 2;
59const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
60const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
61
62const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
64const LOCAL_REVISION_KEY: &str = "localRevision";
65const CLIENT_ID_KEY: &str = "clientId";
66const LEASE_STATE_KEY: &str = "leaseState";
67const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
68const LOG_EPOCH_KEY: &str = "logEpoch";
69const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
74const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
75const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
76const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
79
80#[derive(Debug, Serialize, Deserialize)]
81#[serde(rename_all = "camelCase", deny_unknown_fields)]
82struct PersistedLocalDataRebootstrapReceipt {
83 version: u8,
84 retained_commits: u64,
85 reset_subscriptions: u64,
86}
87
88fn invalid_local_rebootstrap_receipt() -> String {
89 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
90}
91
92fn encode_local_rebootstrap_receipt(
93 retained_commits: usize,
94 reset_subscriptions: usize,
95) -> Result<String, String> {
96 let retained_commits =
97 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
98 let reset_subscriptions =
99 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
100 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
101 return Err(invalid_local_rebootstrap_receipt());
102 }
103 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
104 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
105 retained_commits,
106 reset_subscriptions,
107 })
108 .map_err(|_| invalid_local_rebootstrap_receipt())
109}
110
111fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
112 if value == "v1" {
114 return Ok((0, 0));
115 }
116 let receipt: PersistedLocalDataRebootstrapReceipt =
117 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
118 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
119 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
120 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
121 {
122 return Err(invalid_local_rebootstrap_receipt());
123 }
124 Ok((
125 usize::try_from(receipt.retained_commits)
126 .map_err(|_| invalid_local_rebootstrap_receipt())?,
127 usize::try_from(receipt.reset_subscriptions)
128 .map_err(|_| invalid_local_rebootstrap_receipt())?,
129 ))
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133enum SubState {
134 Active,
135 Revoked,
136 Failed,
137}
138
139#[cfg(test)]
140mod observation_tests {
141 use super::*;
142 use crate::native_transport::HostTransport;
143 use serde_json::json;
144
145 fn client() -> SyncClient {
146 SyncClient::new(
147 "retry-test".to_owned(),
148 &json!({
149 "version": 1,
150 "tables": [{
151 "name": "tasks",
152 "primaryKey": "id",
153 "columns": [
154 { "name": "id", "type": "string", "nullable": false },
155 { "name": "project_id", "type": "string", "nullable": false }
156 ],
157 "scopes": [{ "pattern": "project:{project_id}" }]
158 }]
159 }),
160 ClientLimits::default(),
161 )
162 .expect("test client")
163 }
164
165 #[test]
166 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
167 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
168 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
169 "scopes":[{"pattern":"project:{project_id}"}]}]});
170 for realtime in [false, true] {
171 for fault in [
172 "none",
173 "row",
174 "revision",
175 "commit",
176 "cursor",
177 "error",
178 "missing-end",
179 ] {
180 if realtime && fault == "missing-end" {
181 continue;
182 }
183 let path = std::env::temp_dir()
184 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
185 let mut client = SyncClient::open_path(
186 "frame-test".into(),
187 &schema,
188 Default::default(),
189 path.to_str().unwrap(),
190 )
191 .unwrap();
192 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
193 client
194 .subscribe(
195 "tasks".into(),
196 "tasks".into(),
197 vec![("project_id".into(), vec!["p1".into()])],
198 None,
199 )
200 .unwrap();
201 client.subs[0].cursor = 0;
202 client.subs[0].synced_once = true;
203 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
204 client.persist_sub(&client.subs[0]).unwrap();
205 client.realtime_connected = true;
206 client.drain_change_batches();
207 let mut transport = CountingRealtimeTransport::default();
208 let (_, meta) = client.build_request(false);
209 let mut response = Message {
210 wire_version: WIRE_VERSION,
211 msg_kind: MsgKind::Response,
212 frames: vec![
213 Frame::RespHeader {
214 required_schema_version: None,
215 latest_schema_version: None,
216 log_epoch: Some("epoch-1".into()),
217 reset_required: Some(false),
218 },
219 Frame::SubStart {
220 id: "tasks".into(),
221 status: SubStatus::Active,
222 reason_code: "".into(),
223 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
224 bootstrap: false,
225 },
226 ],
227 };
228 for (index, id) in ["first", "second"].iter().enumerate() {
229 let values = Map::from_iter([
230 ("id".into(), Value::from(*id)),
231 ("project_id".into(), Value::from("p1")),
232 ]);
233 response.frames.push(Frame::Commit {
234 commit_seq: index as i64 + 1,
235 actor_id: "writer".into(),
236 created_at_ms: 1,
237 tables: vec!["tasks".into()],
238 changes: vec![ssp2::model::Change {
239 table_index: 0,
240 row_id: (*id).into(),
241 op: Op::Upsert,
242 row_version: Some(index as i64 + 1),
243 scopes: vec![("project_id".into(), "p1".into())],
244 row: Some(
245 encode_row_json(
246 client.schema.table("tasks").unwrap(),
247 id,
248 &values,
249 &client.encryption,
250 )
251 .unwrap(),
252 ),
253 }],
254 });
255 }
256 response.frames.push(Frame::SubEnd {
257 next_cursor: 2,
258 bootstrap_state: None,
259 });
260 let valid = response.clone();
261 match fault {
262 "row" => {
263 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
264 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
265 },
266 "revision" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND NEW.value = '2' AND EXISTS (SELECT 1 FROM _syncular_base_tasks WHERE id = 'second') BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END").unwrap(),
267 "commit" => client.conn.execute_batch("PRAGMA foreign_keys = ON; CREATE TABLE frame_parent(id INTEGER PRIMARY KEY); CREATE TABLE frame_child(id INTEGER REFERENCES frame_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_frame AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'second' BEGIN INSERT INTO frame_child VALUES(1); END").unwrap(),
268 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
269 "error" => {response.frames.truncate(3);response.frames.push(Frame::Error {code:"sync.invalid_request".into(),message:"later error".into(),category:"protocol".into(),retryable:false,recommended_action:"retry".into(),details:None});},
270 "missing-end" => {response.frames.truncate(3);},
271 _ => {}
272 }
273 if realtime {
274 client.on_realtime_binary(&mut transport, &encode_message(&response));
275 } else {
276 assert_eq!(
277 matches!(
278 client.process_response(&mut transport, response, &meta),
279 SyncOutcome::Ok(_)
280 ),
281 fault == "none",
282 "{fault}"
283 );
284 }
285 assert!(
286 client.conn.is_autocommit(),
287 "no open transaction after {realtime}/{fault}"
288 );
289 let count = if matches!(fault, "none" | "cursor") {
290 2
291 } else {
292 1
293 };
294 let expected_revision = count + u64::from(realtime && fault != "none");
295 assert_eq!(
296 client.local_revision(),
297 expected_revision,
298 "{realtime}/{fault}"
299 );
300 let batches = client.drain_change_batches();
301 assert_eq!(
302 batches.len(),
303 expected_revision as usize,
304 "{realtime}/{fault}"
305 );
306 let row_batches: Vec<_> = batches
307 .iter()
308 .filter(|batch| !batch.tables.is_empty())
309 .collect();
310 assert_eq!(row_batches.len(), count as usize);
311 assert_eq!(
312 row_batches
313 .iter()
314 .map(|b| b.revision.clone())
315 .collect::<Vec<_>>(),
316 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
317 );
318 for table in ["tasks", "_syncular_base_tasks"] {
319 assert_eq!(
320 client
321 .conn
322 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
323 .get::<_, u64>(0))
324 .unwrap(),
325 count,
326 "{realtime}/{fault}/{table}"
327 );
328 }
329 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
330 assert_eq!(
331 transport
332 .messages
333 .iter()
334 .filter(|m| m.contains("\"ack\""))
335 .count(),
336 usize::from(fault == "none")
337 );
338 if matches!(fault, "revision" | "commit" | "cursor") {
339 client
340 .conn
341 .execute_batch("DROP TRIGGER fail_frame")
342 .unwrap();
343 }
344 drop(client);
345 let mut reopened = SyncClient::open_path(
346 "frame-test".into(),
347 &schema,
348 Default::default(),
349 path.to_str().unwrap(),
350 )
351 .unwrap();
352 assert_eq!(reopened.local_revision(), expected_revision);
353 assert_eq!(
354 reopened
355 .query("SELECT id FROM tasks ORDER BY id", &[])
356 .unwrap()
357 .len(),
358 count as usize
359 );
360 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
361 let (_, meta) = reopened.build_request(false);
362 assert!(matches!(
363 reopened.process_response(&mut transport, valid, &meta),
364 SyncOutcome::Ok(_)
365 ));
366 assert_eq!(
367 reopened
368 .query("SELECT id FROM tasks ORDER BY id", &[])
369 .unwrap()
370 .len(),
371 2
372 );
373 assert_eq!(reopened.subs[0].cursor, 2);
374 drop(reopened);
375 std::fs::remove_file(path).unwrap();
376 }
377 }
378 }
379
380 #[test]
381 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
382 for status in [SubStatus::Reset, SubStatus::Revoked] {
383 for fault in ["subscription", "revision"] {
384 let mut client = client();
385 client
386 .subscribe(
387 "tasks".into(),
388 "tasks".into(),
389 vec![("project_id".into(), vec!["p1".into()])],
390 None,
391 )
392 .unwrap();
393 client.subs[0].cursor = 41;
394 client.subs[0].synced_once = true;
395 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
396 client.persist_sub(&client.subs[0]).unwrap();
397 client
398 .mutate(vec![Mutation::Upsert {
399 table: "tasks".into(),
400 values: Map::from_iter([
401 ("id".into(), Value::from("pending")),
402 ("project_id".into(), Value::from("p1")),
403 ]),
404 base_version: None,
405 }])
406 .unwrap();
407 let ids = client.pending_commit_ids();
408 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
409 let revision = client.local_revision();
410 client.drain_change_batches();
411 let (_, meta) = client.build_request(false);
412 client.conn.execute_batch(if fault == "subscription" {
413 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
414 } else {
415 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
416 }).unwrap();
417 let mut transport = CountingRealtimeTransport::default();
418 let mut report = SyncReport::default();
419 if status == SubStatus::Reset {
421 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
422 }
423 let result = client.process_section(
424 &mut transport,
425 "tasks",
426 status,
427 "sync.scope_revoked",
428 vec![],
429 vec![],
430 Some((42, None)),
431 &meta,
432 &mut report,
433 );
434 assert!(result.is_err(), "{status:?}/{fault}");
435 assert!(client.conn.is_autocommit());
436 assert_eq!(client.subs[0].cursor, 41);
437 assert_eq!(client.subs[0].state, SubState::Active);
438 assert_eq!(client.pending_commit_ids(), ids);
439 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
440 assert!(client.rejections.is_empty());
441 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
442 assert_eq!(client.local_revision(), revision);
443 assert!(client.drain_change_batches().is_empty());
444 assert!(report.revoked.is_empty() && report.resets.is_empty());
445 }
446 }
447 }
448
449 #[test]
450 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
451 let mut client = client();
452 client
453 .subscribe(
454 "tasks".into(),
455 "tasks".into(),
456 vec![("project_id".into(), vec!["p1".into()])],
457 None,
458 )
459 .unwrap();
460 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
461 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
462 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
463 let row = |id: &str| ssp2::segment::SegmentRow {
464 server_version: 1,
465 values: vec![
466 Some(ColumnValue::String(id.into())),
467 Some(ColumnValue::String("p1".into())),
468 ],
469 };
470 let segment = RowsSegment {
471 table: "tasks".into(),
472 schema_version: 1,
473 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
474 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
475 };
476 assert!(client.apply_segment(0, &segment, true).is_err());
477 assert!(client.conn.is_autocommit());
478 assert_eq!(
479 client
480 .query("SELECT id FROM tasks ORDER BY id", &[])
481 .unwrap(),
482 vec![Map::from_iter([("id".into(), Value::from("first"))])]
483 );
484 assert_eq!(client.local_revision(), 1);
485 assert_eq!(client.drain_change_batches().len(), 1);
486 assert_eq!(client.subs[0].cursor, -1);
487 client
488 .conn
489 .execute_batch("DROP TRIGGER fail_block")
490 .unwrap();
491 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
492 assert_eq!(client.local_revision(), 3);
493 assert_eq!(client.drain_change_batches().len(), 2);
494 }
495
496 #[test]
497 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
498 assert_eq!(
499 client().diagnostics_storage().blob_cache_bytes_approx,
500 Some(0)
501 );
502 let client = SyncClient::new(
503 "diagnostic-storage".to_owned(),
504 &json!({
505 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
506 "columns": [{"name": "id", "type": "string", "nullable": false},
507 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
508 }),
509 ClientLimits {
510 blob_cache_max_bytes: Some(5),
511 ..Default::default()
512 },
513 )
514 .unwrap();
515 let empty = client.diagnostics_storage();
516 assert_eq!(empty.status, "healthy");
517 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
518 assert_eq!(empty.retained_outcome_entries, Some(0));
519 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
520 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
521 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
522 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
523 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
524 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,refcount,last_used_ms,created_at_ms)
525 VALUES ('body',zeroblob(8),8,1,0,0);").unwrap();
526 let written = client.diagnostics_storage();
527 assert_eq!(written.status, "pressure");
528 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
529 assert_eq!(written.retained_outcome_entries, Some(2));
530 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
531 assert_eq!(written.blob_cache_bytes_approx, Some(8));
532 assert_eq!(
533 written.pressure_reason_code.as_deref(),
534 Some("client.blob_cache_over_limit")
535 );
536 client
537 .conn
538 .execute_batch(
539 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
540 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
541 )
542 .unwrap();
543 let staged = client.diagnostics_storage();
544 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
545 assert_eq!(staged.retained_outcome_entries, Some(0));
546 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
547 assert_eq!(staged.status, "healthy");
548 client.conn.execute_batch("ROLLBACK").unwrap();
549 assert_eq!(client.diagnostics_storage(), written);
550 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
551 let unreadable = client.diagnostics_storage();
552 assert_eq!(unreadable.status, "unreadable");
553 assert!(unreadable.database_bytes_approx.is_none());
554 assert!(unreadable.pending_outbox_bytes_approx.is_none());
555 assert!(unreadable.retained_outcome_entries.is_none());
556 assert!(unreadable.retained_outcome_bytes_approx.is_none());
557 assert!(unreadable.blob_cache_bytes_approx.is_none());
558 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
559 let restored = client.diagnostics_storage();
560 assert_eq!(restored.status, written.status);
561 assert_eq!(
562 restored.retained_outcome_bytes_approx,
563 written.retained_outcome_bytes_approx
564 );
565 client.conn.set_prepared_statement_cache_capacity(0);
566 assert_eq!(client.diagnostics_storage(), restored);
567 }
568
569 #[test]
570 fn background_retry_deadlines_back_off_and_reset() {
571 let mut client = client();
572 client.schedule_background_retry();
573 assert!(matches!(
574 client.drain_sync_intents().as_slice(),
575 [SyncIntent::Background { delay_ms: 250 }]
576 ));
577 client.schedule_background_retry();
578 assert!(matches!(
579 client.drain_sync_intents().as_slice(),
580 [SyncIntent::Background { delay_ms: 500 }]
581 ));
582 client.reset_background_retry();
583 client.schedule_background_retry();
584 assert!(matches!(
585 client.drain_sync_intents().as_slice(),
586 [SyncIntent::Background { delay_ms: 250 }]
587 ));
588 }
589
590 #[test]
591 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
592 let mut client = client();
593 client
594 .subscribe(
595 "epoch-tasks".to_owned(),
596 "tasks".to_owned(),
597 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
598 None,
599 )
600 .expect("subscribe");
601 client.drain_sync_intents();
602
603 client
604 .run_log_epoch_reset("epoch-2")
605 .expect("reset partition log epoch");
606
607 assert!(matches!(
608 client.drain_sync_intents().as_slice(),
609 [SyncIntent::Interactive]
610 ));
611 }
612
613 #[derive(Default)]
614 struct CountingRealtimeTransport {
615 connects: usize,
616 closes: usize,
617 messages: Vec<String>,
618 }
619
620 impl Transport for CountingRealtimeTransport {
621 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
622 Err(TransportError::new("sync.transport_failed", "offline"))
623 }
624
625 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
626 Err(TransportError::new("sync.transport_failed", "offline"))
627 }
628
629 fn download_segment(
630 &mut self,
631 _request: &SegmentRequest,
632 ) -> Result<Vec<u8>, TransportError> {
633 Err(TransportError::new("sync.transport_failed", "offline"))
634 }
635
636 fn realtime_connect(&mut self) -> Result<(), TransportError> {
637 self.connects += 1;
638 Ok(())
639 }
640
641 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
642 self.messages.push(text.to_owned());
643 Ok(())
644 }
645
646 fn realtime_close(&mut self) -> Result<(), TransportError> {
647 self.closes += 1;
648 Ok(())
649 }
650 }
651
652 #[test]
653 fn realtime_connection_ownership_is_idempotent() {
654 let mut client = client();
655 let mut transport = CountingRealtimeTransport {
656 connects: 0,
657 closes: 0,
658 ..Default::default()
659 };
660 client
661 .connect_realtime(&mut transport)
662 .expect("first connect");
663 client
664 .connect_realtime(&mut transport)
665 .expect("idempotent connect");
666 assert_eq!(transport.connects, 1);
667 client.disconnect_realtime(&mut transport);
668 client.disconnect_realtime(&mut transport);
669 assert_eq!(transport.closes, 1);
670 client
671 .connect_realtime(&mut transport)
672 .expect("deliberate reconnect");
673 assert_eq!(transport.connects, 2);
674 }
675
676 #[test]
677 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
678 let mut client = client();
679 client
680 .subscribe(
681 "repair-tasks".to_owned(),
682 "tasks".to_owned(),
683 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
684 None,
685 )
686 .expect("subscribe");
687 {
688 let sub = client
689 .subs
690 .iter_mut()
691 .find(|sub| sub.id == "repair-tasks")
692 .expect("subscription");
693 sub.cursor = 42;
694 sub.synced_once = true;
695 let persisted = sub.clone();
696 client.persist_sub(&persisted).unwrap();
697 }
698 for table in ["_syncular_base_tasks", "tasks"] {
699 client
700 .conn
701 .execute(
702 &format!(
703 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
704 ),
705 rusqlite::params!["server-row", "p1"],
706 )
707 .expect("seed server row");
708 }
709 let pending = client
710 .mutate(vec![Mutation::Upsert {
711 table: "tasks".to_owned(),
712 values: Map::from_iter([
713 ("id".to_owned(), Value::from("offline-row")),
714 ("project_id".to_owned(), Value::from("p1")),
715 ]),
716 base_version: None,
717 }])
718 .expect("queue offline work");
719 client.drain_change_batches();
720 client.drain_sync_intents();
721
722 assert_eq!(
723 client
724 .rebootstrap_local_data(&LocalDataRebootstrapInput {
725 rebootstrap_id: "support-case-001".to_owned(),
726 })
727 .expect("rebootstrap"),
728 LocalDataRebootstrapResult {
729 already_applied: false,
730 retained_commits: 1,
731 reset_subscriptions: 1,
732 }
733 );
734 let visible_ids = client
735 .conn
736 .prepare("SELECT id FROM tasks ORDER BY id")
737 .expect("prepare visible ids")
738 .query_map([], |row| row.get::<_, String>(0))
739 .expect("query visible ids")
740 .collect::<Result<Vec<_>, _>>()
741 .expect("collect visible ids");
742 assert_eq!(visible_ids, vec!["offline-row"]);
743 assert_eq!(client.pending_commit_ids(), vec![pending]);
744 assert_eq!(
745 client
746 .subscription_state("repair-tasks")
747 .expect("subscription")
748 .cursor,
749 -1
750 );
751 assert!(client.upgrading());
752 assert!(client.sync_needed());
753 assert!(matches!(
754 client.drain_sync_intents().as_slice(),
755 [SyncIntent::Interactive]
756 ));
757 assert_eq!(client.drain_change_batches().len(), 1);
758
759 assert_eq!(
760 client
761 .rebootstrap_local_data(&LocalDataRebootstrapInput {
762 rebootstrap_id: "support-case-001".to_owned(),
763 })
764 .expect("idempotent retry"),
765 LocalDataRebootstrapResult {
766 already_applied: true,
767 retained_commits: 1,
768 reset_subscriptions: 1,
769 }
770 );
771 assert!(client.drain_change_batches().is_empty());
772 }
773
774 #[test]
775 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
776 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
777 assert_eq!(
778 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
779 (3, 4)
780 );
781 assert_eq!(
782 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
783 (0, 0)
784 );
785 for malformed in [
786 "",
787 "{}",
788 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
789 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
790 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
791 ] {
792 assert_eq!(
793 decode_local_rebootstrap_receipt(malformed)
794 .expect_err("malformed receipt must fail"),
795 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
796 );
797 }
798 }
799
800 #[test]
801 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
802 let path = std::env::temp_dir().join(format!(
803 "syncular-rebootstrap-receipt-{}.db",
804 uuid::Uuid::new_v4()
805 ));
806 let schema = json!({
807 "version": 1,
808 "tables": [{
809 "name": "tasks",
810 "primaryKey": "id",
811 "columns": [
812 { "name": "id", "type": "string", "nullable": false },
813 { "name": "project_id", "type": "string", "nullable": false }
814 ],
815 "scopes": [{ "pattern": "project:{project_id}" }]
816 }]
817 });
818 let path_string = path.to_str().expect("UTF-8 temp path");
819
820 {
821 let mut first = SyncClient::open_path(
822 "repair-restart-client".to_owned(),
823 &schema,
824 ClientLimits::default(),
825 path_string,
826 )
827 .expect("first open");
828 first
829 .subscribe(
830 "repair-tasks".to_owned(),
831 "tasks".to_owned(),
832 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
833 None,
834 )
835 .expect("subscribe");
836 first
837 .mutate(vec![Mutation::Upsert {
838 table: "tasks".to_owned(),
839 values: Map::from_iter([
840 ("id".to_owned(), Value::from("offline-row")),
841 ("project_id".to_owned(), Value::from("p1")),
842 ]),
843 base_version: None,
844 }])
845 .expect("queue offline work");
846 assert_eq!(
847 first
848 .rebootstrap_local_data(&LocalDataRebootstrapInput {
849 rebootstrap_id: "restart-receipt".to_owned(),
850 })
851 .expect("first rebootstrap"),
852 LocalDataRebootstrapResult {
853 already_applied: false,
854 retained_commits: 1,
855 reset_subscriptions: 1,
856 }
857 );
858 }
859
860 let mut reopened = SyncClient::open_path(
861 "repair-restart-client".to_owned(),
862 &schema,
863 ClientLimits::default(),
864 path_string,
865 )
866 .expect("reopen");
867 reopened.drain_change_batches();
868 reopened.drain_sync_intents();
869 assert_eq!(
870 reopened
871 .rebootstrap_local_data(&LocalDataRebootstrapInput {
872 rebootstrap_id: "restart-receipt".to_owned(),
873 })
874 .expect("receipt replay"),
875 LocalDataRebootstrapResult {
876 already_applied: true,
877 retained_commits: 1,
878 reset_subscriptions: 1,
879 }
880 );
881 assert!(reopened.drain_change_batches().is_empty());
882 assert!(reopened.drain_sync_intents().is_empty());
883 drop(reopened);
884 std::fs::remove_file(path).expect("remove temp database");
885 }
886
887 #[test]
888 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
889 let mut malformed = client();
890 malformed
891 .subscribe(
892 "repair-tasks".to_owned(),
893 "tasks".to_owned(),
894 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
895 None,
896 )
897 .expect("subscribe");
898 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
899 malformed.drain_change_batches();
900 malformed.drain_sync_intents();
901 let malformed_error = malformed
902 .rebootstrap_local_data(&LocalDataRebootstrapInput {
903 rebootstrap_id: "malformed".to_owned(),
904 })
905 .expect_err("malformed receipt must fail closed");
906 assert_eq!(
907 malformed_error,
908 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
909 );
910 assert!(!malformed.upgrading());
911 assert_eq!(
912 malformed
913 .subscription_state("repair-tasks")
914 .expect("unchanged subscription")
915 .cursor,
916 -1
917 );
918 assert!(malformed.drain_change_batches().is_empty());
919 assert!(malformed.drain_sync_intents().is_empty());
920
921 let mut unreadable = client();
922 unreadable
923 .conn
924 .execute(
925 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
926 rusqlite::params!["server-row", "p1"],
927 )
928 .expect("seed visible row");
929 unreadable
930 .conn
931 .execute("DROP TABLE _syncular_meta", [])
932 .expect("break marker storage");
933 let unreadable_error = unreadable
934 .rebootstrap_local_data(&LocalDataRebootstrapInput {
935 rebootstrap_id: "unreadable".to_owned(),
936 })
937 .expect_err("unreadable marker storage must fail closed");
938 assert_eq!(
939 unreadable_error,
940 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
941 );
942 let visible_rows = unreadable
943 .conn
944 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
945 .expect("visible projection remains");
946 assert_eq!(visible_rows, 1);
947 assert!(!unreadable.upgrading());
948 assert!(unreadable.drain_change_batches().is_empty());
949 assert!(unreadable.drain_sync_intents().is_empty());
950 }
951
952 #[test]
953 fn local_rebootstrap_cannot_bypass_schema_floor() {
954 let mut client = client();
955 client.set_schema_floor(Some(SchemaFloor {
956 required_schema_version: Some(2),
957 latest_schema_version: Some(2),
958 }));
959 let error = client
960 .rebootstrap_local_data(&LocalDataRebootstrapInput {
961 rebootstrap_id: "blocked-floor".to_owned(),
962 })
963 .expect_err("schema floor must block repair");
964 assert!(error.contains("cannot bypass an active schema-floor stop"));
965 }
966
967 #[test]
968 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
969 let mut client = client();
970 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
971 const COMMIT_COUNT: usize = 32;
972
973 for index in 0..COMMIT_COUNT {
974 client
975 .mutate(vec![Mutation::Upsert {
976 table: "tasks".to_owned(),
977 values: Map::from_iter([
978 ("id".to_owned(), Value::from(format!("task-{index}"))),
979 ("project_id".to_owned(), Value::from("project-1")),
980 ]),
981 base_version: None,
982 }])
983 .expect("queue commit");
984 }
985
986 let (_, request_meta) = client.build_request(false);
987 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
988 let mut frames = vec![Frame::RespHeader {
989 required_schema_version: None,
990 latest_schema_version: None,
991 log_epoch: Some("epoch-1".to_owned()),
992 reset_required: Some(false),
993 }];
994 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
995 |(index, client_commit_id)| Frame::PushResult {
996 client_commit_id: client_commit_id.clone(),
997 status: PushStatus::Applied,
998 commit_seq: Some(index as i64 + 1),
999 results: vec![OpResult::Applied { op_index: 0 }],
1000 },
1001 ));
1002 let response = Message {
1003 wire_version: WIRE_VERSION,
1004 msg_kind: MsgKind::Response,
1005 frames,
1006 };
1007 let mut transport =
1008 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
1009
1010 client.overlay_rebuild_count.set(0);
1011 client.outcome_prune_count.set(0);
1012 let outcome = client.process_response(&mut transport, response, &request_meta);
1013 assert!(matches!(outcome, SyncOutcome::Ok(_)));
1014 assert!(client.pending_commit_ids().is_empty());
1015 assert_eq!(
1016 client.overlay_rebuild_count.get(),
1017 1,
1018 "one response must reconcile its acknowledged commits with one overlay rebuild"
1019 );
1020 assert_eq!(
1021 client.outcome_prune_count.get(),
1022 1,
1023 "one response must enforce outcome retention once"
1024 );
1025 }
1026
1027 #[test]
1028 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
1029 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
1030 "columns": [
1031 {"name":"id", "type":"string", "nullable":false},
1032 {"name":"project_id", "type":"string", "nullable":false},
1033 {"name":"title", "type":"string", "nullable":false}
1034 ], "scopes": [{"pattern":"project:{project_id}"}],
1035 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1036 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
1037 }] });
1038 let mut incremental =
1039 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1040 let mut reference =
1041 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
1042 let tables = vec!["tasks".to_owned()];
1043 for index in 0..32 {
1044 let id = format!("task-{}", index % 8);
1045 let values = Map::from_iter([
1046 ("id".to_owned(), Value::from(id.clone())),
1047 (
1048 "project_id".to_owned(),
1049 Value::from(format!("p{}", index % 2)),
1050 ),
1051 ("title".to_owned(), Value::from(format!("needle{index}"))),
1052 ]);
1053 let payload = encode_row_json(
1054 incremental.schema.table("tasks").unwrap(),
1055 &id,
1056 &values,
1057 &incremental.encryption,
1058 )
1059 .unwrap();
1060 let change = ssp2::model::Change {
1061 table_index: 0,
1062 row_id: id,
1063 op: if index % 5 == 0 {
1064 Op::Delete
1065 } else {
1066 Op::Upsert
1067 },
1068 row_version: Some(index + 1),
1069 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
1070 row: Some(payload),
1071 };
1072 reference.overlay_dirty.set(true);
1073 for instance in [&mut incremental, &mut reference] {
1074 instance.begin_observation("mirror_test").unwrap();
1075 let mut batch = ChangeAccumulator::default();
1076 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
1077 instance
1078 .apply_commit_changes(&tables, std::slice::from_ref(&change))
1079 .unwrap();
1080 instance.rebuild_overlay_if_dirty();
1081 instance.finish_observation("mirror_test", batch).unwrap();
1082 }
1083 for sql in [
1084 "SELECT * FROM tasks ORDER BY id",
1085 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1086 ] {
1087 assert_eq!(
1088 incremental.query(sql, &[]).unwrap(),
1089 reference.query(sql, &[]).unwrap()
1090 );
1091 }
1092 assert_eq!(incremental.local_revision(), reference.local_revision());
1093 assert_eq!(
1094 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1095 serde_json::to_value(reference.drain_change_batches()).unwrap()
1096 );
1097 }
1098 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1099 assert_eq!(reference.overlay_rebuild_count.get(), 32);
1100 }
1101
1102 #[test]
1103 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
1104 let cases = [
1105 (
1106 "string",
1107 vec![
1108 json!("1"),
1109 json!("01"),
1110 json!(""),
1111 json!("nul\u{0}id"),
1112 json!("é"),
1113 ],
1114 ),
1115 (
1116 "integer",
1117 vec![
1118 json!(i64::MIN),
1119 json!(-1),
1120 json!(0),
1121 json!(1),
1122 json!(i64::MAX),
1123 ],
1124 ),
1125 ("boolean", vec![json!(false), json!(true)]),
1126 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
1127 (
1128 "float",
1129 vec![
1130 json!(1.0),
1131 json!(1.0000000000000002),
1132 json!(-1.0),
1133 json!(1e20),
1134 ],
1135 ),
1136 ];
1137 for (kind, ids) in cases {
1138 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
1139 "columns":[{"name":"id","type":kind,"nullable":false},
1140 {"name":"project_id","type":"string","nullable":false}],
1141 "scopes":[{"pattern":"project:{project_id}"}]}]});
1142 let mut client =
1143 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
1144 for (index, id) in ids.iter().enumerate() {
1145 let table = client.schema.table("tasks").unwrap();
1146 client
1147 .write_base_row(
1148 "tasks",
1149 &vec![
1150 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
1151 Some(ColumnValue::String(format!("p{index}"))),
1152 ],
1153 1,
1154 )
1155 .unwrap();
1156 }
1157 client.rebuild_overlay_if_dirty();
1158 for base in [true, false] {
1159 let full = if base {
1160 base_table("tasks")
1161 } else {
1162 visible_table("tasks")
1163 };
1164 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
1165 for row_id in [
1166 "1",
1167 "01",
1168 "1.0",
1169 "1.0000000000000002",
1170 "+1",
1171 "1e0",
1172 " 1",
1173 "-1",
1174 "0",
1175 "9223372036854775807",
1176 "-9223372036854775808",
1177 "true",
1178 "false",
1179 "",
1180 "nul\u{0}id",
1181 "é",
1182 "null",
1183 "{\"a\":1}",
1184 ] {
1185 let old_sql =
1186 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
1187 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
1188 let old = client
1189 .conn
1190 .prepare(&old_sql)
1191 .unwrap()
1192 .query_map([row_id], |row| row.get::<_, String>(0))
1193 .unwrap()
1194 .collect::<Result<Vec<_>, _>>()
1195 .unwrap();
1196 let new = client
1197 .conn
1198 .prepare_cached(&new_sql)
1199 .unwrap()
1200 .query_map([row_id], |row| row.get::<_, String>(0))
1201 .unwrap()
1202 .collect::<Result<Vec<_>, _>>()
1203 .unwrap();
1204 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
1205 if kind == "float" && row_id == "1.0" {
1208 assert_eq!(old.len(), 2);
1209 }
1210 let mut batch = ChangeAccumulator::default();
1211 assert_eq!(
1212 client.record_row_scopes(&mut batch, "tasks", row_id, base),
1213 !old.is_empty()
1214 );
1215 let expected = old.first().map(|scope| {
1216 BTreeMap::from([(
1217 "tasks".to_owned(),
1218 Some(BTreeSet::from([format!("project:{scope}")])),
1219 )])
1220 });
1221 assert_eq!(batch.tables, expected.unwrap_or_default());
1222 client
1223 .conn
1224 .execute_batch("SAVEPOINT lookup_delete")
1225 .unwrap();
1226 if base {
1227 client.delete_base_row("tasks", row_id).unwrap();
1228 } else {
1229 client.apply_outbox_ops(&[OutboxOp {
1230 upsert: false,
1231 table: "tasks".into(),
1232 row_id: row_id.into(),
1233 base_version: None,
1234 values: None,
1235 changed_fields: None,
1236 }]);
1237 }
1238 let remaining: i64 = client
1239 .conn
1240 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
1241 row.get(0)
1242 })
1243 .unwrap();
1244 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
1245 client
1246 .conn
1247 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
1248 .unwrap();
1249 client.overlay_dirty.set(false);
1250 }
1251 for (predicate, expected) in [
1252 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
1253 (predicate, if kind == "float" { "SCAN" } else { "SEARCH" }),
1254 ] {
1255 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
1256 let details = client
1257 .conn
1258 .prepare(&sql)
1259 .unwrap()
1260 .query_map(["1"], |row| row.get::<_, String>(3))
1261 .unwrap()
1262 .collect::<Result<Vec<_>, _>>()
1263 .unwrap();
1264 assert!(
1265 details.iter().any(|detail| detail.contains(expected)),
1266 "{kind}: {details:?}"
1267 );
1268 }
1269 }
1270 }
1271 }
1272
1273 #[test]
1274 fn pending_row_reconciliation_matches_full_fifo_replay() {
1275 for unique in [false, true] {
1276 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
1277 "name": name, "primaryKey": "id", "columns": [
1278 {"name":"id", "type":"string", "nullable":false},
1279 {"name":"project_id", "type":"string", "nullable":false},
1280 {"name":"title", "type":"string", "nullable":false}],
1281 "scopes": [{"pattern":"project:{project_id}"}],
1282 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
1283 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
1284 })))});
1285 let mut incremental =
1286 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1287 let mut reference =
1288 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
1289 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
1290 for instance in [&mut incremental, &mut reference] {
1291 for table in &tables {
1292 for index in 0..12 {
1293 instance
1294 .write_base_row(
1295 table,
1296 &vec![
1297 Some(ColumnValue::String(format!("row{index}"))),
1298 Some(ColumnValue::String("p0".into())),
1299 Some(ColumnValue::String(format!("base{index}"))),
1300 ],
1301 1,
1302 )
1303 .unwrap();
1304 }
1305 }
1306 instance.rebuild_overlay_if_dirty();
1307 }
1308 for index in 0..80 {
1309 let mutations = (0..3)
1310 .map(|offset| {
1311 let table = tables[(index + offset) % 2].clone();
1312 let id = format!("row{}", (index + offset) % 16);
1313 if (index + offset) % 7 == 0 {
1314 Mutation::Delete {
1315 table,
1316 row_id: id,
1317 base_version: None,
1318 }
1319 } else {
1320 Mutation::Upsert {
1321 table,
1322 values: Map::from_iter([
1323 ("id".into(), Value::from(id)),
1324 ("project_id".into(), Value::from(format!("p{}", index % 3))),
1325 (
1326 "title".into(),
1327 Value::from(format!("needle{}", (index + offset) % 5)),
1328 ),
1329 ]),
1330 base_version: None,
1331 }
1332 }
1333 })
1334 .collect::<Vec<_>>();
1335 for instance in [&mut incremental, &mut reference] {
1336 instance.mutate(mutations.clone()).unwrap();
1337 }
1338 let changes = (0..3)
1341 .map(|offset| {
1342 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
1343 let id = format!("row{}", (index + offset) % 16);
1344 let values = Map::from_iter([
1345 ("id".into(), Value::from(id.clone())),
1346 (
1347 "project_id".into(),
1348 Value::from(format!("p{}", (index + 1) % 3)),
1349 ),
1350 (
1351 "title".into(),
1352 Value::from(format!("remote{index}_{offset}")),
1353 ),
1354 ]);
1355 ssp2::model::Change {
1356 table_index: table_index as u16,
1357 row_id: id.clone(),
1358 op: if (index + offset) % 5 == 0 {
1359 Op::Delete
1360 } else {
1361 Op::Upsert
1362 },
1363 row_version: Some(index as i64 + 2),
1364 scopes: vec![],
1365 row: Some(
1366 encode_row_json(
1367 incremental.schema.table(&tables[table_index]).unwrap(),
1368 &id,
1369 &values,
1370 &incremental.encryption,
1371 )
1372 .unwrap(),
1373 ),
1374 }
1375 })
1376 .collect::<Vec<_>>();
1377 reference.overlay_dirty.set(true);
1378 incremental.apply_commit_frame(&tables, &changes).unwrap();
1379 reference.apply_commit_frame(&tables, &changes).unwrap();
1380 for table in &tables {
1381 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
1382 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
1383 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
1384 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
1385 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
1386 }
1387 }
1388 assert_eq!(incremental.local_revision(), reference.local_revision());
1389 assert_eq!(
1390 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1391 serde_json::to_value(reference.drain_change_batches()).unwrap()
1392 );
1393 }
1394 assert_eq!(
1395 incremental.overlay_rebuild_count.get(),
1396 if unique { 81 } else { 21 }
1397 );
1398 assert_eq!(reference.overlay_rebuild_count.get(), 81);
1399 }
1400 }
1401
1402 #[test]
1403 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
1404 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
1405 "columns":[{"name":"id", "type":"string", "nullable":false},
1406 {"name":"title", "type":"string", "nullable":false}],
1407 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
1408 let mut client =
1409 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
1410 for (id, title) in [("a", "original"), ("b", "occupied")] {
1411 client
1412 .write_base_row(
1413 "tasks",
1414 &vec![
1415 Some(ColumnValue::String(id.into())),
1416 Some(ColumnValue::String(title.into())),
1417 ],
1418 1,
1419 )
1420 .unwrap();
1421 }
1422 client.rebuild_overlay_if_dirty();
1423 let pending = client
1424 .mutate(vec![Mutation::Upsert {
1425 table: "tasks".into(),
1426 values: Map::from_iter([
1427 ("id".into(), Value::from("a")),
1428 ("title".into(), Value::from("occupied")),
1429 ]),
1430 base_version: None,
1431 }])
1432 .unwrap();
1433 assert_eq!(
1434 client
1435 .conn
1436 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1437 .get::<_, String>(
1438 0
1439 ))
1440 .unwrap(),
1441 "original"
1442 );
1443 for (title, expected) in [("available", "occupied"), ("occupied", "original")] {
1444 let change = ssp2::model::Change {
1445 table_index: 0,
1446 row_id: "b".into(),
1447 op: Op::Upsert,
1448 row_version: Some(2),
1449 scopes: vec![],
1450 row: Some(
1451 encode_row_json(
1452 client.schema.table("tasks").unwrap(),
1453 "b",
1454 &Map::from_iter([
1455 ("id".into(), Value::from("b")),
1456 ("title".into(), Value::from(title)),
1457 ]),
1458 &client.encryption,
1459 )
1460 .unwrap(),
1461 ),
1462 };
1463 client
1464 .apply_commit_frame(&["tasks".into()], &[change])
1465 .unwrap();
1466 assert_eq!(
1467 client
1468 .conn
1469 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
1470 .get::<_, String>(
1471 0
1472 ))
1473 .unwrap(),
1474 expected
1475 );
1476 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
1477 }
1478 assert_eq!(client.overlay_rebuild_count.get(), 3);
1479 }
1480
1481 #[test]
1482 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
1483 for failure in ["visible", "revision", "commit"] {
1484 let mut client = client();
1485 let id = client
1486 .mutate(vec![Mutation::Upsert {
1487 table: "tasks".into(),
1488 values: Map::from_iter([
1489 ("id".into(), Value::from("t1")),
1490 ("project_id".into(), Value::from("pending")),
1491 ]),
1492 base_version: None,
1493 }])
1494 .unwrap();
1495 client.drain_change_batches();
1496 let revision = client.local_revision();
1497 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
1498 let trigger = match failure {
1499 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
1500 "revision" => format!("CREATE TRIGGER fail_pending BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(ABORT, 'injected revision failure'); END"),
1501 _ => "PRAGMA foreign_keys = ON; CREATE TABLE pending_parent(id INTEGER PRIMARY KEY); CREATE TABLE pending_child(id INTEGER REFERENCES pending_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_pending AFTER INSERT ON _syncular_base_tasks BEGIN INSERT INTO pending_child VALUES(1); END".to_owned(),
1502 };
1503 client.conn.execute_batch(&trigger).unwrap();
1504 let change = ssp2::model::Change {
1505 table_index: 0,
1506 row_id: "t1".into(),
1507 op: Op::Upsert,
1508 row_version: Some(2),
1509 scopes: vec![],
1510 row: Some(
1511 encode_row_json(
1512 client.schema.table("tasks").unwrap(),
1513 "t1",
1514 &Map::from_iter([
1515 ("id".into(), Value::from("t1")),
1516 ("project_id".into(), Value::from("remote")),
1517 ]),
1518 &client.encryption,
1519 )
1520 .unwrap(),
1521 ),
1522 };
1523 assert!(client
1524 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change))
1525 .is_err());
1526 assert!(client.conn.is_autocommit());
1527 assert!(!client.overlay_dirty.get());
1528 assert_eq!(client.local_revision(), revision);
1529 assert!(client.drain_change_batches().is_empty());
1530 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
1531 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1532 assert!(client
1533 .query("SELECT * FROM _syncular_base_tasks", &[])
1534 .unwrap()
1535 .is_empty());
1536 client
1537 .conn
1538 .execute_batch("DROP TRIGGER fail_pending")
1539 .unwrap();
1540 client
1541 .apply_commit_frame(&["tasks".into()], &[change])
1542 .unwrap();
1543 assert_eq!(client.local_revision(), revision + 1);
1544 assert_eq!(client.pending_commit_ids(), vec![id]);
1545 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
1546 assert_eq!(
1547 client
1548 .conn
1549 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
1550 .get::<_, String>(0))
1551 .unwrap(),
1552 "remote"
1553 );
1554 }
1555 }
1556
1557 #[test]
1558 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
1559 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
1560 "columns": [
1561 {"name":"id", "type":"string", "nullable":false},
1562 {"name":"project_id", "type":"string", "nullable":false},
1563 {"name":"title", "type":"string", "nullable":false}],
1564 "scopes": [{"pattern":"project:{project_id}"}],
1565 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
1566 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
1567 let mut incremental =
1568 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1569 let mut reference =
1570 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
1571 for instance in [&mut incremental, &mut reference] {
1572 for index in 0..16 {
1573 instance
1574 .write_base_row(
1575 "tasks",
1576 &vec![
1577 Some(ColumnValue::String(format!("task{index}"))),
1578 Some(ColumnValue::String("p0".to_owned())),
1579 Some(ColumnValue::String(format!("base{index}"))),
1580 ],
1581 1,
1582 )
1583 .unwrap();
1584 }
1585 instance.rebuild_overlay_if_dirty();
1586 instance.overlay_rebuild_count.set(0);
1587 }
1588 for index in 0..100 {
1589 let mutations = (0..1 + index % 4)
1590 .map(|offset| {
1591 let id = format!("task{}", (index + offset) % 24);
1592 if (index + offset) % 7 == 0 {
1593 Mutation::Delete {
1594 table: "tasks".to_owned(),
1595 row_id: id,
1596 base_version: None,
1597 }
1598 } else {
1599 Mutation::Upsert {
1600 table: "tasks".to_owned(),
1601 values: Map::from_iter([
1602 ("id".to_owned(), Value::from(id)),
1603 (
1604 "project_id".to_owned(),
1605 Value::from(format!("p{}", index % 2)),
1606 ),
1607 (
1608 "title".to_owned(),
1609 Value::from(format!("needle{}", (index + offset) % 5)),
1610 ),
1611 ]),
1612 base_version: None,
1613 }
1614 }
1615 })
1616 .collect::<Vec<_>>();
1617 reference.overlay_dirty.set(true);
1618 incremental.mutate(mutations.clone()).unwrap();
1619 reference.mutate(mutations).unwrap();
1620 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
1621 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
1622 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
1623 }
1624 assert_eq!(incremental.local_revision(), reference.local_revision());
1625 assert_eq!(
1626 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
1627 serde_json::to_value(reference.drain_change_batches()).unwrap()
1628 );
1629 }
1630 assert_eq!(incremental.outbox.len(), 100);
1631 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
1632 assert_eq!(reference.overlay_rebuild_count.get(), 100);
1633 }
1634
1635 #[test]
1636 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
1637 for fail_at in ["outbox", "revision"] {
1638 let mut client = client();
1639 let values = Map::from_iter([
1640 ("id".to_owned(), Value::from("t1")),
1641 ("project_id".to_owned(), Value::from("p1")),
1642 ]);
1643 let mutation = Mutation::Upsert {
1644 table: "tasks".to_owned(),
1645 values,
1646 base_version: None,
1647 };
1648 let trigger = if fail_at == "outbox" {
1649 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
1650 } else {
1651 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
1652 };
1653 client.conn.execute_batch(&trigger).unwrap();
1654 let revision = client.local_revision();
1655 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
1656 assert_eq!(client.local_revision(), revision);
1657 assert!(client.outbox.is_empty());
1658 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
1659 assert_eq!(
1660 client
1661 .conn
1662 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
1663 .get::<_, i64>(0))
1664 .unwrap(),
1665 0
1666 );
1667 assert!(client.drain_change_batches().is_empty());
1668 assert!(!client.overlay_dirty.get());
1669 client
1670 .conn
1671 .execute_batch("DROP TRIGGER fail_append")
1672 .unwrap();
1673 client.mutate(vec![mutation]).unwrap();
1674 assert_eq!(client.outbox.len(), 1);
1675 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
1676 }
1677 }
1678
1679 #[test]
1680 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
1681 let mut client = client();
1682 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
1683 let values = Map::from_iter([
1684 ("id".to_owned(), Value::from("t1")),
1685 ("project_id".to_owned(), Value::from("p1")),
1686 ]);
1687 let payload = encode_row_json(
1688 client.schema.table("tasks").unwrap(),
1689 "t1",
1690 &values,
1691 &client.encryption,
1692 )
1693 .unwrap();
1694 let first = ssp2::model::Change {
1695 table_index: 0,
1696 row_id: "t1".to_owned(),
1697 op: Op::Upsert,
1698 row_version: Some(1),
1699 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
1700 row: Some(payload),
1701 };
1702 let mut invalid = first.clone();
1703 invalid.table_index = 1;
1704 let revision = client.local_revision();
1705 client.begin_observation("failed_mirror").unwrap();
1706 assert!(client
1707 .apply_commit_changes(&tables, &[first, invalid])
1708 .is_err());
1709 client.rollback_observation("failed_mirror");
1710 client.rebuild_overlay_if_dirty();
1711 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
1712 assert_eq!(
1713 client
1714 .conn
1715 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
1716 .get::<_, i64>(
1717 0
1718 ))
1719 .unwrap(),
1720 0
1721 );
1722 assert_eq!(client.local_revision(), revision);
1723 assert!(client.drain_change_batches().is_empty());
1724 }
1725
1726 #[test]
1727 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
1728 for failure in ["journal", "revision", "commit", "later-frame"] {
1729 let mut client = client();
1730 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1731 for id in ["first", "second"] {
1732 client
1733 .mutate(vec![Mutation::Upsert {
1734 table: "tasks".to_owned(),
1735 values: Map::from_iter([
1736 ("id".to_owned(), Value::from(id)),
1737 ("project_id".to_owned(), Value::from("p1")),
1738 ]),
1739 base_version: None,
1740 }])
1741 .unwrap();
1742 }
1743 let (_, meta) = client.build_request(false);
1744 client
1746 .mutate(vec![Mutation::Upsert {
1747 table: "tasks".to_owned(),
1748 values: Map::from_iter([
1749 ("id".to_owned(), Value::from("later")),
1750 ("project_id".to_owned(), Value::from("p1")),
1751 ]),
1752 base_version: None,
1753 }])
1754 .unwrap();
1755 let ids = client.pending_commit_ids();
1756 let rows = client
1757 .query("SELECT * FROM tasks ORDER BY id", &[])
1758 .unwrap();
1759 let revision = client.local_revision();
1760 client.drain_change_batches();
1761 if failure != "later-frame" {
1762 client.conn.execute_batch(match failure {
1763 "journal" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 1 BEGIN SELECT RAISE(FAIL, 'injected second journal failure'); END",
1764 "revision" => "CREATE TRIGGER fail_run BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' AND (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN SELECT RAISE(FAIL, 'injected run revision failure'); END",
1765 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_run AFTER INSERT ON _syncular_commit_outcomes WHEN (SELECT count(*) FROM _syncular_commit_outcomes) = 2 BEGIN INSERT INTO ack_child VALUES (1); END",
1766 }).unwrap();
1767 }
1768 let mut response = Message {
1769 wire_version: WIRE_VERSION,
1770 msg_kind: MsgKind::Response,
1771 frames: vec![Frame::RespHeader {
1772 required_schema_version: None,
1773 latest_schema_version: None,
1774 log_epoch: Some("epoch-1".to_owned()),
1775 reset_required: Some(false),
1776 }],
1777 };
1778 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
1779 response.frames.push(Frame::PushResult {
1780 client_commit_id: id.to_owned(),
1781 status: PushStatus::Applied,
1782 commit_seq: Some(1),
1783 results: vec![OpResult::Applied { op_index: 0 }],
1784 });
1785 }
1786 if failure == "later-frame" {
1787 response.frames.push(Frame::Error {
1788 code: "sync.invalid_request".to_owned(),
1789 message: "later error".to_owned(),
1790 category: "protocol".to_owned(),
1791 retryable: false,
1792 recommended_action: "retry".to_owned(),
1793 details: None,
1794 });
1795 }
1796 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1797 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1798 assert!(
1799 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == if failure == "later-frame" { "sync.invalid_request" } else { "client.outcome_persistence_failed" })
1800 );
1801 if failure == "later-frame" {
1802 assert_eq!(client.pending_commit_ids(), ids[2..]);
1803 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
1804 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
1805 let batches = client.drain_change_batches();
1806 assert_eq!(batches.len(), 1);
1807 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
1808 assert_eq!(client.local_revision(), revision + 1);
1809 response.frames.pop();
1810 } else {
1811 assert_eq!(client.pending_commit_ids(), ids);
1812 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1813 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
1814 assert_eq!(client.local_revision(), revision);
1815 assert!(client.drain_change_batches().is_empty());
1816 assert_eq!(
1817 client
1818 .query("SELECT * FROM tasks ORDER BY id", &[])
1819 .unwrap(),
1820 rows
1821 );
1822 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
1823 }
1824 for frame in &mut response.frames {
1825 if let Frame::PushResult { status, .. } = frame {
1826 *status = PushStatus::Cached;
1827 }
1828 }
1829 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
1830 else {
1831 panic!("retry failed");
1832 };
1833 assert_eq!(
1834 report.applied,
1835 if failure == "later-frame" {
1836 vec![]
1837 } else {
1838 ids[..2].to_vec()
1839 }
1840 );
1841 assert_eq!(client.pending_commit_ids(), ids[2..]);
1842 let batches = client.drain_change_batches();
1843 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
1844 if let Some(batch) = batches.first() {
1845 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
1846 }
1847 assert_eq!(client.local_revision(), revision + 1);
1848 }
1849 }
1850
1851 #[test]
1852 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
1853 for failure in ["revision", "journal", "commit"] {
1854 for status in ["applied", "cached", "rejected", "conflict"] {
1855 let mut client = client();
1856 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1857 for id in ["first", "later"] {
1858 client
1859 .mutate(vec![Mutation::Upsert {
1860 table: "tasks".to_owned(),
1861 values: Map::from_iter([
1862 ("id".to_owned(), Value::from(id)),
1863 ("project_id".to_owned(), Value::from("p1")),
1864 ]),
1865 base_version: None,
1866 }])
1867 .unwrap();
1868 }
1869 let (_, meta) = client.build_request(false);
1870 let ids = client.pending_commit_ids();
1871 let rows = client
1872 .query("SELECT * FROM tasks ORDER BY id", &[])
1873 .unwrap();
1874 let revision = client.local_revision();
1875 client.drain_change_batches();
1876 let (push_status, results) = match status {
1877 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
1878 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
1879 "conflict" => (
1880 PushStatus::Rejected,
1881 vec![OpResult::Conflict {
1882 op_index: 0,
1883 code: "sync.version_conflict".to_owned(),
1884 message: "conflict".to_owned(),
1885 server_version: 1,
1886 server_row: encode_row_json(
1887 client.schema.table("tasks").unwrap(),
1888 "first",
1889 &Map::from_iter([
1890 ("id".to_owned(), Value::from("first")),
1891 ("project_id".to_owned(), Value::from("p1")),
1892 ]),
1893 &client.encryption,
1894 )
1895 .unwrap(),
1896 }],
1897 ),
1898 _ => (
1899 PushStatus::Rejected,
1900 vec![OpResult::Error {
1901 op_index: 0,
1902 code: "sync.validation_failed".to_owned(),
1903 message: "rejected".to_owned(),
1904 retryable: false,
1905 }],
1906 ),
1907 };
1908 let response = Message {
1909 wire_version: WIRE_VERSION,
1910 msg_kind: MsgKind::Response,
1911 frames: vec![
1912 Frame::RespHeader {
1913 required_schema_version: None,
1914 latest_schema_version: None,
1915 log_epoch: Some("epoch-1".to_owned()),
1916 reset_required: Some(false),
1917 },
1918 Frame::PushResult {
1919 client_commit_id: ids[0].clone(),
1920 status: push_status,
1921 commit_seq: Some(1),
1922 results,
1923 },
1924 ],
1925 };
1926 client.conn.execute_batch(match failure {
1927 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
1928 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
1929 _ => "PRAGMA foreign_keys = ON; CREATE TABLE ack_parent(id INTEGER PRIMARY KEY); CREATE TABLE ack_child(id INTEGER REFERENCES ack_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_ack AFTER INSERT ON _syncular_commit_outcomes BEGIN INSERT INTO ack_child VALUES (1); END",
1930 }).unwrap();
1931 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1932 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1933 assert_eq!(
1934 client.pending_commit_ids(),
1935 ids,
1936 "{failure}/{status}: pending identity"
1937 );
1938 assert_eq!(
1939 client.local_revision(),
1940 revision,
1941 "{failure}/{status}: revision"
1942 );
1943 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1944 assert!(client.conflicts().is_empty());
1945 assert!(client.rejections().is_empty());
1946 assert!(client.drain_change_batches().is_empty());
1947 assert_eq!(
1948 client
1949 .query("SELECT * FROM tasks ORDER BY id", &[])
1950 .unwrap(),
1951 rows
1952 );
1953 assert!(
1954 matches!(outcome, SyncOutcome::Failed { error_code, .. } if error_code == "client.outcome_persistence_failed"),
1955 "{failure}/{status}: failed round"
1956 );
1957 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
1958 assert!(matches!(
1959 client.process_response(&mut transport, response, &meta),
1960 SyncOutcome::Ok(_)
1961 ));
1962 assert_eq!(client.pending_commit_ids(), ids[1..]);
1963 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
1964 assert_eq!(client.drain_change_batches().len(), 1);
1965 }
1966 }
1967 }
1968
1969 #[test]
1970 fn mixed_push_results_reconcile_and_prune_once_per_response() {
1971 let mut client = client();
1972 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1973 for index in 0..4 {
1974 client
1975 .mutate(vec![Mutation::Upsert {
1976 table: "tasks".to_owned(),
1977 values: Map::from_iter([
1978 ("id".to_owned(), Value::from(format!("task-{index}"))),
1979 ("project_id".to_owned(), Value::from("project-1")),
1980 ]),
1981 base_version: None,
1982 }])
1983 .expect("queue commit");
1984 }
1985
1986 let (_, request_meta) = client.build_request(false);
1987 let ids = &request_meta.pushed_ids;
1988 assert_eq!(ids.len(), 4);
1989 let response = Message {
1990 wire_version: WIRE_VERSION,
1991 msg_kind: MsgKind::Response,
1992 frames: vec![
1993 Frame::RespHeader {
1994 required_schema_version: None,
1995 latest_schema_version: None,
1996 log_epoch: Some("epoch-1".to_owned()),
1997 reset_required: Some(false),
1998 },
1999 Frame::PushResult {
2000 client_commit_id: ids[0].clone(),
2001 status: PushStatus::Applied,
2002 commit_seq: Some(1),
2003 results: vec![OpResult::Applied { op_index: 0 }],
2004 },
2005 Frame::PushResult {
2006 client_commit_id: ids[1].clone(),
2007 status: PushStatus::Cached,
2008 commit_seq: Some(2),
2009 results: vec![OpResult::Applied { op_index: 0 }],
2010 },
2011 Frame::PushResult {
2012 client_commit_id: ids[2].clone(),
2013 status: PushStatus::Rejected,
2014 commit_seq: None,
2015 results: vec![OpResult::Error {
2016 op_index: 0,
2017 code: "sync.validation_failed".to_owned(),
2018 message: "rejected".to_owned(),
2019 retryable: false,
2020 }],
2021 },
2022 Frame::PushResult {
2023 client_commit_id: ids[3].clone(),
2024 status: PushStatus::Rejected,
2025 commit_seq: None,
2026 results: vec![OpResult::Error {
2027 op_index: 0,
2028 code: "sync.idempotency_cache_miss".to_owned(),
2029 message: "retry".to_owned(),
2030 retryable: true,
2031 }],
2032 },
2033 ],
2034 };
2035 let mut transport =
2036 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
2037
2038 client.overlay_rebuild_count.set(0);
2039 client.outcome_prune_count.set(0);
2040 let outcome = client.process_response(&mut transport, response, &request_meta);
2041 let SyncOutcome::Ok(report) = outcome else {
2042 panic!("mixed push-result response failed");
2043 };
2044
2045 assert_eq!(report.applied, ids[..2]);
2046 assert_eq!(report.rejected, ids[2..3]);
2047 assert_eq!(report.retryable, ids[3..4]);
2048 assert_eq!(client.pending_commit_ids(), ids[3..4]);
2049 assert_eq!(
2050 client
2051 .query("SELECT id FROM tasks ORDER BY id", &[])
2052 .expect("query visible overlay"),
2053 vec![Map::from_iter([("id".to_owned(), Value::from("task-3"))])]
2054 );
2055 assert_eq!(client.overlay_rebuild_count.get(), 1);
2056 assert_eq!(client.outcome_prune_count.get(), 1);
2057 }
2058
2059 #[test]
2060 fn secondary_unique_collision_preserves_existing_synced_row() {
2061 let client = SyncClient::new(
2062 "unique-upsert-test".to_owned(),
2063 &json!({
2064 "version": 1,
2065 "tables": [{
2066 "name": "tasks",
2067 "primaryKey": "id",
2068 "columns": [
2069 { "name": "id", "type": "string", "nullable": false },
2070 { "name": "project_id", "type": "string", "nullable": false },
2071 { "name": "title", "type": "string", "nullable": false }
2072 ],
2073 "scopes": [{ "pattern": "project:{project_id}" }],
2074 "indexes": [{
2075 "name": "tasks_by_project_title",
2076 "columns": ["project_id", "title"],
2077 "unique": true
2078 }]
2079 }]
2080 }),
2081 ClientLimits::default(),
2082 )
2083 .expect("test client");
2084 let table = client.schema.table("tasks").expect("tasks table");
2085 let sql = client.insert_row_sql(&base_table("tasks"), table);
2086
2087 client
2088 .conn
2089 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
2090 .expect("insert first row");
2091 client
2092 .conn
2093 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
2094 .expect("update same primary key");
2095 client
2096 .conn
2097 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
2098 .expect("insert second row");
2099 assert!(client
2100 .conn
2101 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
2102 .is_err());
2103
2104 let rows = client
2105 .conn
2106 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
2107 .expect("prepare rows")
2108 .query_map([], |row| {
2109 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
2110 })
2111 .expect("query rows")
2112 .collect::<Result<Vec<_>, _>>()
2113 .expect("collect rows");
2114 assert_eq!(
2115 rows,
2116 vec![
2117 ("t1".to_owned(), "updated".to_owned()),
2118 ("t2".to_owned(), "original".to_owned())
2119 ]
2120 );
2121 }
2122
2123 #[test]
2124 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
2125 let path = std::env::temp_dir().join(format!(
2126 "syncular-startup-intent-{}.db",
2127 uuid::Uuid::new_v4()
2128 ));
2129 let schema = json!({
2130 "version": 1,
2131 "tables": [{
2132 "name": "tasks",
2133 "primaryKey": "id",
2134 "columns": [
2135 { "name": "id", "type": "string", "nullable": false },
2136 { "name": "project_id", "type": "string", "nullable": false }
2137 ],
2138 "scopes": [{ "pattern": "project:{project_id}" }]
2139 }]
2140 });
2141
2142 {
2143 let mut first = SyncClient::open_path_with_identity(
2144 None,
2145 &schema,
2146 ClientLimits::default(),
2147 path.to_str().expect("UTF-8 temp path"),
2148 )
2149 .expect("first open");
2150 first
2151 .set_window(
2152 &WindowBase {
2153 table: "tasks".to_owned(),
2154 variable: "project_id".to_owned(),
2155 fixed_scopes: Vec::new(),
2156 params: None,
2157 },
2158 &["persisted".to_owned()],
2159 )
2160 .expect("persist window");
2161 }
2162
2163 let mut reopened = SyncClient::open_path_with_identity(
2164 None,
2165 &schema,
2166 ClientLimits::default(),
2167 path.to_str().expect("UTF-8 temp path"),
2168 )
2169 .expect("reopen");
2170 assert!(reopened.sync_needed());
2171 assert!(matches!(
2172 reopened.drain_sync_intents().as_slice(),
2173 [SyncIntent::Interactive]
2174 ));
2175 drop(reopened);
2176 std::fs::remove_file(path).expect("remove temp database");
2177 }
2178
2179 #[test]
2180 fn reopening_preserves_immutable_subscription_identity_and_progress() {
2181 let path = std::env::temp_dir().join(format!(
2182 "syncular-subscription-identity-{}.db",
2183 uuid::Uuid::new_v4()
2184 ));
2185 let schema = json!({
2186 "version": 1,
2187 "tables": [
2188 {
2189 "name": "tasks",
2190 "primaryKey": "id",
2191 "columns": [
2192 { "name": "id", "type": "string", "nullable": false },
2193 { "name": "project_id", "type": "string", "nullable": false }
2194 ],
2195 "scopes": [{ "pattern": "project:{project_id}" }]
2196 },
2197 {
2198 "name": "docs",
2199 "primaryKey": "id",
2200 "columns": [
2201 { "name": "id", "type": "string", "nullable": false },
2202 { "name": "org_id", "type": "string", "nullable": false },
2203 { "name": "project_id", "type": "string", "nullable": false }
2204 ],
2205 "scopes": [
2206 { "pattern": "org:{org_id}" },
2207 { "pattern": "project:{projectId}", "column": "project_id" }
2208 ]
2209 }
2210 ]
2211 });
2212
2213 {
2214 let mut first = SyncClient::open_path_with_identity(
2215 None,
2216 &schema,
2217 ClientLimits::default(),
2218 path.to_str().expect("UTF-8 temp path"),
2219 )
2220 .expect("first open");
2221 first
2222 .subscribe(
2223 "stable-subscription".to_owned(),
2224 "tasks".to_owned(),
2225 vec![(
2226 "project_id".to_owned(),
2227 vec!["p2".to_owned(), "p1".to_owned()],
2228 )],
2229 Some(r#"{"view":"v1"}"#.to_owned()),
2230 )
2231 .expect("persist subscription");
2232 let persisted = {
2233 let subscription = first
2234 .subs
2235 .iter_mut()
2236 .find(|subscription| subscription.id == "stable-subscription")
2237 .expect("subscription");
2238 subscription.cursor = 41;
2239 subscription.bootstrap_state = Some("resume-token".to_owned());
2240 subscription.effective = Some(vec![(
2241 "project_id".to_owned(),
2242 vec!["p1".to_owned(), "p2".to_owned()],
2243 )]);
2244 subscription.synced_once = true;
2245 subscription.clone()
2246 };
2247 first.persist_sub(&persisted).unwrap();
2248 }
2249
2250 let mut reopened = SyncClient::open_path_with_identity(
2251 None,
2252 &schema,
2253 ClientLimits::default(),
2254 path.to_str().expect("UTF-8 temp path"),
2255 )
2256 .expect("reopen");
2257 let progress = reopened
2258 .subscription_state("stable-subscription")
2259 .expect("persisted state");
2260 assert_eq!(progress.cursor, 41);
2261 assert!(progress.has_resume_token);
2262
2263 reopened
2264 .subscribe(
2265 "stable-subscription".to_owned(),
2266 "tasks".to_owned(),
2267 vec![(
2268 "project_id".to_owned(),
2269 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
2270 )],
2271 Some(r#"{"view":"v1"}"#.to_owned()),
2272 )
2273 .expect("canonical intent is idempotent");
2274 assert_eq!(
2275 reopened
2276 .subscription_state("stable-subscription")
2277 .expect("unchanged state")
2278 .cursor,
2279 progress.cursor
2280 );
2281
2282 for (table, scopes, params) in [
2283 (
2284 "tasks",
2285 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2286 Some(r#"{"view":"v1"}"#.to_owned()),
2287 ),
2288 (
2289 "tasks",
2290 vec![(
2291 "project_id".to_owned(),
2292 vec!["p2".to_owned(), "p1".to_owned()],
2293 )],
2294 Some(r#"{"view":"v2"}"#.to_owned()),
2295 ),
2296 (
2297 "docs",
2298 vec![
2299 ("org_id".to_owned(), vec!["o1".to_owned()]),
2300 ("projectId".to_owned(), vec!["p1".to_owned()]),
2301 ],
2302 Some(r#"{"view":"v1"}"#.to_owned()),
2303 ),
2304 ] {
2305 let error = reopened
2306 .subscribe(
2307 "stable-subscription".to_owned(),
2308 table.to_owned(),
2309 scopes,
2310 params,
2311 )
2312 .expect_err("identity rebind must fail");
2313 assert!(error.starts_with("client.subscription_intent_mismatch:"));
2314 assert_eq!(
2315 reopened
2316 .subscription_state("stable-subscription")
2317 .expect("unchanged state")
2318 .cursor,
2319 progress.cursor
2320 );
2321 }
2322
2323 drop(reopened);
2324 std::fs::remove_file(path).expect("remove temp database");
2325 }
2326
2327 #[test]
2328 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
2329 let path = std::env::temp_dir().join(format!(
2330 "syncular-satisfied-schema-floor-{}.db",
2331 uuid::Uuid::new_v4()
2332 ));
2333 let schema = json!({
2334 "version": 23,
2335 "tables": [{
2336 "name": "tasks",
2337 "primaryKey": "id",
2338 "columns": [
2339 { "name": "id", "type": "string", "nullable": false },
2340 { "name": "project_id", "type": "string", "nullable": false }
2341 ],
2342 "scopes": [{ "pattern": "project:{project_id}" }]
2343 }]
2344 });
2345
2346 {
2347 let mut first = SyncClient::open_path_with_identity(
2348 None,
2349 &schema,
2350 ClientLimits::default(),
2351 path.to_str().expect("UTF-8 temp path"),
2352 )
2353 .expect("first open");
2354 first
2355 .subscribe(
2356 "tasks".to_owned(),
2357 "tasks".to_owned(),
2358 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2359 None,
2360 )
2361 .expect("persist subscription");
2362 first.set_schema_floor(Some(SchemaFloor {
2363 required_schema_version: Some(22),
2364 latest_schema_version: Some(22),
2365 }));
2366 }
2367
2368 let mut reopened = SyncClient::open_path_with_identity(
2369 None,
2370 &schema,
2371 ClientLimits::default(),
2372 path.to_str().expect("UTF-8 temp path"),
2373 )
2374 .expect("reopen");
2375 assert!(reopened.schema_floor().is_none());
2376 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
2377 assert!(reopened.sync_needed());
2378 assert!(matches!(
2379 reopened.drain_sync_intents().as_slice(),
2380 [SyncIntent::Interactive]
2381 ));
2382 drop(reopened);
2383 std::fs::remove_file(path).expect("remove temp database");
2384 }
2385
2386 #[test]
2387 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
2388 let path = std::env::temp_dir().join(format!(
2389 "syncular-unsatisfied-schema-floor-{}.db",
2390 uuid::Uuid::new_v4()
2391 ));
2392 let schema = json!({
2393 "version": 1,
2394 "tables": [{
2395 "name": "tasks",
2396 "primaryKey": "id",
2397 "columns": [
2398 { "name": "id", "type": "string", "nullable": false },
2399 { "name": "project_id", "type": "string", "nullable": false }
2400 ],
2401 "scopes": [{ "pattern": "project:{project_id}" }]
2402 }]
2403 });
2404
2405 {
2406 let mut first = SyncClient::open_path_with_identity(
2407 None,
2408 &schema,
2409 ClientLimits::default(),
2410 path.to_str().expect("UTF-8 temp path"),
2411 )
2412 .expect("first open");
2413 first
2414 .subscribe(
2415 "tasks".to_owned(),
2416 "tasks".to_owned(),
2417 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2418 None,
2419 )
2420 .expect("persist subscription");
2421 first.set_schema_floor(Some(SchemaFloor {
2422 required_schema_version: Some(2),
2423 latest_schema_version: Some(2),
2424 }));
2425 }
2426
2427 let reopened = SyncClient::open_path_with_identity(
2428 None,
2429 &schema,
2430 ClientLimits::default(),
2431 path.to_str().expect("UTF-8 temp path"),
2432 )
2433 .expect("reopen");
2434 assert_eq!(
2435 reopened.schema_floor(),
2436 Some(&SchemaFloor {
2437 required_schema_version: Some(2),
2438 latest_schema_version: Some(2),
2439 })
2440 );
2441 assert!(!reopened.sync_needed());
2442 drop(reopened);
2443 std::fs::remove_file(path).expect("remove temp database");
2444 }
2445
2446 #[test]
2447 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
2448 let path = std::env::temp_dir().join(format!(
2449 "syncular-indexed-column-bump-{}.db",
2450 uuid::Uuid::new_v4()
2451 ));
2452 let old_schema = json!({
2453 "version": 1,
2454 "tables": [
2455 {
2456 "name": "tasks",
2457 "primaryKey": "id",
2458 "columns": [
2459 { "name": "id", "type": "string", "nullable": false },
2460 { "name": "project_id", "type": "string", "nullable": false }
2461 ],
2462 "scopes": [{ "pattern": "project:{project_id}" }]
2463 },
2464 {
2465 "name": "legacy",
2466 "primaryKey": "id",
2467 "columns": [
2468 { "name": "id", "type": "string", "nullable": false },
2469 { "name": "project_id", "type": "string", "nullable": false }
2470 ],
2471 "scopes": [{ "pattern": "project:{project_id}" }]
2472 }
2473 ]
2474 });
2475 {
2476 let mut first = SyncClient::open_path_with_identity(
2477 None,
2478 &old_schema,
2479 ClientLimits::default(),
2480 path.to_str().expect("UTF-8 temp path"),
2481 )
2482 .expect("open old schema");
2483 first
2484 .subscribe(
2485 "legacy-sub".to_owned(),
2486 "legacy".to_owned(),
2487 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2488 None,
2489 )
2490 .expect("persist legacy subscription");
2491 }
2492
2493 let new_schema = json!({
2494 "version": 2,
2495 "tables": [{
2496 "name": "tasks",
2497 "primaryKey": "id",
2498 "columns": [
2499 { "name": "id", "type": "string", "nullable": false },
2500 { "name": "project_id", "type": "string", "nullable": false },
2501 { "name": "facility_membership_id", "type": "string", "nullable": true }
2502 ],
2503 "scopes": [{ "pattern": "project:{project_id}" }],
2504 "indexes": [{
2505 "name": "tasks_by_membership",
2506 "columns": ["project_id", "facility_membership_id"],
2507 "unique": false
2508 }]
2509 }]
2510 });
2511 let upgraded = SyncClient::open_path_with_identity(
2512 None,
2513 &new_schema,
2514 ClientLimits::default(),
2515 path.to_str().expect("UTF-8 temp path"),
2516 )
2517 .expect("open upgraded schema");
2518 let columns = upgraded
2519 .conn
2520 .prepare("PRAGMA table_info(tasks)")
2521 .expect("prepare columns")
2522 .query_map([], |row| row.get::<_, String>(1))
2523 .expect("query columns")
2524 .collect::<Result<Vec<_>, _>>()
2525 .expect("collect columns");
2526 assert!(columns.contains(&"facility_membership_id".to_owned()));
2527 let index_count: i64 = upgraded
2528 .conn
2529 .query_row(
2530 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
2531 [],
2532 |row| row.get(0),
2533 )
2534 .expect("query index");
2535 assert_eq!(index_count, 1);
2536 assert!(upgraded.subscription_state("legacy-sub").is_none());
2537 drop(upgraded);
2538 std::fs::remove_file(path).expect("remove temp database");
2539 }
2540
2541 #[test]
2542 fn migrates_pre_envelope_outcome_journal_additively() {
2543 let path = std::env::temp_dir().join(format!(
2544 "syncular-outcome-migration-{}.db",
2545 uuid::Uuid::new_v4()
2546 ));
2547 let conn = Connection::open(&path).expect("open legacy database");
2548 conn.execute_batch(
2549 "CREATE TABLE _syncular_commit_outcomes (
2550 seq INTEGER PRIMARY KEY AUTOINCREMENT,
2551 client_commit_id TEXT NOT NULL UNIQUE,
2552 status TEXT NOT NULL,
2553 recorded_at_ms INTEGER NOT NULL,
2554 results_json TEXT NOT NULL,
2555 resolution TEXT NOT NULL DEFAULT 'active',
2556 resolved_at_ms INTEGER,
2557 replacement_client_commit_id TEXT);",
2558 )
2559 .expect("create legacy outcome journal");
2560 drop(conn);
2561 let schema = json!({
2562 "version": 1,
2563 "tables": [{
2564 "name": "tasks",
2565 "primaryKey": "id",
2566 "columns": [
2567 { "name": "id", "type": "string", "nullable": false },
2568 { "name": "project_id", "type": "string", "nullable": false }
2569 ],
2570 "scopes": [{ "pattern": "project:{project_id}" }]
2571 }]
2572 });
2573 let client = SyncClient::open_path(
2574 "migration-native".to_owned(),
2575 &schema,
2576 ClientLimits::default(),
2577 path.to_str().expect("UTF-8 temp path"),
2578 )
2579 .expect("migrate database");
2580 let has_operations = client
2581 .conn
2582 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
2583 .expect("prepare table info")
2584 .query_map([], |row| row.get::<_, String>(1))
2585 .expect("query table info")
2586 .filter_map(Result::ok)
2587 .any(|column| column == "operations_json");
2588 assert!(has_operations);
2589 drop(client);
2590 std::fs::remove_file(path).expect("remove temp database");
2591 }
2592
2593 #[test]
2594 fn durable_conflict_outcome_and_resolution_survive_reopen() {
2595 let path = std::env::temp_dir().join(format!(
2596 "syncular-durable-outcome-{}.db",
2597 uuid::Uuid::new_v4()
2598 ));
2599 let schema = json!({
2600 "version": 1,
2601 "tables": [{
2602 "name": "tasks",
2603 "primaryKey": "id",
2604 "columns": [
2605 { "name": "id", "type": "string", "nullable": false },
2606 { "name": "project_id", "type": "string", "nullable": false }
2607 ],
2608 "scopes": [{ "pattern": "project:{project_id}" }]
2609 }]
2610 });
2611 let conflict = ConflictRecord {
2612 client_commit_id: "losing-commit".to_owned(),
2613 op_index: 0,
2614 table: "tasks".to_owned(),
2615 row_id: "t1".to_owned(),
2616 code: "sync.version_conflict".to_owned(),
2617 message: "stale base version".to_owned(),
2618 server_version: 2,
2619 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
2620 operation: Some(CommitOperation {
2621 table: "tasks".to_owned(),
2622 row_id: "t1".to_owned(),
2623 op: "upsert".to_owned(),
2624 base_version: Some(1),
2625 values: None,
2626 changed_fields: None,
2627 }),
2628 };
2629 let failed_operations = vec![
2630 OutboxOp {
2631 upsert: true,
2632 table: "tasks".to_owned(),
2633 row_id: "t1".to_owned(),
2634 base_version: Some(1),
2635 values: None,
2636 changed_fields: None,
2637 },
2638 OutboxOp {
2639 upsert: true,
2640 table: "tasks".to_owned(),
2641 row_id: "status-event-1".to_owned(),
2642 base_version: Some(0),
2643 values: None,
2644 changed_fields: None,
2645 },
2646 ];
2647
2648 {
2649 let mut first = SyncClient::open_path(
2650 "durable-native".to_owned(),
2651 &schema,
2652 ClientLimits::default(),
2653 path.to_str().expect("UTF-8 temp path"),
2654 )
2655 .expect("first open");
2656 first
2657 .begin_observation("test_outcome")
2658 .expect("begin outcome");
2659 first
2660 .persist_commit_outcome(
2661 "losing-commit",
2662 CommitOutcomeStatus::Conflict,
2663 &[CommitOperationOutcome::Conflict {
2664 conflict: conflict.clone(),
2665 }],
2666 Some(&failed_operations),
2667 )
2668 .expect("persist outcome");
2669 first.conflicts.push(conflict);
2670 first
2671 .finish_observation(
2672 "test_outcome",
2673 ChangeAccumulator {
2674 conflicts: true,
2675 outcomes: true,
2676 ..ChangeAccumulator::default()
2677 },
2678 )
2679 .expect("commit outcome");
2680 }
2681
2682 {
2683 let mut reopened = SyncClient::open_path(
2684 "durable-native".to_owned(),
2685 &schema,
2686 ClientLimits::default(),
2687 path.to_str().expect("UTF-8 temp path"),
2688 )
2689 .expect("reopen");
2690 assert_eq!(reopened.conflicts().len(), 1);
2691 let outcome = reopened
2692 .commit_outcome("losing-commit")
2693 .expect("read outcome")
2694 .expect("outcome");
2695 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
2696 let operations = outcome.operations.expect("aggregate envelope");
2697 assert_eq!(operations.len(), 2);
2698 assert_eq!(operations[1].row_id, "status-event-1");
2699 let resolved = reopened
2700 .resolve_commit_outcome(ResolveCommitOutcomeInput {
2701 client_commit_id: "losing-commit".to_owned(),
2702 resolution: CommitOutcomeResolution::ResolvedKeepServer,
2703 replacement_client_commit_id: None,
2704 })
2705 .expect("resolve");
2706 assert_eq!(
2707 resolved.resolution,
2708 CommitOutcomeResolution::ResolvedKeepServer
2709 );
2710 assert!(reopened.conflicts().is_empty());
2711 }
2712
2713 let reopened = SyncClient::open_path(
2714 "durable-native".to_owned(),
2715 &schema,
2716 ClientLimits::default(),
2717 path.to_str().expect("UTF-8 temp path"),
2718 )
2719 .expect("second reopen");
2720 assert!(reopened.conflicts().is_empty());
2721 assert_eq!(
2722 reopened
2723 .commit_outcome("losing-commit")
2724 .expect("read outcome")
2725 .expect("outcome")
2726 .resolution,
2727 CommitOutcomeResolution::ResolvedKeepServer
2728 );
2729 drop(reopened);
2730 std::fs::remove_file(path).expect("remove temp database");
2731 }
2732
2733 #[test]
2734 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
2735 let path =
2736 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
2737 let schema = json!({
2738 "version": 1,
2739 "tables": [{
2740 "name": "tasks",
2741 "primaryKey": "id",
2742 "columns": [
2743 { "name": "id", "type": "string", "nullable": false },
2744 { "name": "project_id", "type": "string", "nullable": false }
2745 ],
2746 "scopes": [{ "pattern": "project:{project_id}" }]
2747 }]
2748 });
2749 let mut client = SyncClient::open_path_with_identity(
2750 Some("sidecar-client".to_owned()),
2751 &schema,
2752 ClientLimits::default(),
2753 path.to_str().expect("UTF-8 temp path"),
2754 )
2755 .expect("open owner");
2756 let base = WindowBase {
2757 table: "tasks".to_owned(),
2758 variable: "project_id".to_owned(),
2759 fixed_scopes: Vec::new(),
2760 params: None,
2761 };
2762 client
2763 .set_window(&base, &["one".to_owned()])
2764 .expect("set window");
2765 client
2766 .mutate(vec![Mutation::Upsert {
2767 table: "tasks".to_owned(),
2768 values: Map::from_iter([
2769 ("id".to_owned(), Value::from("t1")),
2770 ("project_id".to_owned(), Value::from("one")),
2771 ]),
2772 base_version: None,
2773 }])
2774 .expect("local mutate");
2775
2776 let coverage = [WindowCoverage {
2777 base,
2778 units: vec!["one".to_owned(), "missing".to_owned()],
2779 }];
2780 let owner = client
2781 .query_snapshot(
2782 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
2783 &[],
2784 &coverage,
2785 )
2786 .expect("owner snapshot");
2787 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
2788 let sidecar = reader
2789 .query_snapshot(
2790 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
2791 &[],
2792 &coverage,
2793 )
2794 .expect("sidecar snapshot");
2795
2796 assert_eq!(sidecar.revision, owner.revision);
2797 assert_eq!(sidecar.rows, owner.rows);
2798 assert_eq!(
2799 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
2800 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
2801 );
2802 assert_eq!(sidecar.revision, "2");
2803 assert_eq!(sidecar.rows[0]["id"], "t1");
2804 assert_eq!(sidecar.rows[0]["server_version"], -1);
2805 assert!(!sidecar.coverage.complete);
2806 assert_eq!(sidecar.coverage.pending.len(), 1);
2807 assert_eq!(sidecar.coverage.missing.len(), 1);
2808
2809 drop(reader);
2810 drop(client);
2811 std::fs::remove_file(path).expect("remove temp database");
2812 }
2813
2814 #[test]
2815 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
2816 let schema = json!({
2817 "version": 1,
2818 "tables": [{
2819 "name": "catalogue_codes",
2820 "primaryKey": "id",
2821 "columns": [
2822 { "name": "id", "type": "string", "nullable": false },
2823 { "name": "release_id", "type": "string", "nullable": false },
2824 { "name": "code", "type": "string", "nullable": false },
2825 { "name": "title", "type": "string", "nullable": false }
2826 ],
2827 "scopes": [{ "pattern": "release:{release_id}" }],
2828 "ftsIndexes": [{
2829 "name": "catalogue_codes_fts",
2830 "columns": ["code", "title"],
2831 "tokenize": "unicode61 remove_diacritics 2"
2832 }]
2833 }]
2834 });
2835 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
2836 .expect("FTS5 client");
2837 let insert_trigger: String = client
2838 .conn
2839 .query_row(
2840 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
2841 [],
2842 |row| row.get(0),
2843 )
2844 .expect("insert trigger");
2845 let replace_guard: String = client
2846 .conn
2847 .query_row(
2848 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
2849 [],
2850 |row| row.get(0),
2851 )
2852 .expect("replace guard");
2853 assert!(!insert_trigger.contains("DELETE FROM"));
2854 assert!(replace_guard.contains("BEFORE INSERT"));
2855 assert!(replace_guard.contains("WHEN EXISTS"));
2856 let search = |client: &SyncClient, query: &str| {
2857 client
2858 .query(
2859 "SELECT c.id FROM catalogue_codes_fts f JOIN catalogue_codes c ON CAST(c.id AS TEXT) = f._syncular_source_id WHERE catalogue_codes_fts MATCH ?1 ORDER BY c.id",
2860 &[Value::from(query)],
2861 )
2862 .expect("FTS query")
2863 };
2864
2865 client
2866 .mutate(vec![Mutation::Upsert {
2867 table: "catalogue_codes".to_owned(),
2868 values: Map::from_iter([
2869 ("id".to_owned(), Value::from("c1")),
2870 ("release_id".to_owned(), Value::from("r1")),
2871 ("code".to_owned(), Value::from("A01")),
2872 ("title".to_owned(), Value::from("Cholera")),
2873 ]),
2874 base_version: None,
2875 }])
2876 .expect("insert code");
2877 assert_eq!(search(&client, "cholera").len(), 1);
2878
2879 client
2880 .mutate(vec![Mutation::Upsert {
2881 table: "catalogue_codes".to_owned(),
2882 values: Map::from_iter([
2883 ("id".to_owned(), Value::from("c1")),
2884 ("release_id".to_owned(), Value::from("r1")),
2885 ("code".to_owned(), Value::from("A01")),
2886 ("title".to_owned(), Value::from("Enteric infection")),
2887 ]),
2888 base_version: None,
2889 }])
2890 .expect("update code");
2891 assert!(search(&client, "cholera").is_empty());
2892 assert_eq!(search(&client, "enteric").len(), 1);
2893
2894 client
2895 .mutate(vec![Mutation::Delete {
2896 table: "catalogue_codes".to_owned(),
2897 row_id: "c1".to_owned(),
2898 base_version: None,
2899 }])
2900 .expect("delete code");
2901 assert!(search(&client, "enteric").is_empty());
2902 }
2903
2904 #[test]
2905 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
2906 let schema = json!({
2907 "version": 1,
2908 "tables": [{
2909 "name": "patient_notes",
2910 "primaryKey": "id",
2911 "columns": [
2912 { "name": "id", "type": "string", "nullable": false },
2913 { "name": "practice_id", "type": "string", "nullable": false },
2914 { "name": "encryption_key_id", "type": "string", "nullable": false },
2915 { "name": "title", "type": "string", "nullable": false }
2916 ],
2917 "scopes": [{ "pattern": "practice:{practice_id}" }],
2918 "ftsIndexes": [{
2919 "name": "patient_notes_fts",
2920 "columns": ["title"],
2921 "tokenize": "unicode61 remove_diacritics 2"
2922 }]
2923 }]
2924 });
2925 let mut client = SyncClient::new(
2926 "local-purge-test".to_owned(),
2927 &schema,
2928 ClientLimits::default(),
2929 )
2930 .expect("local purge client");
2931 client
2932 .conn
2933 .execute(
2934 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
2935 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
2936 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
2937 [],
2938 )
2939 .expect("seed base rows");
2940 client.overlay_dirty.set(true);
2941 client.rebuild_overlay_if_dirty();
2942
2943 let note = |id: &str, key_id: &str, title: &str| {
2944 Map::from_iter([
2945 ("id".to_owned(), Value::from(id)),
2946 ("practice_id".to_owned(), Value::from("practice-1")),
2947 ("encryption_key_id".to_owned(), Value::from(key_id)),
2948 ("title".to_owned(), Value::from(title)),
2949 ])
2950 };
2951 let doomed = client
2952 .mutate(vec![
2953 Mutation::Upsert {
2954 table: "patient_notes".to_owned(),
2955 values: note("target", "key-revoked", "Target changed"),
2956 base_version: None,
2957 },
2958 Mutation::Upsert {
2959 table: "patient_notes".to_owned(),
2960 values: note("unrelated", "key-held", "Unrelated changed"),
2961 base_version: None,
2962 },
2963 ])
2964 .expect("doomed commit");
2965 let kept = client
2966 .mutate(vec![Mutation::Upsert {
2967 table: "patient_notes".to_owned(),
2968 values: note("kept", "key-held", "Kept optimistic"),
2969 base_version: None,
2970 }])
2971 .expect("kept commit");
2972 client.drain_change_batches();
2973
2974 let input = LocalDataPurgeInput {
2975 purge_id: "purge-001".to_owned(),
2976 targets: vec![LocalDataPurgeTarget {
2977 table: "patient_notes".to_owned(),
2978 selectors: BTreeMap::from([(
2979 "encryption_key_id".to_owned(),
2980 vec!["key-revoked".to_owned()],
2981 )]),
2982 }],
2983 };
2984 assert_eq!(
2985 client.purge_local_data(&input).expect("apply purge"),
2986 LocalDataPurgeResult {
2987 already_applied: false,
2988 purged_rows: 1,
2989 dropped_commits: 1,
2990 }
2991 );
2992 let rows = client
2993 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
2994 .expect("visible rows");
2995 assert_eq!(rows.len(), 2);
2996 assert_eq!(rows[0]["id"], "kept");
2997 assert_eq!(rows[1]["id"], "unrelated");
2998 assert_eq!(rows[1]["title"], "Unrelated original");
2999 let fts = client
3000 .query(
3001 "SELECT n.id FROM patient_notes_fts f JOIN patient_notes n ON CAST(n.id AS TEXT) = f._syncular_source_id WHERE patient_notes_fts MATCH 'target'",
3002 &[],
3003 )
3004 .expect("fts query");
3005 assert!(fts.is_empty());
3006 assert_eq!(client.outbox.len(), 1);
3007 assert_eq!(client.outbox[0].client_commit_id, kept);
3008 let outcome = client
3009 .commit_outcome(&doomed)
3010 .expect("read doomed outcome")
3011 .expect("doomed outcome");
3012 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
3013 match &outcome.results[0] {
3014 CommitOperationOutcome::Error { rejection } => {
3015 assert_eq!(rejection.code, "client.local_data_purged");
3016 assert_eq!(rejection.client_commit_id, doomed);
3017 }
3018 other => panic!("expected local purge rejection, got {other:?}"),
3019 }
3020 assert_eq!(client.drain_change_batches().len(), 1);
3021 assert_eq!(
3022 client.purge_local_data(&input).expect("retry purge"),
3023 LocalDataPurgeResult {
3024 already_applied: true,
3025 purged_rows: 0,
3026 dropped_commits: 0,
3027 }
3028 );
3029 let conflicting = LocalDataPurgeInput {
3030 purge_id: input.purge_id.clone(),
3031 targets: vec![LocalDataPurgeTarget {
3032 table: "patient_notes".to_owned(),
3033 selectors: BTreeMap::from([(
3034 "encryption_key_id".to_owned(),
3035 vec!["key-held".to_owned()],
3036 )]),
3037 }],
3038 };
3039 assert!(client
3040 .purge_local_data(&conflicting)
3041 .expect_err("id collision must fail")
3042 .contains("already used with a different plan"));
3043 }
3044}
3045
3046impl SubState {
3047 fn name(self) -> &'static str {
3048 match self {
3049 SubState::Active => "active",
3050 SubState::Revoked => "revoked",
3051 SubState::Failed => "failed",
3052 }
3053 }
3054
3055 fn parse(value: &str) -> Self {
3056 match value {
3057 "revoked" => Self::Revoked,
3058 "failed" => Self::Failed,
3059 _ => Self::Active,
3060 }
3061 }
3062}
3063
3064#[derive(Debug, Clone)]
3065struct Subscription {
3066 id: String,
3067 table: String,
3068 requested: Vec<(String, Vec<String>)>,
3069 params: Option<String>,
3070 cursor: i64,
3071 bootstrap_state: Option<String>,
3073 state: SubState,
3074 reason_code: Option<String>,
3075 effective: Option<Vec<(String, Vec<String>)>>,
3078 synced_once: bool,
3079}
3080
3081#[derive(Debug, Clone)]
3082struct OutboxOp {
3083 upsert: bool,
3084 table: String,
3085 row_id: String,
3086 base_version: Option<i64>,
3087 values: Option<Map<String, Value>>,
3090 changed_fields: Option<Vec<String>>,
3092}
3093
3094impl From<&OutboxOp> for CommitOperation {
3095 fn from(operation: &OutboxOp) -> Self {
3096 Self {
3097 table: operation.table.clone(),
3098 row_id: operation.row_id.clone(),
3099 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
3100 base_version: operation.base_version,
3101 values: operation.values.clone(),
3102 changed_fields: operation.changed_fields.clone(),
3103 }
3104 }
3105}
3106
3107#[derive(Debug, Clone)]
3108struct OutboxCommit {
3109 client_commit_id: String,
3110 ops: Vec<OutboxOp>,
3111}
3112
3113#[derive(Debug, Clone)]
3114struct CompiledLocalDataPurgeTarget {
3115 table: String,
3116 selectors: Vec<(String, Vec<String>)>,
3117}
3118
3119struct StoredCommitOutcomeRow {
3120 sequence: i64,
3121 client_commit_id: String,
3122 status: String,
3123 recorded_at_ms: i64,
3124 results_json: String,
3125 operations_json: Option<String>,
3126 resolution: String,
3127 resolved_at_ms: Option<i64>,
3128 replacement_client_commit_id: Option<String>,
3129}
3130
3131enum SectionError {
3134 FailClosed,
3135 Abort(String, String),
3136}
3137
3138struct RequestMeta {
3139 pushed_ids: Vec<String>,
3140 fresh: Vec<(String, bool)>,
3143 accept: u8,
3144 deferred_commits: usize,
3148}
3149
3150#[derive(Default)]
3151struct ChangeAccumulator {
3152 tables: BTreeMap<String, Option<BTreeSet<String>>>,
3154 windows: BTreeMap<(String, String), BTreeSet<String>>,
3155 status: bool,
3156 conflicts: bool,
3157 rejections: bool,
3158 outcomes: bool,
3159}
3160
3161impl ChangeAccumulator {
3162 fn table(&mut self, table: &str) {
3163 self.tables.insert(table.to_owned(), None);
3164 }
3165
3166 fn scope(&mut self, table: &str, key: String) {
3167 match self.tables.get_mut(table) {
3168 Some(None) => {}
3169 Some(Some(keys)) => {
3170 keys.insert(key);
3171 }
3172 None => {
3173 self.tables
3174 .insert(table.to_owned(), Some(BTreeSet::from([key])));
3175 }
3176 }
3177 }
3178
3179 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
3180 self.windows
3181 .entry((base_key.to_owned(), table.to_owned()))
3182 .or_default()
3183 .insert(unit.to_owned());
3184 }
3185
3186 fn touched(&self) -> bool {
3187 !self.tables.is_empty()
3188 || !self.windows.is_empty()
3189 || self.status
3190 || self.conflicts
3191 || self.rejections
3192 || self.outcomes
3193 }
3194}
3195
3196const PUSH_OPS_PER_REQUEST: usize = 500;
3202const MAX_LOCAL_PURGE_TARGETS: usize = 64;
3203const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
3204const MAX_LOCAL_PURGE_VALUES: usize = 128;
3205const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
3206pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
3207
3208pub struct SyncClient {
3209 #[cfg(feature = "bench-internals")]
3210 benchmark_phases: Recorder,
3211 conn: Connection,
3212 schema: ClientSchema,
3213 client_id: String,
3214 limits: ClientLimits,
3215 subs: Vec<Subscription>,
3216 outbox: Vec<OutboxCommit>,
3217 conflicts: Vec<ConflictRecord>,
3218 rejections: Vec<RejectionRecord>,
3219 schema_floor: Option<SchemaFloor>,
3220 lease_state: Option<LeaseState>,
3222 stopped: bool,
3224 upgrading: bool,
3226 sync_needed: bool,
3228 realtime_connected: bool,
3229 presence: HashMap<String, HashMap<String, PresencePeer>>,
3231 now_ms: Option<i64>,
3234 encryption: crate::values::EncryptionConfig,
3238 security_preflight: bool,
3241 insert_sql: RefCell<HashMap<String, String>>,
3247 overlay_dirty: Cell<bool>,
3251 #[cfg(test)]
3254 overlay_rebuild_count: Cell<usize>,
3255 #[cfg(test)]
3258 outcome_prune_count: Cell<usize>,
3259 change_queue: VecDeque<ClientChangeBatch>,
3261 sync_intent_queue: VecDeque<SyncIntent>,
3262 retry_delay_ms: u64,
3264 last_round: Option<DiagnosticLastRound>,
3265 last_change: Option<DiagnosticLastChange>,
3266}
3267
3268fn quote_ident(name: &str) -> String {
3269 format!("\"{}\"", name.replace('"', "\"\""))
3270}
3271
3272fn row_id_predicate(table: &TableSchema) -> String {
3274 let key = quote_ident(&table.primary_key);
3275 let text_match = format!("CAST({key} AS TEXT) = ?1");
3276 match table.columns[table.pk_index].ty {
3277 ColumnType::String | ColumnType::Json => {
3278 format!("{key} = ?1 AND {text_match}")
3279 }
3280 ColumnType::Integer | ColumnType::Boolean => {
3284 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
3285 }
3286 _ => text_match,
3289 }
3290}
3291
3292fn is_local_operation_code_like(value: &str) -> bool {
3293 let bytes = value.as_bytes();
3294 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
3295 && bytes
3296 .iter()
3297 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
3298}
3299
3300fn base_table(name: &str) -> String {
3301 quote_ident(&format!("_syncular_base_{name}"))
3302}
3303
3304type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
3309
3310fn window_base_key(base: &WindowBase) -> String {
3311 format!(
3312 "{}\0{}\0{}",
3313 base.table,
3314 base.variable,
3315 canonical_scope_json(&base.fixed_scopes)
3316 )
3317}
3318
3319fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
3321 let mut scopes = base.fixed_scopes.clone();
3322 scopes.retain(|(k, _)| k != &base.variable);
3323 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
3324 scopes
3325}
3326
3327fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
3331 let canonical = canonical_scope_json(&unit_scopes(base, unit));
3332 let digest = Sha256::digest(canonical.as_bytes());
3333 let hex = bytes_to_hex(&digest);
3334 format!("w:{}:{}", base.table, &hex[..16])
3335}
3336
3337fn visible_table(name: &str) -> String {
3338 quote_ident(name)
3339}
3340
3341const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
3342
3343fn is_synced_table_name(name: &str) -> bool {
3346 if name.starts_with("sqlite_") {
3347 return false;
3348 }
3349 if name.starts_with("_syncular_base_") {
3350 return true; }
3352 !name.starts_with("_syncular_")
3354}
3355
3356fn blob_id_for(bytes: &[u8]) -> String {
3358 let digest = Sha256::digest(bytes);
3359 format!("sha256:{}", bytes_to_hex(&digest))
3360}
3361
3362enum RowParam<'a> {
3366 Cell(&'a Option<ColumnValue>),
3367 Version(i64),
3368}
3369
3370impl rusqlite::ToSql for RowParam<'_> {
3371 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
3372 Ok(match self {
3373 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
3374 RowParam::Cell(cell) => match cell {
3375 None => ToSqlOutput::Owned(SqlValue::Null),
3376 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
3377 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
3378 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
3379 Some(ColumnValue::Boolean(b)) => {
3380 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
3381 }
3382 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
3383 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
3384 }
3385 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
3387 ToSqlOutput::Borrowed(ValueRef::Blob(b))
3388 }
3389 },
3390 })
3391 }
3392}
3393
3394fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
3402 use ssp2::segment::ColumnType;
3403 let mismatch = || {
3404 Err(format!(
3405 "image column {:?} holds a value of the wrong type",
3406 column.name
3407 ))
3408 };
3409 match value {
3410 ValueRef::Null => {
3411 if !column.nullable {
3412 return Err(format!(
3413 "image column {:?} is NULL but not nullable",
3414 column.name
3415 ));
3416 }
3417 Ok(ToSqlOutput::Owned(SqlValue::Null))
3418 }
3419 ValueRef::Integer(i) => match column.ty {
3420 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
3421 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
3422 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
3423 _ => mismatch(),
3424 },
3425 ValueRef::Real(_) => match column.ty {
3426 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
3427 _ => mismatch(),
3428 },
3429 ValueRef::Text(t) => {
3430 std::str::from_utf8(t)
3431 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
3432 match column.ty {
3433 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
3434 Ok(ToSqlOutput::Borrowed(value))
3435 }
3436 _ => mismatch(),
3437 }
3438 }
3439 ValueRef::Blob(_) => match column.ty {
3440 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
3442 _ => mismatch(),
3443 },
3444 }
3445}
3446
3447fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
3448 use rusqlite::types::ValueRef;
3449 match value {
3450 ValueRef::Null => Value::Null,
3451 ValueRef::Integer(i) => match column.ty {
3452 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
3453 ssp2::segment::ColumnType::Float => {
3454 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
3455 }
3456 _ => Value::from(i),
3457 },
3458 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3459 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3460 ValueRef::Blob(b) => {
3461 let mut map = Map::new();
3462 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3463 Value::Object(map)
3464 }
3465 }
3466}
3467
3468fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
3472 Ok(match value {
3473 Value::Null => SqlValue::Null,
3474 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
3475 Value::Number(n) => {
3476 if let Some(i) = n.as_i64() {
3477 SqlValue::Integer(i)
3478 } else if let Some(f) = n.as_f64() {
3479 SqlValue::Real(f)
3480 } else {
3481 return Err(format!("query param number {n} is out of range"));
3482 }
3483 }
3484 Value::String(s) => SqlValue::Text(s.clone()),
3485 Value::Object(_) => {
3486 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
3487 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
3488 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
3489 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
3490 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
3491 })?)
3492 } else {
3493 return Err(
3494 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
3495 .to_owned(),
3496 );
3497 }
3498 }
3499 Value::Array(_) => return Err("query array params are not supported".to_owned()),
3500 })
3501}
3502
3503fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
3508 use rusqlite::types::ValueRef;
3509 match value {
3510 ValueRef::Null => Value::Null,
3511 ValueRef::Integer(i) => {
3512 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
3516 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
3517 Value::from(i)
3518 } else {
3519 let mut map = Map::new();
3520 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
3521 Value::Object(map)
3522 }
3523 }
3524 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
3525 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
3526 ValueRef::Blob(b) => {
3527 let mut map = Map::new();
3528 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
3529 Value::Object(map)
3530 }
3531 }
3532}
3533
3534fn query_connection(
3535 conn: &Connection,
3536 sql: &str,
3537 params: &[QueryValue],
3538) -> Result<Vec<QueryRow>, String> {
3539 crate::query_guard::assert_read_only_query(sql)?;
3540 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
3541 let bound: Vec<SqlValue> = params
3542 .iter()
3543 .map(json_param_to_sql)
3544 .collect::<Result<_, _>>()?;
3545 let mut stmt = conn.prepare(&lowered_sql).map_err(|e| e.to_string())?;
3546 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
3547 let bound_refs: Vec<&dyn rusqlite::ToSql> =
3548 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
3549 let mut sql_rows = stmt
3550 .query(bound_refs.as_slice())
3551 .map_err(|e| e.to_string())?;
3552 let mut out = Vec::new();
3553 while let Some(row) = sql_rows.next().map_err(|e| e.to_string())? {
3554 let mut record = Map::new();
3555 for (i, name) in column_names.iter().enumerate() {
3556 let value = row.get_ref(i).map_err(|e| e.to_string())?;
3557 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
3558 }
3559 out.push(record);
3560 }
3561 Ok(out)
3562}
3563
3564fn persisted_window_state(conn: &Connection, base: &WindowBase) -> Result<WindowState, String> {
3565 let mut stmt = conn
3566 .prepare(
3567 "SELECT windows.unit, subscriptions.state_json
3568 FROM _syncular_windows AS windows
3569 JOIN _syncular_subscriptions AS subscriptions
3570 ON subscriptions.id = windows.sub_id
3571 WHERE windows.base = ?1
3572 ORDER BY windows.unit ASC",
3573 )
3574 .map_err(|error| error.to_string())?;
3575 let rows = stmt
3576 .query_map(rusqlite::params![window_base_key(base)], |row| {
3577 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
3578 })
3579 .map_err(|error| error.to_string())?;
3580 let mut units = Vec::new();
3581 let mut pending = Vec::new();
3582 for row in rows {
3583 let (unit, raw) = row.map_err(|error| error.to_string())?;
3584 let state: Value = serde_json::from_str(&raw)
3585 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
3586 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
3587 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
3588 || state
3589 .get("bootstrapState")
3590 .is_some_and(|value| !value.is_null());
3591 if is_pending {
3592 pending.push(unit.clone());
3593 }
3594 units.push(unit);
3595 }
3596 Ok(WindowState { units, pending })
3597}
3598
3599fn snapshot_connection(
3600 conn: &Connection,
3601 sql: &str,
3602 params: &[Value],
3603 coverage: &[WindowCoverage],
3604) -> Result<QuerySnapshot, String> {
3605 conn.execute_batch("SAVEPOINT syncular_snapshot_read")
3606 .map_err(|error| error.to_string())?;
3607 let result = (|| {
3608 let revision = conn
3609 .query_row(
3610 "SELECT value FROM _syncular_meta WHERE key = ?1",
3611 rusqlite::params![LOCAL_REVISION_KEY],
3612 |row| row.get::<_, String>(0),
3613 )
3614 .ok()
3615 .and_then(|value| value.parse::<u64>().ok())
3616 .unwrap_or(0);
3617 let rows = query_connection(conn, sql, params)?;
3618 let mut pending = Vec::new();
3619 let mut missing = Vec::new();
3620 for requested in coverage {
3621 let base_key = window_base_key(&requested.base);
3622 let state = persisted_window_state(conn, &requested.base)?;
3623 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
3624 let reference = WindowUnitRef {
3625 base_key: base_key.clone(),
3626 unit: unit.clone(),
3627 };
3628 if !state.units.iter().any(|held| held == &unit) {
3629 missing.push(reference);
3630 } else if state.pending.iter().any(|held| held == &unit) {
3631 pending.push(reference);
3632 }
3633 }
3634 }
3635 Ok(QuerySnapshot {
3636 revision: revision.to_string(),
3637 rows,
3638 coverage: CoverageSnapshot {
3639 complete: pending.is_empty() && missing.is_empty(),
3640 pending,
3641 missing,
3642 },
3643 })
3644 })();
3645 match result {
3646 Ok(snapshot) => {
3647 conn.execute_batch("RELEASE syncular_snapshot_read")
3648 .map_err(|error| error.to_string())?;
3649 Ok(snapshot)
3650 }
3651 Err(error) => {
3652 let _ = conn.execute_batch(
3653 "ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read",
3654 );
3655 Err(error)
3656 }
3657 }
3658}
3659
3660pub struct FileQuerySnapshotReader {
3665 path: String,
3666 conn: Option<Connection>,
3667}
3668
3669impl FileQuerySnapshotReader {
3670 #[must_use]
3671 pub fn new(path: impl Into<String>) -> Self {
3672 Self {
3673 path: path.into(),
3674 conn: None,
3675 }
3676 }
3677
3678 fn connection(&mut self) -> Result<&Connection, String> {
3679 if self.conn.is_none() {
3680 let conn = Connection::open_with_flags(
3681 &self.path,
3682 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
3683 )
3684 .map_err(|error| format!("open read sidecar {:?}: {error}", self.path))?;
3685 conn.busy_timeout(std::time::Duration::from_millis(250))
3686 .map_err(|error| error.to_string())?;
3687 self.conn = Some(conn);
3688 }
3689 self.conn
3690 .as_ref()
3691 .ok_or_else(|| "read sidecar connection missing".to_owned())
3692 }
3693
3694 pub fn query_snapshot(
3695 &mut self,
3696 sql: &str,
3697 params: &[Value],
3698 coverage: &[WindowCoverage],
3699 ) -> Result<QuerySnapshot, String> {
3700 snapshot_connection(self.connection()?, sql, params, coverage)
3701 }
3702}
3703
3704impl SyncClient {
3705 pub fn new_with_identity(
3706 client_id: Option<String>,
3707 schema_json: &Value,
3708 limits: ClientLimits,
3709 ) -> Result<Self, String> {
3710 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
3711 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
3712 Self::with_connection(resolved, schema_json, limits, conn)
3713 }
3714
3715 pub fn new(
3716 client_id: String,
3717 schema_json: &Value,
3718 limits: ClientLimits,
3719 ) -> Result<Self, String> {
3720 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
3721 Self::with_connection(client_id, schema_json, limits, conn)
3722 }
3723
3724 pub fn open_path(
3730 client_id: String,
3731 schema_json: &Value,
3732 limits: ClientLimits,
3733 path: &str,
3734 ) -> Result<Self, String> {
3735 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
3736 Self::with_connection(client_id, schema_json, limits, conn)
3737 }
3738
3739 pub fn open_path_with_identity(
3740 client_id: Option<String>,
3741 schema_json: &Value,
3742 limits: ClientLimits,
3743 path: &str,
3744 ) -> Result<Self, String> {
3745 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
3746 conn.busy_timeout(std::time::Duration::from_millis(250))
3752 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
3753 conn.pragma_update(None, "journal_mode", "WAL")
3754 .map_err(|error| format!("configure db {path:?} WAL mode: {error}"))?;
3755 let persisted = conn
3756 .query_row(
3757 "SELECT value FROM _syncular_meta WHERE key = 'clientId'",
3758 [],
3759 |row| row.get::<_, String>(0),
3760 )
3761 .ok();
3762 let resolved = persisted
3763 .clone()
3764 .or(client_id.clone())
3765 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
3766 if let (Some(existing), Some(requested)) = (persisted, client_id) {
3767 if existing != requested {
3768 return Err(format!(
3769 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
3770 ));
3771 }
3772 }
3773 Self::with_connection(resolved, schema_json, limits, conn)
3774 }
3775
3776 #[must_use]
3777 pub fn client_id(&self) -> &str {
3778 &self.client_id
3779 }
3780
3781 pub fn with_connection(
3788 client_id: String,
3789 schema_json: &Value,
3790 limits: ClientLimits,
3791 conn: Connection,
3792 ) -> Result<Self, String> {
3793 if limits.outcome_retention_max_entries == Some(0) {
3794 return Err(
3795 "sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned(),
3796 );
3797 }
3798 let schema = parse_schema_json(schema_json)?;
3799 let mut client = SyncClient {
3800 #[cfg(feature = "bench-internals")]
3801 benchmark_phases: Recorder::default(),
3802 conn,
3803 schema,
3804 client_id,
3805 limits,
3806 subs: Vec::new(),
3807 outbox: Vec::new(),
3808 conflicts: Vec::new(),
3809 rejections: Vec::new(),
3810 schema_floor: None,
3811 lease_state: None,
3812 stopped: false,
3813 upgrading: false,
3814 sync_needed: false,
3815 realtime_connected: false,
3816 presence: HashMap::new(),
3817 now_ms: None,
3818 encryption: crate::values::EncryptionConfig::default(),
3819 security_preflight: false,
3820 insert_sql: RefCell::new(HashMap::new()),
3821 overlay_dirty: Cell::new(false),
3822 #[cfg(test)]
3823 overlay_rebuild_count: Cell::new(0),
3824 #[cfg(test)]
3825 outcome_prune_count: Cell::new(0),
3826 change_queue: VecDeque::new(),
3827 sync_intent_queue: VecDeque::new(),
3828 retry_delay_ms: 250,
3829 last_round: None,
3830 last_change: None,
3831 };
3832 client
3836 .conn
3837 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
3838 client.create_bookkeeping_tables()?;
3842 match client.get_meta(CLIENT_ID_KEY) {
3843 Some(existing) if existing != client.client_id => {
3844 return Err(format!(
3845 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {:?}",
3846 client.client_id
3847 ));
3848 }
3849 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
3850 _ => {}
3851 }
3852 client.restore_persisted_state()?;
3853 let marker = client
3854 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
3855 .and_then(|value| value.parse::<i32>().ok());
3856 match marker {
3857 None => {
3858 client.create_synced_tables()?;
3859 client.set_meta(LOCAL_SCHEMA_VERSION_KEY, &client.schema.version.to_string());
3860 }
3861 Some(version) if version == client.schema.version => {
3862 client.create_synced_tables()?;
3863 }
3864 Some(_) => client.run_schema_reset()?,
3865 }
3866 client.clear_satisfied_persisted_schema_floor();
3867 client.prune_unknown_subscriptions()?;
3868 if marker == Some(client.schema.version) && !client.outbox.is_empty() {
3869 client.overlay_dirty.set(true);
3872 client.rebuild_overlay();
3873 }
3874 client.enqueue_startup_sync_if_needed();
3880 Ok(client)
3881 }
3882
3883 pub fn set_now_ms(&mut self, now_ms: i64) {
3886 self.now_ms = Some(now_ms);
3887 }
3888
3889 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
3893 self.encryption = encryption;
3894 }
3895
3896 #[must_use]
3897 pub fn security_lifecycle(&self) -> &'static str {
3898 if self.security_preflight {
3899 "preflight"
3900 } else {
3901 "active"
3902 }
3903 }
3904
3905 #[must_use]
3906 pub fn security_preflight(&self) -> bool {
3907 self.security_preflight
3908 }
3909
3910 pub fn begin_security_preflight(&mut self) {
3917 self.seal_security_on_teardown();
3918 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
3921 }
3922
3923 pub fn seal_security_on_teardown(&mut self) {
3933 self.security_preflight = true;
3934 self.encryption = crate::values::EncryptionConfig::default();
3935 self.sync_intent_queue.clear();
3936 }
3937
3938 pub fn activate_security(
3940 &mut self,
3941 encryption: crate::values::EncryptionConfig,
3942 ) -> Result<(), String> {
3943 if !self.security_preflight {
3944 return Err(
3945 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
3946 );
3947 }
3948 self.encryption = encryption;
3949 self.security_preflight = false;
3950 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
3951 self.enqueue_startup_sync_if_needed();
3952 Ok(())
3953 }
3954
3955 fn clock_now_ms(&self) -> i64 {
3956 self.now_ms.unwrap_or_else(|| {
3957 std::time::SystemTime::now()
3958 .duration_since(std::time::UNIX_EPOCH)
3959 .map(|d| d.as_millis() as i64)
3960 .unwrap_or(0)
3961 })
3962 }
3963
3964 fn create_bookkeeping_tables(&self) -> Result<(), String> {
3965 self.conn
3967 .execute_batch(
3968 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
3969 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3970 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
3971 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
3972 seq INTEGER PRIMARY KEY AUTOINCREMENT,
3973 client_commit_id TEXT NOT NULL UNIQUE,
3974 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
3975 recorded_at_ms INTEGER NOT NULL,
3976 results_json TEXT NOT NULL,
3977 operations_json TEXT,
3978 resolution TEXT NOT NULL DEFAULT 'active'
3979 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
3980 resolved_at_ms INTEGER,
3981 replacement_client_commit_id TEXT);
3982 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
3983 ON _syncular_commit_outcomes(resolution, seq);
3984 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
3985 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
3986 CREATE TABLE IF NOT EXISTS _syncular_meta (
3987 key TEXT PRIMARY KEY, value TEXT NOT NULL);
3988 CREATE TABLE IF NOT EXISTS _syncular_windows (
3989 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
3990 PRIMARY KEY (base, unit));
3991 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
3992 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
3993 effective_scopes TEXT NOT NULL);",
3994 )
3995 .map_err(|e| e.to_string())?;
3996 let _ = self
3999 .conn
4000 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
4001 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
4002 self.set_meta(LOCAL_REVISION_KEY, "0");
4003 }
4004 if self.schema_has_blobs() {
4009 self.conn
4010 .execute_batch(
4011 "CREATE TABLE IF NOT EXISTS _syncular_blobs (blob_id TEXT PRIMARY KEY,
4012 bytes BLOB NOT NULL, byte_length INTEGER NOT NULL,
4013 media_type TEXT, refcount INTEGER NOT NULL DEFAULT 0,
4014 created_at_ms INTEGER NOT NULL,
4015 last_used_ms INTEGER NOT NULL DEFAULT 0);
4016 CREATE TABLE IF NOT EXISTS _syncular_blob_uploads (blob_id TEXT PRIMARY KEY,
4017 media_type TEXT, created_at_ms INTEGER NOT NULL);",
4018 )
4019 .map_err(|e| e.to_string())?;
4020 let _ = self.conn.execute_batch(
4024 "ALTER TABLE _syncular_blobs ADD COLUMN last_used_ms INTEGER NOT NULL DEFAULT 0",
4025 );
4026 }
4027 Ok(())
4028 }
4029
4030 fn schema_has_blobs(&self) -> bool {
4032 self.schema
4033 .tables
4034 .iter()
4035 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
4036 }
4037
4038 fn get_meta(&self, key: &str) -> Option<String> {
4041 self.conn
4042 .query_row(
4043 "SELECT value FROM _syncular_meta WHERE key = ?1",
4044 rusqlite::params![key],
4045 |row| row.get::<_, String>(0),
4046 )
4047 .ok()
4048 }
4049
4050 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
4051 self.conn
4052 .query_row(
4053 "SELECT value FROM _syncular_meta WHERE key = ?1",
4054 rusqlite::params![key],
4055 |row| row.get::<_, String>(0),
4056 )
4057 .optional()
4058 .map_err(|_| {
4059 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
4060 })
4061 }
4062
4063 fn set_meta(&self, key: &str, value: &str) {
4064 let _ = self.conn.execute(
4065 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
4066 rusqlite::params![key, value],
4067 );
4068 }
4069
4070 fn delete_meta(&self, key: &str) {
4071 let _ = self.conn.execute(
4072 "DELETE FROM _syncular_meta WHERE key = ?1",
4073 rusqlite::params![key],
4074 );
4075 }
4076
4077 fn restore_persisted_state(&mut self) -> Result<(), String> {
4078 self.subs = {
4079 let mut stmt = self
4080 .conn
4081 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
4082 .map_err(|error| error.to_string())?;
4083 let rows = stmt
4084 .query_map([], |row| {
4085 Ok((
4086 row.get::<_, String>(0)?,
4087 row.get::<_, String>(1)?,
4088 row.get::<_, String>(2)?,
4089 ))
4090 })
4091 .map_err(|error| error.to_string())?;
4092 let mut subscriptions = Vec::new();
4093 for row in rows {
4094 let (id, table, raw) = row.map_err(|error| error.to_string())?;
4095 let state: Value = serde_json::from_str(&raw)
4096 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
4097 let requested = json_to_scope_map(
4098 state.get("requested").unwrap_or(&Value::Object(Map::new())),
4099 )?;
4100 let effective = state
4101 .get("effectiveScopes")
4102 .filter(|value| !value.is_null())
4103 .map(json_to_scope_map)
4104 .transpose()?;
4105 subscriptions.push(Subscription {
4106 id,
4107 table,
4108 requested,
4109 params: state
4110 .get("params")
4111 .and_then(Value::as_str)
4112 .map(str::to_owned),
4113 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
4114 bootstrap_state: state
4115 .get("bootstrapState")
4116 .and_then(Value::as_str)
4117 .map(str::to_owned),
4118 state: SubState::parse(
4119 state
4120 .get("status")
4121 .and_then(Value::as_str)
4122 .unwrap_or("active"),
4123 ),
4124 reason_code: state
4125 .get("reasonCode")
4126 .and_then(Value::as_str)
4127 .map(str::to_owned),
4128 effective,
4129 synced_once: state
4130 .get("syncedOnce")
4131 .and_then(Value::as_bool)
4132 .unwrap_or(false),
4133 });
4134 }
4135 subscriptions
4136 };
4137
4138 self.outbox = {
4139 let mut stmt = self
4140 .conn
4141 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
4142 .map_err(|error| error.to_string())?;
4143 let rows = stmt
4144 .query_map([], |row| {
4145 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
4146 })
4147 .map_err(|error| error.to_string())?;
4148 let mut commits = Vec::new();
4149 for row in rows {
4150 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
4151 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
4152 format!("invalid persisted outbox {client_commit_id:?}: {error}")
4153 })?;
4154 let mut ops = Vec::with_capacity(entries.len());
4155 for entry in entries {
4156 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
4157 ops.push(OutboxOp {
4158 upsert: op == "upsert",
4159 table: entry
4160 .get("table")
4161 .and_then(Value::as_str)
4162 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
4163 .to_owned(),
4164 row_id: entry
4165 .get("rowId")
4166 .and_then(Value::as_str)
4167 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
4168 .to_owned(),
4169 base_version: entry.get("baseVersion").and_then(Value::as_i64),
4170 values: entry.get("values").and_then(Value::as_object).cloned(),
4171 changed_fields: entry.get("changedFields").and_then(Value::as_array).map(
4172 |values| {
4173 values
4174 .iter()
4175 .filter_map(Value::as_str)
4176 .map(str::to_owned)
4177 .collect()
4178 },
4179 ),
4180 });
4181 }
4182 commits.push(OutboxCommit {
4183 client_commit_id,
4184 ops,
4185 });
4186 }
4187 commits
4188 };
4189
4190 self.prune_commit_outcomes()?;
4191 let active = self.commit_outcomes(CommitOutcomeQuery {
4192 active_only: true,
4193 ..CommitOutcomeQuery::default()
4194 })?;
4195 self.conflicts = active
4196 .iter()
4197 .flat_map(|outcome| outcome.results.iter())
4198 .filter_map(|result| match result {
4199 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
4200 _ => None,
4201 })
4202 .collect();
4203 self.rejections = active
4204 .iter()
4205 .flat_map(|outcome| outcome.results.iter())
4206 .filter_map(|result| match result {
4207 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
4208 _ => None,
4209 })
4210 .collect();
4211
4212 self.lease_state = self
4213 .get_meta(LEASE_STATE_KEY)
4214 .map(|raw| serde_json::from_str(&raw))
4215 .transpose()
4216 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
4217 self.schema_floor = self
4218 .get_meta(SCHEMA_FLOOR_KEY)
4219 .map(|raw| serde_json::from_str(&raw))
4220 .transpose()
4221 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
4222 self.stopped = self.schema_floor.is_some();
4223 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
4226 self.security_preflight = true;
4227 }
4228 Ok(())
4229 }
4230
4231 fn prune_unknown_subscriptions(&mut self) -> Result<(), String> {
4234 let valid_tables: BTreeSet<String> = self
4235 .schema
4236 .tables
4237 .iter()
4238 .map(|table| table.name.clone())
4239 .collect();
4240 let stale_ids: Vec<String> = self
4241 .subs
4242 .iter()
4243 .filter(|sub| !valid_tables.contains(&sub.table))
4244 .map(|sub| sub.id.clone())
4245 .collect();
4246 for id in &stale_ids {
4247 self.conn
4248 .execute(
4249 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
4250 rusqlite::params![id],
4251 )
4252 .map_err(|error| error.to_string())?;
4253 self.conn
4254 .execute(
4255 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
4256 rusqlite::params![id],
4257 )
4258 .map_err(|error| error.to_string())?;
4259 self.conn
4260 .execute(
4261 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
4262 rusqlite::params![id],
4263 )
4264 .map_err(|error| error.to_string())?;
4265 }
4266 self.subs.retain(|sub| valid_tables.contains(&sub.table));
4267 Ok(())
4268 }
4269
4270 #[must_use]
4271 pub fn local_revision(&self) -> u64 {
4272 self.get_meta(LOCAL_REVISION_KEY)
4273 .and_then(|value| value.parse().ok())
4274 .unwrap_or(0)
4275 }
4276
4277 #[must_use]
4278 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
4279 SyncStatusSnapshot {
4280 current_schema_version: self.schema.version,
4281 outbox: self.outbox.len(),
4282 upgrading: self.upgrading,
4283 lease_state: self.lease_state.clone(),
4284 schema_floor: self.schema_floor.clone(),
4285 sync_needed: self.sync_needed,
4286 }
4287 }
4288
4289 pub fn diagnostics_snapshot(
4290 &self,
4291 request: &ClientDiagnosticsRequest,
4292 ) -> Result<ClientDiagnosticsSnapshot, String> {
4293 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
4294 return Err(format!(
4295 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
4296 ));
4297 }
4298 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
4299 for sub in &self.subs {
4300 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
4301 let complete =
4302 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
4303 let state = match sub.state {
4304 SubState::Revoked => "revoked",
4305 SubState::Failed => "failed",
4306 SubState::Active if reset => "reset",
4307 SubState::Active if complete => "complete",
4308 SubState::Active => "bootstrapping",
4309 };
4310 subscriptions.insert(
4311 sub.id.clone(),
4312 DiagnosticSubscription {
4313 id: sub.id.clone(),
4314 table: sub.table.clone(),
4315 state: state.to_owned(),
4316 complete,
4317 cursor: Some(sub.cursor),
4318 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
4319 },
4320 );
4321 }
4322 for expected in &request.expected_subscriptions {
4323 if expected.id.is_empty() || expected.table.is_empty() {
4324 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
4325 }
4326 if subscriptions
4327 .get(&expected.id)
4328 .is_some_and(|registered| registered.table != expected.table)
4329 {
4330 subscriptions.insert(
4331 expected.id.clone(),
4332 DiagnosticSubscription {
4333 id: expected.id.clone(),
4334 table: expected.table.clone(),
4335 state: "failed".to_owned(),
4336 complete: false,
4337 cursor: None,
4338 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
4339 },
4340 );
4341 } else {
4342 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
4343 DiagnosticSubscription {
4344 id: expected.id.clone(),
4345 table: expected.table.clone(),
4346 state: "unregistered".to_owned(),
4347 complete: false,
4348 cursor: None,
4349 reason_code: None,
4350 }
4351 });
4352 }
4353 }
4354 let mut ordered_subscriptions = Vec::new();
4355 let mut included = BTreeSet::new();
4356 for expected in &request.expected_subscriptions {
4357 if included.insert(expected.id.clone()) {
4358 if let Some(subscription) = subscriptions.get(&expected.id) {
4359 ordered_subscriptions.push(subscription.clone());
4360 }
4361 }
4362 }
4363 for (id, subscription) in &subscriptions {
4364 if included.insert(id.clone()) {
4365 ordered_subscriptions.push(subscription.clone());
4366 }
4367 }
4368 let subscriptions_truncated =
4369 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
4370 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
4371 let captured_at_ms = self.clock_now_ms();
4372 let lease = if let Some(error_code) = self
4373 .lease_state
4374 .as_ref()
4375 .and_then(|state| state.error_code.clone())
4376 {
4377 ClientDiagnosticsLease {
4378 state: "stopped".to_owned(),
4379 expires_at_ms: self
4380 .lease_state
4381 .as_ref()
4382 .and_then(|state| state.expires_at_ms),
4383 error_code: Some(Self::diagnostic_code(&error_code)),
4384 }
4385 } else if let Some(expires_at_ms) = self
4386 .lease_state
4387 .as_ref()
4388 .and_then(|state| state.expires_at_ms)
4389 {
4390 ClientDiagnosticsLease {
4391 state: if expires_at_ms <= captured_at_ms {
4392 "expired".to_owned()
4393 } else {
4394 "active".to_owned()
4395 },
4396 expires_at_ms: Some(expires_at_ms),
4397 error_code: None,
4398 }
4399 } else {
4400 ClientDiagnosticsLease {
4401 state: "none".to_owned(),
4402 expires_at_ms: None,
4403 error_code: None,
4404 }
4405 };
4406 let connectivity = match self.last_round.as_ref() {
4407 Some(round) if round.status == "succeeded" => "online",
4408 Some(round)
4409 if round.status == "failed"
4410 && round
4411 .error_code
4412 .as_deref()
4413 .is_some_and(Self::retryable_transport_code) =>
4414 {
4415 "offline"
4416 }
4417 _ => "unknown",
4418 };
4419 Ok(ClientDiagnosticsSnapshot {
4420 version: CLIENT_DIAGNOSTICS_VERSION,
4421 captured_at_ms,
4422 host: ClientDiagnosticsHost {
4423 kind: "direct".to_owned(),
4424 role: "single".to_owned(),
4425 connectivity: connectivity.to_owned(),
4426 realtime: if self.realtime_connected {
4427 "connected".to_owned()
4428 } else {
4429 "disconnected".to_owned()
4430 },
4431 },
4432 security_lifecycle: self.security_lifecycle().to_owned(),
4433 schema: ClientDiagnosticsSchema {
4434 current_version: self.schema.version,
4435 upgrading: self.upgrading,
4436 required_version: self
4437 .schema_floor
4438 .as_ref()
4439 .and_then(|floor| floor.required_schema_version),
4440 latest_version: self
4441 .schema_floor
4442 .as_ref()
4443 .and_then(|floor| floor.latest_schema_version),
4444 },
4445 replica: ClientDiagnosticsReplica {
4446 local_revision: self.local_revision().to_string(),
4447 sync_needed: self.sync_needed,
4448 pending_outbox: self.outbox.len(),
4449 },
4450 lease,
4451 subscriptions: ordered_subscriptions,
4452 subscriptions_truncated,
4453 last_round: self.last_round.clone(),
4454 last_change: self.last_change.clone(),
4455 storage: self.diagnostics_storage(),
4456 })
4457 }
4458
4459 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
4460 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
4461 let page_count: i64 = self
4462 .conn
4463 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
4464 let page_size: i64 = self
4465 .conn
4466 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
4467 let outbox_bytes: i64 = self
4469 .conn
4470 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
4471 .query_row([], |row| row.get(0))?;
4472 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
4473 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
4474 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
4475 let blob_bytes = if self.schema_has_blobs() {
4476 self.conn
4477 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
4478 .query_row([], |row| row.get(0))?
4479 } else {
4480 0
4481 };
4482 let pressure = self
4483 .limits
4484 .blob_cache_max_bytes
4485 .is_some_and(|limit| blob_bytes > limit);
4486 Ok(ClientDiagnosticsStorage {
4487 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
4488 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
4489 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
4490 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
4491 retained_outcome_entries: Some(outcome_entries.max(0)),
4492 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
4493 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
4494 })
4495 };
4496 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
4497 status: "unreadable".to_owned(),
4498 database_bytes_approx: None,
4499 pending_outbox_bytes_approx: None,
4500 retained_outcome_bytes_approx: None,
4501 retained_outcome_entries: None,
4502 blob_cache_bytes_approx: None,
4503 pressure_reason_code: None,
4504 })
4505 }
4506
4507 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
4508 self.change_queue.drain(..).collect()
4509 }
4510
4511 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
4512 self.sync_intent_queue.drain(..).collect()
4513 }
4514
4515 fn schedule_background_retry(&mut self) {
4516 self.sync_intent_queue.push_back(SyncIntent::Background {
4517 delay_ms: self.retry_delay_ms,
4518 });
4519 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
4520 }
4521
4522 fn reset_background_retry(&mut self) {
4523 self.retry_delay_ms = 250;
4524 }
4525
4526 fn retryable_transport_code(code: &str) -> bool {
4527 code == "transport.failed"
4528 || code == "transport.unavailable"
4529 || code == "sync.transport_failed"
4530 }
4531
4532 fn diagnostic_code(code: &str) -> String {
4533 let valid = !code.is_empty()
4534 && code.len() <= 96
4535 && code.contains('.')
4536 && code
4537 .bytes()
4538 .next()
4539 .is_some_and(|byte| byte.is_ascii_lowercase())
4540 && code.bytes().all(|byte| {
4541 byte.is_ascii_lowercase()
4542 || byte.is_ascii_digit()
4543 || matches!(byte, b'.' | b'_' | b'-')
4544 });
4545 if valid {
4546 code.to_owned()
4547 } else {
4548 "client.unknown_failure".to_owned()
4549 }
4550 }
4551
4552 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
4553 if self.sync_needed != value {
4554 if self.begin_observation("syncular_status").is_ok() {
4555 self.sync_needed = value;
4556 let batch = ChangeAccumulator {
4557 status: true,
4558 ..ChangeAccumulator::default()
4559 };
4560 if self.finish_observation("syncular_status", batch).is_err() {
4561 self.rollback_observation("syncular_status");
4562 }
4563 } else {
4564 self.sync_needed = value;
4565 }
4566 }
4567 if value && interactive {
4568 self.sync_intent_queue.push_back(SyncIntent::Interactive);
4569 }
4570 }
4571
4572 fn begin_observation(&self, name: &str) -> Result<(), String> {
4573 self.conn
4574 .execute_batch(&format!("SAVEPOINT {name}"))
4575 .map_err(|error| error.to_string())
4576 }
4577
4578 fn rollback_observation(&self, name: &str) {
4579 let _ = self
4580 .conn
4581 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"));
4582 }
4583
4584 fn finish_observation(&mut self, name: &str, batch: ChangeAccumulator) -> Result<(), String> {
4585 #[cfg(feature = "bench-internals")]
4586 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
4587 if !batch.touched() {
4588 self.conn
4589 .execute_batch(&format!("RELEASE {name}"))
4590 .map_err(|error| error.to_string())?;
4591 return Ok(());
4592 }
4593 let revision = self
4594 .local_revision()
4595 .checked_add(1)
4596 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
4597 self.conn
4598 .execute(
4599 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
4600 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
4601 )
4602 .map_err(|error| error.to_string())?;
4603 let status = batch.status.then(|| self.status_snapshot());
4604 let event = ClientChangeBatch {
4605 revision: revision.to_string(),
4606 tables: batch
4607 .tables
4608 .into_iter()
4609 .map(|(table, scope_keys)| TableChange {
4610 table,
4611 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
4612 })
4613 .collect(),
4614 windows: batch
4615 .windows
4616 .into_iter()
4617 .map(|((base_key, table), units)| WindowChange {
4618 base_key,
4619 table,
4620 units: units.into_iter().collect(),
4621 })
4622 .collect(),
4623 status,
4624 conflicts_changed: batch.conflicts,
4625 rejections_changed: batch.rejections,
4626 outcomes_changed: batch.outcomes,
4627 };
4628 self.conn
4629 .execute_batch(&format!("RELEASE {name}"))
4630 .map_err(|error| error.to_string())?;
4631 let mut diagnostic_tables = event
4632 .tables
4633 .iter()
4634 .map(|entry| entry.table.clone())
4635 .collect::<BTreeSet<_>>()
4636 .into_iter()
4637 .collect::<Vec<_>>();
4638 let mut diagnostic_windows = event
4639 .windows
4640 .iter()
4641 .map(|entry| entry.table.clone())
4642 .collect::<BTreeSet<_>>()
4643 .into_iter()
4644 .collect::<Vec<_>>();
4645 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
4646 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
4647 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
4648 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
4649 self.last_change = Some(DiagnosticLastChange {
4650 revision: event.revision.clone(),
4651 recorded_at_ms: self.clock_now_ms(),
4652 tables: diagnostic_tables,
4653 windows: diagnostic_windows,
4654 domains_truncated,
4655 status_changed: event.status.is_some(),
4656 conflicts_changed: event.conflicts_changed,
4657 rejections_changed: event.rejections_changed,
4658 outcomes_changed: event.outcomes_changed,
4659 });
4660 self.change_queue.push_back(event);
4661 Ok(())
4662 }
4663
4664 fn record_scope_map(
4665 &self,
4666 batch: &mut ChangeAccumulator,
4667 table_name: &str,
4668 scopes: &[(String, Vec<String>)],
4669 ) {
4670 let Some(table) = self.schema.table(table_name) else {
4671 return;
4672 };
4673 for (variable, values) in scopes {
4674 let Some(scope) = table
4675 .scope_variables
4676 .iter()
4677 .find(|scope| &scope.variable == variable)
4678 else {
4679 continue;
4680 };
4681 for value in values {
4682 batch.scope(table_name, format!("{}:{value}", scope.prefix));
4683 }
4684 }
4685 }
4686
4687 fn record_row_scopes(
4689 &self,
4690 batch: &mut ChangeAccumulator,
4691 table_name: &str,
4692 row_id: &str,
4693 base: bool,
4694 ) -> bool {
4695 let Some(table) = self.schema.table(table_name) else {
4696 return false;
4697 };
4698 if table.scope_variables.is_empty() {
4699 return false;
4700 }
4701 let columns = table
4702 .scope_variables
4703 .iter()
4704 .map(|scope| quote_ident(&scope.column))
4705 .collect::<Vec<_>>()
4706 .join(", ");
4707 let full_table = if base {
4708 base_table(table_name)
4709 } else {
4710 visible_table(table_name)
4711 };
4712 let sql = format!(
4713 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
4714 row_id_predicate(table)
4715 );
4716 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
4717 return false;
4718 };
4719 let values = stmt.query_row(rusqlite::params![row_id], |row| {
4720 let mut values = Vec::with_capacity(table.scope_variables.len());
4721 for index in 0..table.scope_variables.len() {
4722 values.push(row.get::<_, Option<String>>(index)?);
4723 }
4724 Ok(values)
4725 });
4726 let Ok(values) = values else {
4727 return false;
4728 };
4729 let mut recorded = false;
4730 for (scope, value) in table.scope_variables.iter().zip(values) {
4731 if let Some(value) = value {
4732 batch.scope(table_name, format!("{}:{value}", scope.prefix));
4733 recorded = true;
4734 }
4735 }
4736 recorded
4737 }
4738
4739 fn record_commit_changes(
4740 &self,
4741 batch: &mut ChangeAccumulator,
4742 tables: &[String],
4743 changes: &[ssp2::model::Change],
4744 ) {
4745 #[cfg(feature = "bench-internals")]
4746 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
4747 for change in changes {
4748 let Some(table_name) = tables.get(change.table_index as usize) else {
4749 continue;
4750 };
4751 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
4752 if let Some(table) = self.schema.table(table_name) {
4753 for (variable, value) in &change.scopes {
4754 if let Some(scope) = table
4755 .scope_variables
4756 .iter()
4757 .find(|scope| &scope.variable == variable)
4758 {
4759 batch.scope(table_name, format!("{}:{value}", scope.prefix));
4760 precise = true;
4761 }
4762 }
4763 }
4764 if !precise {
4765 batch.table(table_name);
4766 }
4767 }
4768 }
4769
4770 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
4771 if effective.is_empty() {
4772 return false;
4773 }
4774 let Some(table) = self.schema.table(table_name) else {
4775 return false;
4776 };
4777 let mut clauses = Vec::new();
4778 let mut params = Vec::new();
4779 for (variable, values) in effective {
4780 let Some(column) = table.scope_column(variable) else {
4781 return false;
4782 };
4783 if values.is_empty() {
4784 return false;
4785 }
4786 let placeholders = values
4787 .iter()
4788 .map(|value| {
4789 params.push(SqlValue::Text(value.clone()));
4790 "?"
4791 })
4792 .collect::<Vec<_>>()
4793 .join(", ");
4794 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
4795 }
4796 let sql = format!(
4797 "SELECT 1 FROM {} WHERE {} LIMIT 1",
4798 base_table(table_name),
4799 clauses.join(" AND ")
4800 );
4801 self.conn
4802 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
4803 .is_ok()
4804 }
4805
4806 pub fn upgrading(&self) -> bool {
4808 self.upgrading
4809 }
4810
4811 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
4817 let new_schema = parse_schema_json(schema_json)?;
4818 let marker: Option<i32> = self
4819 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
4820 .and_then(|v| v.parse().ok());
4821 self.schema = new_schema;
4822 if marker != Some(self.schema.version) {
4823 self.run_schema_reset()?;
4824 }
4825 self.prune_unknown_subscriptions()?;
4826 self.enqueue_startup_sync_if_needed();
4830 Ok(())
4831 }
4832
4833 fn enqueue_startup_sync_if_needed(&mut self) {
4834 let startup_work = !self.stopped
4835 && (!self.outbox.is_empty()
4836 || self.subs.iter().any(|sub| sub.state == SubState::Active));
4837 if startup_work {
4838 self.sync_needed = true;
4839 self.sync_intent_queue.push_back(SyncIntent::Interactive);
4840 }
4841 }
4842
4843 fn clear_satisfied_persisted_schema_floor(&mut self) {
4850 let satisfied = self
4851 .schema_floor
4852 .as_ref()
4853 .and_then(|floor| floor.required_schema_version)
4854 .is_some_and(|required| self.schema.version >= required);
4855 if !satisfied {
4856 return;
4857 }
4858 self.schema_floor = None;
4859 self.stopped = false;
4860 self.delete_meta(SCHEMA_FLOOR_KEY);
4861 }
4862
4863 fn run_schema_reset(&mut self) -> Result<(), String> {
4869 self.begin_observation("syncular_schema_reset")?;
4870 let mut batch = ChangeAccumulator::default();
4871 let result = self.run_schema_reset_observed(&mut batch, true);
4872 if let Err(error) = result {
4873 self.rollback_observation("syncular_schema_reset");
4874 return Err(error);
4875 }
4876 if let Err(error) = self.finish_observation("syncular_schema_reset", batch) {
4877 self.rollback_observation("syncular_schema_reset");
4878 return Err(error);
4879 }
4880 Ok(())
4881 }
4882
4883 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
4884 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
4885 self.begin_observation("syncular_log_epoch_reset")?;
4886 let mut batch = ChangeAccumulator::default();
4887 let result = self.run_schema_reset_observed(&mut batch, false);
4888 if let Err(error) = result {
4889 self.rollback_observation("syncular_log_epoch_reset");
4890 return Err(error);
4891 }
4892 self.set_meta(LOG_EPOCH_KEY, log_epoch);
4893 self.sync_needed = true;
4894 batch.status = true;
4895 if let Err(error) = self.finish_observation("syncular_log_epoch_reset", batch) {
4896 self.rollback_observation("syncular_log_epoch_reset");
4897 return Err(error);
4898 }
4899 self.sync_intent_queue.push_back(SyncIntent::Interactive);
4900 Ok(resets)
4901 }
4902
4903 fn run_schema_reset_observed(
4904 &mut self,
4905 batch: &mut ChangeAccumulator,
4906 drop_incompatible: bool,
4907 ) -> Result<(), String> {
4908 self.upgrading = true;
4909 batch.status = true;
4910 for table in &self.schema.tables {
4911 batch.table(&table.name);
4912 }
4913 for (base_key, unit, table) in self.load_registered_window_units() {
4914 batch.window(&base_key, &table, &unit);
4915 }
4916 self.insert_sql.borrow_mut().clear();
4918 self.overlay_dirty.set(true);
4919 let virtual_tables: Vec<String> = {
4927 let mut stmt = self
4928 .conn
4929 .prepare(
4930 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
4931 )
4932 .map_err(|e| e.to_string())?;
4933 let rows = stmt
4934 .query_map([], |row| row.get::<_, String>(0))
4935 .map_err(|e| e.to_string())?;
4936 rows.filter_map(Result::ok)
4937 .filter(|name| is_synced_table_name(name))
4938 .collect()
4939 };
4940 for name in virtual_tables {
4941 self.conn
4942 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
4943 .map_err(|e| e.to_string())?;
4944 }
4945 let existing: Vec<String> = {
4946 let mut stmt = self
4947 .conn
4948 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
4949 .map_err(|e| e.to_string())?;
4950 let rows = stmt
4951 .query_map([], |row| row.get::<_, String>(0))
4952 .map_err(|e| e.to_string())?;
4953 rows.filter_map(Result::ok)
4954 .filter(|name| is_synced_table_name(name))
4955 .collect()
4956 };
4957 for name in &existing {
4958 if let Some(table) = name.strip_prefix("_syncular_base_") {
4959 batch.table(table);
4960 } else if !name.starts_with("_syncular_") {
4961 batch.table(name);
4962 }
4963 }
4964 for name in existing {
4965 self.conn
4966 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
4967 .map_err(|e| e.to_string())?;
4968 }
4969 self.create_synced_tables()?;
4971 for sub in &mut self.subs {
4973 sub.cursor = -1;
4974 sub.bootstrap_state = None;
4975 sub.effective = None;
4976 sub.state = SubState::Active;
4977 sub.reason_code = None;
4978 sub.synced_once = false;
4979 }
4980 let subs = self.subs.clone();
4981 for sub in &subs {
4982 self.persist_sub(sub)?;
4983 }
4984 self.stopped = false;
4986 self.schema_floor = None;
4987 self.delete_meta(SCHEMA_FLOOR_KEY);
4988 self.set_meta(LOCAL_SCHEMA_VERSION_KEY, &self.schema.version.to_string());
4990 if drop_incompatible && self.drop_incompatible_outbox()? {
4994 batch.rejections = true;
4995 batch.status = true;
4996 batch.outcomes = true;
4997 }
4998 self.rebuild_overlay();
5000 Ok(())
5001 }
5002
5003 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
5007 let schema = &self.schema;
5008 let incompatible = self
5009 .outbox
5010 .iter()
5011 .filter(|commit| {
5012 commit.ops.iter().any(|op| {
5013 if !op.upsert {
5014 return false;
5015 }
5016 match schema.table(&op.table) {
5017 None => true,
5018 Some(table) => op.values.as_ref().is_some_and(|values| {
5019 values
5020 .keys()
5021 .any(|key| !table.columns.iter().any(|c| &c.name == key))
5022 }),
5023 }
5024 })
5025 })
5026 .cloned()
5027 .collect::<Vec<_>>();
5028 if incompatible.is_empty() {
5029 return Ok(false);
5030 }
5031 let mut rejections = Vec::new();
5032 for commit in &incompatible {
5033 let results = commit
5034 .ops
5035 .iter()
5036 .enumerate()
5037 .map(|(op_index, operation)| {
5038 let rejection = RejectionRecord {
5039 client_commit_id: commit.client_commit_id.clone(),
5040 op_index: op_index as i32,
5041 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
5042 message: "the persisted commit cannot encode under the current schema"
5043 .to_owned(),
5044 retryable: false,
5045 details: None,
5046 operation: Some(CommitOperation::from(operation)),
5047 };
5048 rejections.push(rejection.clone());
5049 CommitOperationOutcome::Error { rejection }
5050 })
5051 .collect::<Vec<_>>();
5052 self.persist_commit_outcome(
5053 &commit.client_commit_id,
5054 CommitOutcomeStatus::Rejected,
5055 &results,
5056 Some(&commit.ops),
5057 )?;
5058 self.delete_outbox_persisted(&commit.client_commit_id)?;
5059 }
5060 self.prune_commit_outcomes()?;
5061 let incompatible_ids = incompatible
5062 .iter()
5063 .map(|commit| commit.client_commit_id.as_str())
5064 .collect::<BTreeSet<_>>();
5065 self.outbox
5066 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
5067 self.rejections.extend(rejections);
5068 Ok(true)
5069 }
5070
5071 fn create_synced_tables(&self) -> Result<(), String> {
5074 for table in &self.schema.tables {
5075 for (full, index_prefix) in [
5079 (base_table(&table.name), "_syncular_base_"),
5080 (visible_table(&table.name), ""),
5081 ] {
5082 let mut cols: Vec<String> =
5083 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
5084 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
5085 let sql = format!(
5086 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
5087 cols.join(", "),
5088 quote_ident(&table.primary_key)
5089 );
5090 self.conn.execute(&sql, []).map_err(|e| e.to_string())?;
5091 for index in &table.indexes {
5095 let unique = if index.unique { "UNIQUE " } else { "" };
5096 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
5097 let cols_sql = index
5098 .columns
5099 .iter()
5100 .map(|c| quote_ident(c))
5101 .collect::<Vec<_>>()
5102 .join(", ");
5103 let index_sql = format!(
5104 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
5105 );
5106 self.conn
5107 .execute(&index_sql, [])
5108 .map_err(|e| e.to_string())?;
5109 }
5110 }
5111 }
5112 for table in &self.schema.tables {
5115 for index in &table.fts_indexes {
5116 self.create_fts_projection(table, index)?;
5117 }
5118 }
5119 Ok(())
5120 }
5121
5122 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
5123 let count: i64 = self
5124 .conn
5125 .query_row(
5126 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
5127 rusqlite::params![index.name],
5128 |row| row.get(0),
5129 )
5130 .map_err(|e| e.to_string())?;
5131 Ok(count > 0)
5132 }
5133
5134 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
5135 for suffix in ["ai", "ad", "au"] {
5136 self.conn
5137 .execute(
5138 &format!(
5139 "DROP TRIGGER IF EXISTS {}",
5140 quote_ident(&format!("{}_{suffix}", index.name))
5141 ),
5142 [],
5143 )
5144 .map_err(|e| e.to_string())?;
5145 }
5146 Ok(())
5147 }
5148
5149 fn create_fts_triggers(
5150 &self,
5151 table: &TableSchema,
5152 index: &FtsIndexSchema,
5153 ) -> Result<(), String> {
5154 let fts = quote_ident(&index.name);
5155 let source = visible_table(&table.name);
5156 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5157 let pk = quote_ident(&table.primary_key);
5158 let projection_columns = std::iter::once(source_id.clone())
5159 .chain(index.columns.iter().map(|column| quote_ident(column)))
5160 .collect::<Vec<_>>()
5161 .join(", ");
5162 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
5163 .chain(
5164 index
5165 .columns
5166 .iter()
5167 .map(|column| format!("new.{}", quote_ident(column))),
5168 )
5169 .collect::<Vec<_>>()
5170 .join(", ");
5171 let delete_new = format!("DELETE FROM {fts} WHERE {source_id} = CAST(new.{pk} AS TEXT)");
5172 let delete_old = format!("DELETE FROM {fts} WHERE {source_id} = CAST(old.{pk} AS TEXT)");
5173 let insert_new = format!("INSERT INTO {fts} ({projection_columns}) VALUES ({new_values})");
5174 let bi = quote_ident(&format!("{}_bi", index.name));
5175 let ai = quote_ident(&format!("{}_ai", index.name));
5176 let ad = quote_ident(&format!("{}_ad", index.name));
5177 let au = quote_ident(&format!("{}_au", index.name));
5178 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
5179 let sql = format!(
5180 "DROP TRIGGER IF EXISTS {bi};
5181 DROP TRIGGER IF EXISTS {ai};
5182 DROP TRIGGER IF EXISTS {ad};
5183 DROP TRIGGER IF EXISTS {au};
5184 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
5185 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
5186 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
5187 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
5188 );
5189 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5190 }
5191
5192 fn rebuild_fts_projection(
5193 &self,
5194 table: &TableSchema,
5195 index: &FtsIndexSchema,
5196 ) -> Result<(), String> {
5197 let fts = quote_ident(&index.name);
5198 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
5199 let pk = quote_ident(&table.primary_key);
5200 let indexed_columns = index
5201 .columns
5202 .iter()
5203 .map(|column| quote_ident(column))
5204 .collect::<Vec<_>>();
5205 let projection_columns = std::iter::once(source_id)
5206 .chain(indexed_columns.iter().cloned())
5207 .collect::<Vec<_>>()
5208 .join(", ");
5209 let sql = format!(
5210 "DELETE FROM {fts}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source};",
5211 columns = indexed_columns.join(", "),
5212 source = visible_table(&table.name),
5213 );
5214 self.conn.execute_batch(&sql).map_err(|e| e.to_string())
5215 }
5216
5217 fn create_fts_projection(
5218 &self,
5219 table: &TableSchema,
5220 index: &FtsIndexSchema,
5221 ) -> Result<(), String> {
5222 let existed = self.fts_projection_exists(index)?;
5223 let tokenizer = index.tokenize.replace('\'', "''");
5224 let columns = index
5225 .columns
5226 .iter()
5227 .map(|column| quote_ident(column))
5228 .collect::<Vec<_>>()
5229 .join(", ");
5230 let sql = format!(
5231 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
5232 fts = quote_ident(&index.name),
5233 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
5234 );
5235 self.conn.execute(&sql, []).map_err(|error| {
5236 format!(
5237 "cannot create local FTS5 projection {:?}: {error}",
5238 index.name
5239 )
5240 })?;
5241 self.create_fts_triggers(table, index)?;
5242 if !existed {
5243 self.rebuild_fts_projection(table, index)?;
5244 }
5245 Ok(())
5246 }
5247
5248 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
5251 let state = serde_json::json!({
5252 "requested": scope_map_to_json(&sub.requested),
5253 "params": sub.params,
5254 "cursor": sub.cursor,
5255 "bootstrapState": sub.bootstrap_state,
5256 "status": sub.state.name(),
5257 "reasonCode": sub.reason_code,
5258 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
5259 "syncedOnce": sub.synced_once,
5260 });
5261 self.conn.execute(
5262 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
5263 rusqlite::params![sub.id, sub.table, state.to_string()],
5264 ).map(|_| ()).map_err(|error| error.to_string())
5265 }
5266
5267 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
5268 let ops: Vec<Value> = commit
5269 .ops
5270 .iter()
5271 .map(|op| {
5272 serde_json::json!({
5273 "op": if op.upsert { "upsert" } else { "delete" },
5274 "table": op.table,
5275 "rowId": op.row_id,
5276 "baseVersion": op.base_version,
5277 "values": op.values.clone().map(Value::Object),
5278 "changedFields": op.changed_fields,
5279 })
5280 })
5281 .collect();
5282 self.conn
5283 .execute(
5284 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
5285 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
5286 )
5287 .map(|_| ())
5288 .map_err(|error| error.to_string())
5289 }
5290
5291 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
5292 self.conn
5293 .execute(
5294 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
5295 rusqlite::params![client_commit_id],
5296 )
5297 .map(|_| ())
5298 .map_err(|error| error.to_string())
5299 }
5300
5301 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
5302 match status {
5303 CommitOutcomeStatus::Applied => "applied",
5304 CommitOutcomeStatus::Cached => "cached",
5305 CommitOutcomeStatus::Conflict => "conflict",
5306 CommitOutcomeStatus::Rejected => "rejected",
5307 }
5308 }
5309
5310 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
5311 match resolution {
5312 CommitOutcomeResolution::Active => "active",
5313 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
5314 CommitOutcomeResolution::Superseded => "superseded",
5315 CommitOutcomeResolution::Dismissed => "dismissed",
5316 }
5317 }
5318
5319 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
5320 match value {
5321 "applied" => Ok(CommitOutcomeStatus::Applied),
5322 "cached" => Ok(CommitOutcomeStatus::Cached),
5323 "conflict" => Ok(CommitOutcomeStatus::Conflict),
5324 "rejected" => Ok(CommitOutcomeStatus::Rejected),
5325 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
5326 }
5327 }
5328
5329 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
5330 match value {
5331 "active" => Ok(CommitOutcomeResolution::Active),
5332 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
5333 "superseded" => Ok(CommitOutcomeResolution::Superseded),
5334 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
5335 _ => Err(format!(
5336 "invalid persisted commit outcome resolution {value:?}"
5337 )),
5338 }
5339 }
5340
5341 fn persist_commit_outcome(
5342 &self,
5343 client_commit_id: &str,
5344 status: CommitOutcomeStatus,
5345 results: &[CommitOperationOutcome],
5346 operations: Option<&[OutboxOp]>,
5347 ) -> Result<(), String> {
5348 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
5349 let operations_json = operations
5350 .map(|items| {
5351 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
5352 })
5353 .transpose()
5354 .map_err(|error| error.to_string())?;
5355 self.conn
5356 .execute(
5357 "INSERT INTO _syncular_commit_outcomes (
5358 client_commit_id, status, recorded_at_ms, results_json,
5359 operations_json, resolution
5360 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
5361 rusqlite::params![
5362 client_commit_id,
5363 Self::outcome_status_name(status),
5364 self.clock_now_ms(),
5365 results_json,
5366 operations_json
5367 ],
5368 )
5369 .map(|_| ())
5370 .map_err(|error| error.to_string())
5371 }
5372
5373 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
5374 let StoredCommitOutcomeRow {
5375 sequence,
5376 client_commit_id,
5377 status,
5378 recorded_at_ms,
5379 results_json,
5380 operations_json,
5381 resolution,
5382 resolved_at_ms,
5383 replacement_client_commit_id,
5384 } = row;
5385 Ok(CommitOutcome {
5386 sequence,
5387 client_commit_id,
5388 status: Self::parse_outcome_status(&status)?,
5389 recorded_at_ms,
5390 results: serde_json::from_str(&results_json)
5391 .map_err(|error| format!("invalid persisted commit outcome results: {error}"))?,
5392 operations: operations_json
5393 .map(|value| {
5394 serde_json::from_str(&value).map_err(|error| {
5395 format!("invalid persisted commit outcome operations: {error}")
5396 })
5397 })
5398 .transpose()?,
5399 resolution: Self::parse_outcome_resolution(&resolution)?,
5400 resolved_at_ms,
5401 replacement_client_commit_id,
5402 })
5403 }
5404
5405 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
5406 let row = self
5407 .conn
5408 .query_row(
5409 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5410 resolution, resolved_at_ms, replacement_client_commit_id
5411 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
5412 rusqlite::params![client_commit_id],
5413 |row| {
5414 Ok(StoredCommitOutcomeRow {
5415 sequence: row.get(0)?,
5416 client_commit_id: row.get(1)?,
5417 status: row.get(2)?,
5418 recorded_at_ms: row.get(3)?,
5419 results_json: row.get(4)?,
5420 operations_json: row.get(5)?,
5421 resolution: row.get(6)?,
5422 resolved_at_ms: row.get(7)?,
5423 replacement_client_commit_id: row.get(8)?,
5424 })
5425 },
5426 )
5427 .optional()
5428 .map_err(|error| error.to_string())?;
5429 row.map(Self::outcome_from_row).transpose()
5430 }
5431
5432 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
5433 if query.limit == Some(0) {
5434 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
5435 }
5436 let mut sql = String::from(
5437 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
5438 resolution, resolved_at_ms, replacement_client_commit_id
5439 FROM _syncular_commit_outcomes",
5440 );
5441 if query.active_only {
5442 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
5443 }
5444 sql.push_str(" ORDER BY seq DESC");
5445 if let Some(limit) = query.limit {
5446 sql.push_str(&format!(" LIMIT {limit}"));
5447 }
5448 let mut stmt = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
5449 let rows = stmt
5450 .query_map([], |row| {
5451 Ok(StoredCommitOutcomeRow {
5452 sequence: row.get(0)?,
5453 client_commit_id: row.get(1)?,
5454 status: row.get(2)?,
5455 recorded_at_ms: row.get(3)?,
5456 results_json: row.get(4)?,
5457 operations_json: row.get(5)?,
5458 resolution: row.get(6)?,
5459 resolved_at_ms: row.get(7)?,
5460 replacement_client_commit_id: row.get(8)?,
5461 })
5462 })
5463 .map_err(|error| error.to_string())?;
5464 let mut outcomes = Vec::new();
5465 for row in rows {
5466 outcomes.push(Self::outcome_from_row(
5467 row.map_err(|error| error.to_string())?,
5468 )?);
5469 }
5470 Ok(outcomes)
5471 }
5472
5473 fn prune_commit_outcomes(&self) -> Result<(), String> {
5474 #[cfg(test)]
5475 self.outcome_prune_count
5476 .set(self.outcome_prune_count.get() + 1);
5477 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
5478 let count = self
5479 .conn
5480 .query_row(
5481 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
5482 [],
5483 |row| row.get::<_, i64>(0),
5484 )
5485 .map_err(|error| error.to_string())? as usize;
5486 let excess = count.saturating_sub(max_entries);
5487 if excess == 0 {
5488 return Ok(());
5489 }
5490 let mut stmt = self
5491 .conn
5492 .prepare(
5493 "SELECT seq FROM _syncular_commit_outcomes
5494 WHERE status IN ('applied', 'cached') OR resolution != 'active'
5495 ORDER BY seq ASC LIMIT ?1",
5496 )
5497 .map_err(|error| error.to_string())?;
5498 let rows = stmt
5499 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
5500 .map_err(|error| error.to_string())?;
5501 let sequences = rows
5502 .collect::<Result<Vec<_>, _>>()
5503 .map_err(|error| error.to_string())?;
5504 drop(stmt);
5505 for sequence in sequences {
5506 self.conn
5507 .execute(
5508 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
5509 rusqlite::params![sequence],
5510 )
5511 .map_err(|error| error.to_string())?;
5512 }
5513 Ok(())
5514 }
5515
5516 pub fn subscribe(
5519 &mut self,
5520 id: String,
5521 table: String,
5522 scopes: Vec<(String, Vec<String>)>,
5523 params: Option<String>,
5524 ) -> Result<(), String> {
5525 if self.schema.table(&table).is_none() {
5526 return Err(format!("unknown table {table:?}"));
5527 }
5528 if let Some(existing) = self.subs.iter().find(|subscription| subscription.id == id) {
5529 let same_intent = existing.table == table
5530 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
5531 && existing.params == params;
5532 if same_intent {
5533 return Ok(());
5534 }
5535 return Err(
5536 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
5537 .to_owned(),
5538 );
5539 }
5540 let sub = Subscription {
5541 id: id.clone(),
5542 table,
5543 requested: scopes,
5544 params,
5545 cursor: -1,
5546 bootstrap_state: None,
5547 state: SubState::Active,
5548 reason_code: None,
5549 effective: None,
5550 synced_once: false,
5551 };
5552 self.persist_sub(&sub)?;
5553 self.subs.push(sub);
5554 Ok(())
5555 }
5556
5557 pub fn unsubscribe(&mut self, id: &str) {
5558 self.subs.retain(|s| s.id != id);
5559 let _ = self.conn.execute(
5560 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
5561 rusqlite::params![id],
5562 );
5563 }
5564
5565 pub fn set_window(
5573 &mut self,
5574 base: &WindowBase,
5575 units: &[String],
5576 ) -> Result<CommandEffects, String> {
5577 let table = self
5578 .schema
5579 .table(&base.table)
5580 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
5581 if table.scope_column(&base.variable).is_none() {
5582 return Err(format!(
5583 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
5584 base.table, base.variable
5585 ));
5586 }
5587 let base_key = window_base_key(base);
5588 let wanted: std::collections::HashSet<&String> = units.iter().collect();
5589 let live = self.load_window_units(&base_key);
5590 self.begin_observation("syncular_window")?;
5591 let mut batch = ChangeAccumulator::default();
5592 let mut changed = false;
5593
5594 for unit in units {
5596 if live.iter().any(|(u, _)| u == unit) {
5597 continue;
5598 }
5599 let sub_id = derive_sub_id(base, unit);
5600 self.delete_pending_evict(&sub_id);
5601 self.insert_window_unit(&base_key, unit, &sub_id);
5602 self.subscribe(
5603 sub_id,
5604 base.table.clone(),
5605 unit_scopes(base, unit),
5606 base.params.clone(),
5607 )?;
5608 batch.window(&base_key, &base.table, unit);
5609 changed = true;
5610 }
5611
5612 for (unit, sub_id) in live {
5614 if wanted.contains(&unit) {
5615 continue;
5616 }
5617 let effective = self
5618 .subs
5619 .iter()
5620 .find(|sub| sub.id == sub_id)
5621 .and_then(|sub| sub.effective.clone())
5622 .unwrap_or_else(|| unit_scopes(base, &unit));
5623 self.record_scope_map(&mut batch, &base.table, &effective);
5624 batch.window(&base_key, &base.table, &unit);
5625 self.evict_unit(&base_key, base, &unit, &sub_id);
5626 changed = true;
5627 }
5628 if let Err(error) = self.finish_observation("syncular_window", batch) {
5629 self.rollback_observation("syncular_window");
5630 return Err(error);
5631 }
5632 Ok(if changed {
5633 CommandEffects::interactive()
5634 } else {
5635 CommandEffects::none()
5636 })
5637 }
5638
5639 pub fn window_state(&self, base: &WindowBase) -> WindowState {
5644 let mut units = Vec::new();
5645 let mut pending = Vec::new();
5646 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
5647 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
5648 Some(sub) => {
5649 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
5650 }
5651 None => true,
5652 };
5653 if is_pending {
5654 pending.push(unit.clone());
5655 }
5656 units.push(unit);
5657 }
5658 WindowState { units, pending }
5659 }
5660
5661 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
5662 let mut stmt = match self
5663 .conn
5664 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
5665 {
5666 Ok(stmt) => stmt,
5667 Err(_) => return Vec::new(),
5668 };
5669 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
5670 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
5671 });
5672 match rows {
5673 Ok(rows) => rows.filter_map(Result::ok).collect(),
5674 Err(_) => Vec::new(),
5675 }
5676 }
5677
5678 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
5679 let mut stmt = match self.conn.prepare(
5680 "SELECT windows.base, windows.unit, subscriptions.tbl
5681 FROM _syncular_windows AS windows
5682 JOIN _syncular_subscriptions AS subscriptions
5683 ON subscriptions.id = windows.sub_id
5684 ORDER BY windows.base, windows.unit",
5685 ) {
5686 Ok(stmt) => stmt,
5687 Err(_) => return Vec::new(),
5688 };
5689 let rows = stmt.query_map([], |row| {
5690 Ok((
5691 row.get::<_, String>(0)?,
5692 row.get::<_, String>(1)?,
5693 row.get::<_, String>(2)?,
5694 ))
5695 });
5696 match rows {
5697 Ok(rows) => rows.filter_map(Result::ok).collect(),
5698 Err(_) => Vec::new(),
5699 }
5700 }
5701
5702 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
5703 self.conn
5704 .query_row(
5705 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
5706 rusqlite::params![sub_id],
5707 |row| Ok((row.get(0)?, row.get(1)?)),
5708 )
5709 .ok()
5710 }
5711
5712 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) {
5713 let _ = self.conn.execute(
5714 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
5715 rusqlite::params![base_key, unit, sub_id],
5716 );
5717 }
5718
5719 fn delete_window_unit(&self, base_key: &str, unit: &str) {
5720 let _ = self.conn.execute(
5721 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
5722 rusqlite::params![base_key, unit],
5723 );
5724 }
5725
5726 fn evict_unit(&mut self, base_key: &str, base: &WindowBase, unit: &str, sub_id: &str) {
5733 let effective = self
5734 .subs
5735 .iter()
5736 .find(|s| s.id == sub_id)
5737 .and_then(|s| s.effective.clone())
5738 .unwrap_or_else(|| unit_scopes(base, unit));
5739 let pinned = self.pinned_row_ids(&base.table);
5740 let deferred = self
5741 .evict_scope_rows(&base.table, &effective, &pinned)
5742 .unwrap_or(false);
5743 self.delete_window_unit(base_key, unit);
5744 self.unsubscribe(sub_id);
5745 if deferred {
5746 self.save_pending_evict(sub_id, &base.table, &effective);
5747 } else {
5748 self.delete_pending_evict(sub_id);
5749 }
5750 self.rebuild_overlay();
5751 }
5752
5753 fn evict_scope_rows(
5757 &mut self,
5758 table_name: &str,
5759 effective: &[(String, Vec<String>)],
5760 pinned: &std::collections::HashSet<String>,
5761 ) -> Result<bool, ()> {
5762 if effective.is_empty() {
5763 return Ok(false);
5764 }
5765 let table = self.schema.table(table_name).ok_or(())?.clone();
5766 let mut clauses = Vec::new();
5767 let mut params: Vec<SqlValue> = Vec::new();
5768 for (variable, values) in effective {
5769 let column = table.scope_column(variable).ok_or(())?;
5770 let placeholders: Vec<String> = values
5771 .iter()
5772 .map(|v| {
5773 params.push(SqlValue::Text(v.clone()));
5774 "?".to_owned()
5775 })
5776 .collect();
5777 clauses.push(format!(
5778 "{} IN ({})",
5779 quote_ident(column),
5780 placeholders.join(", ")
5781 ));
5782 }
5783 let mut sql = format!(
5784 "DELETE FROM {} WHERE {}",
5785 base_table(table_name),
5786 clauses.join(" AND ")
5787 );
5788 if !pinned.is_empty() {
5789 let pk = quote_ident(&table.primary_key);
5790 let holes: Vec<String> = pinned
5791 .iter()
5792 .map(|id| {
5793 params.push(SqlValue::Text(id.clone()));
5794 "?".to_owned()
5795 })
5796 .collect();
5797 sql.push_str(&format!(" AND {} NOT IN ({})", pk, holes.join(", ")));
5798 }
5799 self.overlay_dirty.set(true);
5800 self.conn
5801 .execute(&sql, rusqlite::params_from_iter(params))
5802 .map_err(|_| ())?;
5803 if pinned.is_empty() {
5804 return Ok(false);
5805 }
5806 let mut where_params: Vec<SqlValue> = Vec::new();
5809 let mut where_clauses = Vec::new();
5810 for (variable, values) in effective {
5811 let column = table.scope_column(variable).ok_or(())?;
5812 let placeholders: Vec<String> = values
5813 .iter()
5814 .map(|v| {
5815 where_params.push(SqlValue::Text(v.clone()));
5816 "?".to_owned()
5817 })
5818 .collect();
5819 where_clauses.push(format!(
5820 "{} IN ({})",
5821 quote_ident(column),
5822 placeholders.join(", ")
5823 ));
5824 }
5825 let pk = quote_ident(&table.primary_key);
5826 let select = format!(
5827 "SELECT {} FROM {} WHERE {}",
5828 pk,
5829 base_table(table_name),
5830 where_clauses.join(" AND ")
5831 );
5832 let mut stmt = self.conn.prepare(&select).map_err(|_| ())?;
5833 let survivors: Vec<String> = stmt
5834 .query_map(rusqlite::params_from_iter(where_params), |row| {
5835 row.get::<_, String>(0)
5836 })
5837 .map_err(|_| ())?
5838 .filter_map(Result::ok)
5839 .collect();
5840 Ok(survivors.iter().any(|id| pinned.contains(id)))
5841 }
5842
5843 fn drain_pending_evictions(&mut self) {
5847 let pending = self.load_pending_evictions();
5848 if pending.is_empty() {
5849 return;
5850 }
5851 for (sub_id, table_name, effective) in pending {
5852 if self.schema.table(&table_name).is_none() {
5853 self.delete_pending_evict(&sub_id);
5854 continue;
5855 }
5856 let pinned = self.pinned_row_ids(&table_name);
5857 let deferred = self
5858 .evict_scope_rows(&table_name, &effective, &pinned)
5859 .unwrap_or(false);
5860 if !deferred {
5861 self.delete_pending_evict(&sub_id);
5862 }
5863 }
5864 self.rebuild_overlay_if_dirty();
5865 }
5866
5867 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
5870 let mut pinned = std::collections::HashSet::new();
5871 for commit in &self.outbox {
5872 for op in &commit.ops {
5873 if op.table == table {
5874 pinned.insert(op.row_id.clone());
5875 }
5876 }
5877 }
5878 pinned
5879 }
5880
5881 fn save_pending_evict(&self, sub_id: &str, table: &str, effective: &[(String, Vec<String>)]) {
5882 let _ = self.conn.execute(
5883 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes)
5884 VALUES (?1, ?2, ?3)",
5885 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
5886 );
5887 }
5888
5889 fn delete_pending_evict(&self, sub_id: &str) {
5890 let _ = self.conn.execute(
5891 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
5892 rusqlite::params![sub_id],
5893 );
5894 }
5895
5896 fn load_pending_evictions(&self) -> Vec<PendingEvict> {
5897 let mut stmt = match self
5898 .conn
5899 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
5900 {
5901 Ok(stmt) => stmt,
5902 Err(_) => return Vec::new(),
5903 };
5904 let rows = stmt.query_map([], |row| {
5905 Ok((
5906 row.get::<_, String>(0)?,
5907 row.get::<_, String>(1)?,
5908 row.get::<_, String>(2)?,
5909 ))
5910 });
5911 let mut out = Vec::new();
5912 if let Ok(rows) = rows {
5913 for entry in rows.filter_map(Result::ok) {
5914 let (sub_id, table, json) = entry;
5915 if let Ok(value) = serde_json::from_str::<Value>(&json) {
5916 if let Ok(effective) = json_to_scope_map(&value) {
5917 out.push((sub_id, table, effective));
5918 }
5919 }
5920 }
5921 }
5922 out
5923 }
5924
5925 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, String> {
5927 if mutations.is_empty() {
5928 return Err("a commit must contain at least one operation (§6.1)".to_owned());
5929 }
5930 let mut ops = Vec::with_capacity(mutations.len());
5931 for mutation in mutations {
5932 match mutation {
5933 Mutation::Upsert {
5934 table,
5935 values,
5936 base_version,
5937 } => {
5938 let schema_table = self
5939 .schema
5940 .table(&table)
5941 .ok_or_else(|| format!("unknown table {table:?}"))?;
5942 let values = normalize_values_casing(schema_table, values)?;
5946 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
5947 encode_row_json(schema_table, &row_id, &values, &self.encryption)?;
5950 ops.push(OutboxOp {
5951 upsert: true,
5952 table,
5953 row_id,
5954 base_version,
5955 values: Some(values),
5956 changed_fields: None,
5957 });
5958 }
5959 Mutation::Delete {
5960 table,
5961 row_id,
5962 base_version,
5963 } => {
5964 if self.schema.table(&table).is_none() {
5965 return Err(format!("unknown table {table:?}"));
5966 }
5967 ops.push(OutboxOp {
5968 upsert: false,
5969 table,
5970 row_id,
5971 base_version,
5972 values: None,
5973 changed_fields: None,
5974 });
5975 }
5976 }
5977 }
5978 self.record_outbox_commit(ops)
5979 }
5980
5981 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
5982 let commit = OutboxCommit {
5983 client_commit_id: uuid::Uuid::new_v4().to_string(),
5984 ops,
5985 };
5986 self.begin_observation("syncular_mutation")?;
5987 let mut batch = ChangeAccumulator::default();
5988 for op in &commit.ops {
5989 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
5990 if let Some(values) = &op.values {
5991 if let Some(table) = self.schema.table(&op.table) {
5992 for scope in &table.scope_variables {
5993 if let Some(Value::String(value)) = values.get(&scope.column) {
5994 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
5995 precise = true;
5996 }
5997 }
5998 }
5999 }
6000 if !precise {
6001 batch.table(&op.table);
6002 }
6003 }
6004 if let Err(error) = self.persist_outbox_insert(&commit) {
6005 self.rollback_observation("syncular_mutation");
6006 return Err(error);
6007 }
6008 let was_dirty = self.overlay_dirty.get();
6009 if !was_dirty {
6012 self.apply_outbox_ops(&commit.ops);
6013 }
6014 let id = commit.client_commit_id.clone();
6015 self.outbox.push(commit);
6016 if was_dirty {
6017 self.rebuild_overlay();
6018 }
6019 batch.status = true;
6020 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
6021 self.rollback_observation("syncular_mutation");
6022 self.outbox.pop();
6023 self.overlay_dirty.set(was_dirty);
6024 return Err(error);
6025 }
6026 Ok(id)
6027 }
6028
6029 pub fn patch(
6034 &mut self,
6035 table: &str,
6036 row_id: &str,
6037 partial: Map<String, Value>,
6038 base_version: Option<i64>,
6039 ) -> Result<String, String> {
6040 let schema_table = self
6041 .schema
6042 .table(table)
6043 .ok_or_else(|| format!("unknown table {table:?}"))?;
6044 let partial = normalize_values_casing(schema_table, partial)?;
6045 let mut values = self
6046 .read_rows(table)?
6047 .into_iter()
6048 .find(|row| row.row_id == row_id)
6049 .map(|row| row.values)
6050 .ok_or_else(|| {
6051 format!(
6052 "sync.invalid_request: table {table:?} has no local row with primary key {row_id:?} to patch"
6053 )
6054 })?;
6055 let mut changed_fields = partial.keys().cloned().collect::<Vec<_>>();
6056 changed_fields.sort();
6057 values.extend(partial);
6058 let row_id_from_values = render_row_id_json(values.get(&schema_table.primary_key))?;
6059 if row_id_from_values != row_id {
6060 return Err("sync.invalid_request: patch cannot change the primary key".to_owned());
6061 }
6062 encode_row_json(schema_table, row_id, &values, &self.encryption)?;
6063 self.record_outbox_commit(vec![OutboxOp {
6064 upsert: true,
6065 table: table.to_owned(),
6066 row_id: row_id.to_owned(),
6067 base_version,
6068 values: Some(values),
6069 changed_fields: Some(changed_fields),
6070 }])
6071 }
6072
6073 pub fn pending_commit_ids(&self) -> Vec<String> {
6074 self.outbox
6075 .iter()
6076 .map(|c| c.client_commit_id.clone())
6077 .collect()
6078 }
6079
6080 pub fn conflicts(&self) -> &[ConflictRecord] {
6081 &self.conflicts
6082 }
6083
6084 pub fn rejections(&self) -> &[RejectionRecord] {
6085 &self.rejections
6086 }
6087
6088 pub fn resolve_commit_outcome(
6089 &mut self,
6090 input: ResolveCommitOutcomeInput,
6091 ) -> Result<CommitOutcome, String> {
6092 let current = self
6093 .commit_outcome(&input.client_commit_id)?
6094 .ok_or_else(|| {
6095 format!(
6096 "sync.outcome_not_found: no durable outcome exists for {:?}",
6097 input.client_commit_id
6098 )
6099 })?;
6100 if current.resolution != CommitOutcomeResolution::Active {
6101 return Ok(current);
6102 }
6103 if input.resolution == CommitOutcomeResolution::Active {
6104 return Err("sync.invalid_request: resolution must leave active state".to_owned());
6105 }
6106 match input.resolution {
6107 CommitOutcomeResolution::Superseded => {
6108 let replacement = input
6109 .replacement_client_commit_id
6110 .as_deref()
6111 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
6112 .ok_or_else(|| {
6113 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
6114 .to_owned()
6115 })?;
6116 let _ = replacement;
6117 }
6118 _ if input.replacement_client_commit_id.is_some() => {
6119 return Err(
6120 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
6121 .to_owned(),
6122 );
6123 }
6124 _ => {}
6125 }
6126 let allowed = match current.status {
6127 CommitOutcomeStatus::Conflict => matches!(
6128 input.resolution,
6129 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
6130 ),
6131 CommitOutcomeStatus::Rejected => {
6132 input.resolution == CommitOutcomeResolution::Superseded
6133 }
6134 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
6135 input.resolution == CommitOutcomeResolution::Dismissed
6136 }
6137 };
6138 if !allowed {
6139 return Err(format!(
6140 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
6141 input.resolution, current.status
6142 ));
6143 }
6144
6145 self.begin_observation("syncular_outcome_resolution")?;
6146 let result = (|| {
6147 self.conn
6148 .execute(
6149 "UPDATE _syncular_commit_outcomes
6150 SET resolution = ?1, resolved_at_ms = ?2,
6151 replacement_client_commit_id = ?3
6152 WHERE client_commit_id = ?4 AND resolution = 'active'",
6153 rusqlite::params![
6154 Self::outcome_resolution_name(input.resolution),
6155 self.clock_now_ms(),
6156 input.replacement_client_commit_id,
6157 input.client_commit_id
6158 ],
6159 )
6160 .map_err(|error| error.to_string())?;
6161 let resolved = self
6162 .commit_outcome(¤t.client_commit_id)?
6163 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
6164 self.prune_commit_outcomes()?;
6165 Ok(resolved)
6166 })();
6167 let resolved = match result {
6168 Ok(outcome) => outcome,
6169 Err(error) => {
6170 self.rollback_observation("syncular_outcome_resolution");
6171 return Err(error);
6172 }
6173 };
6174 self.conflicts
6175 .retain(|record| record.client_commit_id != current.client_commit_id);
6176 self.rejections
6177 .retain(|record| record.client_commit_id != current.client_commit_id);
6178 let batch = ChangeAccumulator {
6179 conflicts: current.status == CommitOutcomeStatus::Conflict,
6180 rejections: current.status == CommitOutcomeStatus::Rejected,
6181 outcomes: true,
6182 ..ChangeAccumulator::default()
6183 };
6184 if let Err(error) = self.finish_observation("syncular_outcome_resolution", batch) {
6185 self.rollback_observation("syncular_outcome_resolution");
6186 return Err(error);
6187 }
6188 Ok(resolved)
6189 }
6190
6191 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
6192 self.schema_floor.as_ref()
6193 }
6194
6195 pub fn lease_state(&self) -> Option<&LeaseState> {
6197 self.lease_state.as_ref()
6198 }
6199
6200 fn record_lease_error(&mut self, code: &str) {
6203 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
6204 return;
6205 }
6206 let mut next = self.lease_state.clone().unwrap_or_default();
6207 next.error_code = Some(code.to_owned());
6208 self.set_lease_state(Some(next));
6209 }
6210
6211 fn set_lease_state(&mut self, next: Option<LeaseState>) {
6212 if self.lease_state == next {
6213 return;
6214 }
6215 if self.begin_observation("syncular_lease").is_err() {
6216 return;
6217 }
6218 self.lease_state = next;
6219 if let Some(lease) = &self.lease_state {
6220 if let Ok(json) = serde_json::to_string(lease) {
6221 self.set_meta(LEASE_STATE_KEY, &json);
6222 }
6223 } else {
6224 self.delete_meta(LEASE_STATE_KEY);
6225 }
6226 let batch = ChangeAccumulator {
6227 status: true,
6228 ..ChangeAccumulator::default()
6229 };
6230 if self.finish_observation("syncular_lease", batch).is_err() {
6231 self.rollback_observation("syncular_lease");
6232 }
6233 }
6234
6235 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
6236 if self.schema_floor == next {
6237 return;
6238 }
6239 if self.begin_observation("syncular_schema_floor").is_err() {
6240 return;
6241 }
6242 self.schema_floor = next;
6243 self.stopped = self.schema_floor.is_some();
6244 if let Some(floor) = &self.schema_floor {
6245 if let Ok(json) = serde_json::to_string(floor) {
6246 self.set_meta(SCHEMA_FLOOR_KEY, &json);
6247 }
6248 } else {
6249 self.delete_meta(SCHEMA_FLOOR_KEY);
6250 }
6251 let batch = ChangeAccumulator {
6252 status: true,
6253 ..ChangeAccumulator::default()
6254 };
6255 if self
6256 .finish_observation("syncular_schema_floor", batch)
6257 .is_err()
6258 {
6259 self.rollback_observation("syncular_schema_floor");
6260 }
6261 }
6262
6263 fn set_upgrading(&mut self, value: bool) {
6264 if self.upgrading == value {
6265 return;
6266 }
6267 if self.begin_observation("syncular_upgrading").is_err() {
6268 return;
6269 }
6270 self.upgrading = value;
6271 let batch = ChangeAccumulator {
6272 status: true,
6273 ..ChangeAccumulator::default()
6274 };
6275 if self
6276 .finish_observation("syncular_upgrading", batch)
6277 .is_err()
6278 {
6279 self.rollback_observation("syncular_upgrading");
6280 }
6281 }
6282
6283 pub fn sync_needed(&self) -> bool {
6284 self.sync_needed
6285 }
6286
6287 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
6288 let sub = self.subs.iter().find(|s| s.id == id)?;
6289 Some(SubscriptionStateView {
6290 id: sub.id.clone(),
6291 table: sub.table.clone(),
6292 status: sub.state.name().to_owned(),
6293 cursor: sub.cursor,
6294 has_resume_token: sub.bootstrap_state.is_some(),
6295 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
6296 reason_code: sub.reason_code.clone(),
6297 })
6298 }
6299
6300 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
6301 let schema_table = self
6302 .schema
6303 .table(table)
6304 .ok_or_else(|| format!("unknown table {table:?}"))?;
6305 let sql = format!(
6306 "SELECT * FROM {} ORDER BY {} ASC",
6307 visible_table(table),
6308 quote_ident(&schema_table.primary_key)
6309 );
6310 let mut stmt = self.conn.prepare(&sql).map_err(|e| e.to_string())?;
6311 let mut rows = stmt.query([]).map_err(|e| e.to_string())?;
6312 let mut out = Vec::new();
6313 while let Some(row) = rows.next().map_err(|e| e.to_string())? {
6314 let mut values = Map::new();
6315 for (i, column) in schema_table.columns.iter().enumerate() {
6316 let value = row.get_ref(i).map_err(|e| e.to_string())?;
6317 values.insert(column.name.clone(), sql_ref_to_json(column, value));
6318 }
6319 let version: i64 = row
6320 .get(schema_table.columns.len())
6321 .map_err(|e| e.to_string())?;
6322 let row_id = match values.get(&schema_table.primary_key) {
6323 Some(Value::String(s)) => s.clone(),
6324 Some(Value::Number(n)) => n.to_string(),
6325 Some(Value::Bool(b)) => b.to_string(),
6326 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
6327 };
6328 out.push(RowState {
6329 row_id,
6330 version,
6331 values,
6332 });
6333 }
6334 Ok(out)
6335 }
6336
6337 #[cfg(feature = "crdt-yjs")]
6353 fn crdt_column_bytes(
6354 &self,
6355 table: &str,
6356 row_id: &str,
6357 column: &str,
6358 ) -> Result<Option<Vec<u8>>, String> {
6359 let schema_table = self
6360 .schema
6361 .table(table)
6362 .ok_or_else(|| format!("unknown table {table:?}"))?;
6363 let col = schema_table
6364 .columns
6365 .iter()
6366 .find(|c| c.name == column)
6367 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
6368 if col.ty != ColumnType::Crdt {
6369 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
6370 }
6371 let sql = format!(
6372 "SELECT {} FROM {} WHERE {}",
6373 quote_ident(column),
6374 visible_table(table),
6375 row_id_predicate(schema_table)
6376 );
6377 let bytes: Option<Vec<u8>> = self
6378 .conn
6379 .prepare_cached(&sql)
6380 .map_err(|error| error.to_string())?
6381 .query_row(rusqlite::params![row_id], |row| {
6382 row.get::<_, Option<Vec<u8>>>(0)
6383 })
6384 .map_err(|e| match e {
6385 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
6386 other => other.to_string(),
6387 })?;
6388 Ok(bytes)
6389 }
6390
6391 #[cfg(feature = "crdt-yjs")]
6395 pub fn crdt_text(
6396 &self,
6397 table: &str,
6398 row_id: &str,
6399 column: &str,
6400 name: &str,
6401 ) -> Result<String, String> {
6402 let bytes = self
6403 .crdt_column_bytes(table, row_id, column)?
6404 .unwrap_or_default();
6405 crate::crdt::text(&bytes, name)
6406 }
6407
6408 #[cfg(feature = "crdt-yjs")]
6412 pub fn crdt_insert_text(
6413 &mut self,
6414 table: &str,
6415 row_id: &str,
6416 column: &str,
6417 name: &str,
6418 index: u32,
6419 value: &str,
6420 ) -> Result<String, String> {
6421 let current = self
6422 .crdt_column_bytes(table, row_id, column)?
6423 .unwrap_or_default();
6424 let update = crate::crdt::insert_text(¤t, name, index, value)?;
6425 self.crdt_push_update(table, row_id, column, &update)
6426 }
6427
6428 #[cfg(feature = "crdt-yjs")]
6431 pub fn crdt_delete_text(
6432 &mut self,
6433 table: &str,
6434 row_id: &str,
6435 column: &str,
6436 name: &str,
6437 index: u32,
6438 len: u32,
6439 ) -> Result<String, String> {
6440 let current = self
6441 .crdt_column_bytes(table, row_id, column)?
6442 .unwrap_or_default();
6443 let update = crate::crdt::delete_text(¤t, name, index, len)?;
6444 self.crdt_push_update(table, row_id, column, &update)
6445 }
6446
6447 #[cfg(feature = "crdt-yjs")]
6452 pub fn crdt_apply_update(
6453 &mut self,
6454 table: &str,
6455 row_id: &str,
6456 column: &str,
6457 update: &[u8],
6458 ) -> Result<String, String> {
6459 let current = self
6460 .crdt_column_bytes(table, row_id, column)?
6461 .unwrap_or_default();
6462 let next = crate::crdt::apply_update(¤t, update)?;
6463 self.crdt_push_update(table, row_id, column, &next)
6464 }
6465
6466 #[cfg(feature = "crdt-yjs")]
6472 fn crdt_push_update(
6473 &mut self,
6474 table: &str,
6475 row_id: &str,
6476 column: &str,
6477 crdt_bytes: &[u8],
6478 ) -> Result<String, String> {
6479 let schema_table = self
6480 .schema
6481 .table(table)
6482 .ok_or_else(|| format!("unknown table {table:?}"))?
6483 .clone();
6484 let mut values: Map<String, Value> = self
6487 .read_rows(table)?
6488 .into_iter()
6489 .find(|r| r.row_id == row_id)
6490 .map(|r| r.values)
6491 .unwrap_or_else(|| {
6492 let mut map = Map::new();
6493 map.insert(
6494 schema_table.primary_key.clone(),
6495 Value::from(row_id.to_owned()),
6496 );
6497 map
6498 });
6499 let mut bytes_obj = Map::new();
6501 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
6502 values.insert(column.to_owned(), Value::Object(bytes_obj));
6503 self.mutate(vec![Mutation::Upsert {
6504 table: table.to_owned(),
6505 values,
6506 base_version: None,
6507 }])
6508 }
6509
6510 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
6525 query_connection(&self.conn, sql, params)
6526 }
6527
6528 #[cfg(feature = "bench-internals")]
6530 #[doc(hidden)]
6531 pub fn benchmark_connection(&mut self) -> &mut Connection {
6532 &mut self.conn
6533 }
6534
6535 #[cfg(feature = "bench-internals")]
6537 #[doc(hidden)]
6538 pub fn benchmark_phases(
6539 &self,
6540 enabled: Option<bool>,
6541 reset: bool,
6542 ) -> Result<Option<Value>, String> {
6543 self.benchmark_phases.configure(enabled, reset)?;
6544 Ok(self.benchmark_phases.snapshot())
6545 }
6546
6547 pub fn query_snapshot(
6549 &mut self,
6550 sql: &str,
6551 params: &[QueryValue],
6552 coverage: &[WindowCoverage],
6553 ) -> Result<QuerySnapshot, String> {
6554 snapshot_connection(&self.conn, sql, params, coverage)
6555 }
6556
6557 fn build_request(&self, url_capable: bool) -> (Message, RequestMeta) {
6560 #[cfg(feature = "bench-internals")]
6561 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
6562 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
6563 let mut frames = vec![Frame::ReqHeader {
6564 client_id: self.client_id.clone(),
6565 schema_version: self.schema.version,
6566 log_epoch: log_epoch.clone(),
6567 }];
6568 let mut pushed_ids = Vec::new();
6569 let mut ops_in_request = 0usize;
6570 let mut deferred_commits = 0usize;
6571 #[cfg(feature = "bench-internals")]
6572 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
6573 for (index, commit) in self
6574 .outbox
6575 .iter()
6576 .take(if log_epoch.is_some() {
6577 self.outbox.len()
6578 } else {
6579 0
6580 })
6581 .enumerate()
6582 {
6583 if ops_in_request > 0 && ops_in_request + commit.ops.len() > PUSH_OPS_PER_REQUEST {
6588 deferred_commits = self.outbox.len() - index;
6589 break;
6590 }
6591 ops_in_request += commit.ops.len();
6592 let operations = commit
6593 .ops
6594 .iter()
6595 .map(|op| {
6596 let payload = op.values.as_ref().and_then(|values| {
6597 let table = self.schema.table(&op.table)?;
6598 encode_row_json(table, &op.row_id, values, &self.encryption).ok()
6602 });
6603 ssp2::model::Operation {
6604 table: op.table.clone(),
6605 row_id: op.row_id.clone(),
6606 op: if op.upsert { Op::Upsert } else { Op::Delete },
6607 base_version: op.base_version,
6608 payload,
6609 }
6610 })
6611 .collect();
6612 frames.push(Frame::PushCommit {
6613 client_commit_id: commit.client_commit_id.clone(),
6614 operations,
6615 });
6616 pushed_ids.push(commit.client_commit_id.clone());
6617 }
6618 #[cfg(feature = "bench-internals")]
6619 drop(outbox_phase);
6620 let accept = self.limits.accept.unwrap_or(if url_capable {
6623 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
6624 } else {
6625 DEFAULT_ACCEPT
6626 });
6627 frames.push(Frame::PullHeader {
6628 limit_commits: self.limits.limit_commits.unwrap_or(0),
6629 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
6630 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
6631 accept,
6632 });
6633 let mut fresh = Vec::new();
6634 for sub in &self.subs {
6635 if sub.state != SubState::Active {
6636 continue;
6637 }
6638 let mut scopes = sub.requested.clone();
6639 sort_scope_map(&mut scopes);
6640 frames.push(Frame::Subscription {
6641 id: sub.id.clone(),
6642 table: sub.table.clone(),
6643 scopes,
6644 params: sub.params.clone().map(RawJson),
6645 cursor: sub.cursor,
6646 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
6647 });
6648 fresh.push((
6649 sub.id.clone(),
6650 sub.cursor < 0 && sub.bootstrap_state.is_none(),
6651 ));
6652 }
6653 let message = Message {
6654 wire_version: WIRE_VERSION,
6655 msg_kind: MsgKind::Request,
6656 frames,
6657 };
6658 (
6659 message,
6660 RequestMeta {
6661 pushed_ids,
6662 fresh,
6663 accept,
6664 deferred_commits,
6665 },
6666 )
6667 }
6668
6669 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
6672 let started_at_ms = self.clock_now_ms();
6673 let outcome = self.sync_inner(transport);
6674 let completed_at_ms = self.clock_now_ms();
6675 self.last_round = Some(match &outcome {
6676 SyncOutcome::Ok(report) => DiagnosticLastRound {
6677 status: "succeeded".to_owned(),
6678 started_at_ms,
6679 completed_at_ms,
6680 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
6681 counters: Some(DiagnosticRoundCounters {
6682 pushed: report.pushed,
6683 applied: report.applied.len(),
6684 rejected: report.rejected.len(),
6685 retryable: report.retryable.len(),
6686 conflicts: report.conflicts,
6687 commits_applied: report.commits_applied,
6688 segment_rows_applied: report.segment_rows_applied,
6689 bootstrapping: report.bootstrapping.len(),
6690 resets: report.resets.len(),
6691 revoked: report.revoked.len(),
6692 failed: report.failed.len(),
6693 deferred_commits: report.deferred_commits,
6694 }),
6695 error_code: None,
6696 },
6697 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
6698 status: "failed".to_owned(),
6699 started_at_ms,
6700 completed_at_ms,
6701 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
6702 counters: None,
6703 error_code: Some(Self::diagnostic_code(error_code)),
6704 },
6705 });
6706 outcome
6707 }
6708
6709 fn sync_inner(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
6710 if self.stopped {
6711 return SyncOutcome::Ok(SyncReport {
6714 schema_floor: self.schema_floor.clone(),
6715 ..SyncReport::default()
6716 });
6717 }
6718 self.set_sync_needed(false, false);
6721 if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
6724 if let Err(TransportError { code, message }) = self.flush_blob_uploads(transport) {
6725 if Self::retryable_transport_code(&code) {
6726 self.schedule_background_retry();
6727 }
6728 return SyncOutcome::Failed {
6729 error_code: code,
6730 message,
6731 };
6732 }
6733 }
6734 let (message, meta) = self.build_request(transport.supports_url_fetch());
6735 let request_bytes = {
6736 #[cfg(feature = "bench-internals")]
6737 let _phase = self.benchmark_phases.start(Phase::RequestEncode);
6738 encode_message(&message)
6739 };
6740 let round = if self.realtime_connected {
6744 transport.realtime_sync(&request_bytes)
6745 } else {
6746 transport.sync(&request_bytes)
6747 };
6748 let response_bytes = match round {
6749 Ok(bytes) => bytes,
6750 Err(TransportError { code, message }) => {
6751 self.record_lease_error(&code);
6754 if Self::retryable_transport_code(&code) {
6755 self.schedule_background_retry();
6756 }
6757 return SyncOutcome::Failed {
6758 error_code: code,
6759 message,
6760 };
6761 }
6762 };
6763 #[cfg(feature = "bench-internals")]
6764 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
6765 let response = match decode_message(&response_bytes) {
6766 Ok(message) => message,
6767 Err(error) => {
6768 return SyncOutcome::Failed {
6771 error_code: error.code.as_str().to_owned(),
6772 message: error.detail,
6773 };
6774 }
6775 };
6776 #[cfg(feature = "bench-internals")]
6777 drop(decode_phase);
6778 if response.msg_kind != MsgKind::Response {
6779 return SyncOutcome::Failed {
6780 error_code: "sync.invalid_request".to_owned(),
6781 message: "expected a response message".to_owned(),
6782 };
6783 }
6784 let mut outcome = self.process_response(transport, response, &meta);
6785 if let SyncOutcome::Ok(report) = &mut outcome {
6786 report.deferred_commits = meta.deferred_commits;
6787 }
6788 match &outcome {
6789 SyncOutcome::Ok(_) => self.reset_background_retry(),
6790 SyncOutcome::Failed { error_code, .. }
6791 if Self::retryable_transport_code(error_code) =>
6792 {
6793 self.schedule_background_retry();
6794 }
6795 SyncOutcome::Failed { .. } => {}
6796 }
6797 if meta.deferred_commits > 0 {
6798 self.set_sync_needed(true, true);
6801 }
6802 outcome
6803 }
6804
6805 pub fn sync_until_idle(
6806 &mut self,
6807 transport: &mut dyn Transport,
6808 max_rounds: Option<u32>,
6809 ) -> SyncOutcome {
6810 let rounds = max_rounds.unwrap_or(12).max(1);
6811 let mut aggregate = SyncReport::default();
6812 for _ in 0..rounds {
6813 match self.sync(transport) {
6814 SyncOutcome::Failed {
6815 error_code,
6816 message,
6817 } => {
6818 return SyncOutcome::Failed {
6819 error_code,
6820 message,
6821 };
6822 }
6823 SyncOutcome::Ok(report) => {
6824 aggregate.pushed += report.pushed;
6825 aggregate.applied.extend(report.applied.iter().cloned());
6826 aggregate.rejected.extend(report.rejected.iter().cloned());
6827 aggregate.retryable.extend(report.retryable.iter().cloned());
6828 aggregate.conflicts += report.conflicts;
6829 aggregate.commits_applied += report.commits_applied;
6830 aggregate.segment_rows_applied += report.segment_rows_applied;
6831 aggregate.bootstrapping = report.bootstrapping.clone();
6832 aggregate.resets.extend(report.resets.iter().cloned());
6833 aggregate.revoked.extend(report.revoked.iter().cloned());
6834 aggregate.failed.extend(report.failed.iter().cloned());
6835 aggregate.deferred_commits = report.deferred_commits;
6836 if report.schema_floor.is_some() {
6837 aggregate.schema_floor = report.schema_floor.clone();
6838 }
6839 let more = !report.bootstrapping.is_empty()
6845 || report.commits_applied > 0
6846 || report.segment_rows_applied > 0
6847 || !report.resets.is_empty()
6848 || self.sync_needed;
6849 if !more {
6850 break;
6851 }
6852 }
6853 }
6854 }
6855 SyncOutcome::Ok(aggregate)
6856 }
6857
6858 fn process_response(
6859 &mut self,
6860 transport: &mut dyn Transport,
6861 response: Message,
6862 meta: &RequestMeta,
6863 ) -> SyncOutcome {
6864 #[cfg(feature = "bench-internals")]
6865 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
6866 let mut report = SyncReport {
6867 pushed: meta.pushed_ids.len() as u32,
6868 ..SyncReport::default()
6869 };
6870 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
6871 HashMap::new();
6872 let pushed_ids = meta
6873 .pushed_ids
6874 .iter()
6875 .map(String::as_str)
6876 .collect::<HashSet<_>>();
6877 let mut last_final_push_result_id: Option<String> = None;
6878 for frame in &response.frames {
6879 match frame {
6880 Frame::PushResultDetails {
6881 client_commit_id,
6882 entries,
6883 } => {
6884 let details = rejection_details_by_commit
6885 .entry(client_commit_id.clone())
6886 .or_default();
6887 for entry in entries {
6888 let parsed = match RejectionDetails::parse(&entry.details.0) {
6889 Ok(value) => value,
6890 Err(message) => {
6891 return SyncOutcome::Failed {
6892 error_code: "sync.invalid_request".to_owned(),
6893 message,
6894 };
6895 }
6896 };
6897 details.insert(entry.op_index, parsed);
6898 }
6899 }
6900 Frame::PushResult {
6901 client_commit_id,
6902 status,
6903 results,
6904 ..
6905 } if pushed_ids.contains(client_commit_id.as_str())
6906 && Self::push_result_is_final(*status, results) =>
6907 {
6908 last_final_push_result_id = Some(client_commit_id.clone());
6909 }
6910 _ => {}
6911 }
6912 }
6913 let mut frames = response.frames.into_iter().peekable();
6914 if response.wire_version < 2 {
6915 return SyncOutcome::Failed {
6916 error_code: "client.invalid_host_response".to_owned(),
6917 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
6918 };
6919 }
6920 match frames.next() {
6921 Some(Frame::RespHeader {
6922 required_schema_version,
6923 latest_schema_version,
6924 log_epoch,
6925 reset_required,
6926 }) => {
6927 let Some(log_epoch) = log_epoch else {
6928 return SyncOutcome::Failed {
6929 error_code: "client.invalid_host_response".to_owned(),
6930 message: "response header omits logEpoch".to_owned(),
6931 };
6932 };
6933 let Some(reset_required) = reset_required else {
6934 return SyncOutcome::Failed {
6935 error_code: "client.invalid_host_response".to_owned(),
6936 message: "response header omits resetRequired".to_owned(),
6937 };
6938 };
6939 if let Some(required) = required_schema_version {
6940 let floor = SchemaFloor {
6943 required_schema_version: Some(required),
6944 latest_schema_version,
6945 };
6946 self.set_schema_floor(Some(floor.clone()));
6947 report.schema_floor = Some(floor);
6948 return SyncOutcome::Ok(report);
6949 }
6950 if reset_required {
6951 if frames.next().is_some() {
6952 return SyncOutcome::Failed {
6953 error_code: "client.invalid_host_response".to_owned(),
6954 message: "log-epoch reset response contains body frames".to_owned(),
6955 };
6956 }
6957 match self.run_log_epoch_reset(&log_epoch) {
6958 Ok(resets) => {
6959 report.resets = resets;
6960 return SyncOutcome::Ok(report);
6961 }
6962 Err(message) => {
6963 return SyncOutcome::Failed {
6964 error_code: "sync.local_corrupt".to_owned(),
6965 message,
6966 };
6967 }
6968 }
6969 }
6970 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
6971 return SyncOutcome::Failed {
6972 error_code: "client.invalid_host_response".to_owned(),
6973 message: "server changed logEpoch without requiring a reset".to_owned(),
6974 };
6975 }
6976 }
6977 _ => {
6978 return SyncOutcome::Failed {
6979 error_code: "sync.invalid_request".to_owned(),
6980 message: "response does not start with RESP_HEADER".to_owned(),
6981 };
6982 }
6983 }
6984
6985 let mut failure: Option<(String, String)> = None;
6986 while let Some(frame) = frames.next() {
6987 match frame {
6988 frame @ Frame::PushResult { .. } => {
6989 let successful = matches!(
6990 &frame,
6991 Frame::PushResult {
6992 status: PushStatus::Applied | PushStatus::Cached,
6993 ..
6994 }
6995 );
6996 let mut results = vec![frame];
6997 if successful {
6998 while matches!(
6999 frames.peek(),
7000 Some(Frame::PushResult {
7001 status: PushStatus::Applied | PushStatus::Cached,
7002 ..
7003 })
7004 ) {
7005 if let Some(next) = frames.next() {
7006 results.push(next);
7007 }
7008 }
7009 }
7010 if self
7011 .handle_push_results(
7012 &results,
7013 &pushed_ids,
7014 &rejection_details_by_commit,
7015 &mut report,
7016 last_final_push_result_id.as_deref(),
7017 )
7018 .is_err()
7019 {
7020 failure = Some((
7021 "client.outcome_persistence_failed".to_owned(),
7022 "local commit outcome could not be persisted".to_owned(),
7023 ));
7024 break;
7025 }
7026 }
7027 Frame::PushResultDetails { .. } => {}
7028 Frame::SubStart {
7029 id,
7030 status,
7031 reason_code,
7032 effective_scopes,
7033 bootstrap: _,
7034 } => {
7035 let mut body = Vec::new();
7036 let mut sub_end: Option<(i64, Option<String>)> = None;
7037 for inner in frames.by_ref() {
7038 match inner {
7039 Frame::SubEnd {
7040 next_cursor,
7041 bootstrap_state,
7042 } => {
7043 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
7044 break;
7045 }
7046 Frame::Unknown { .. } => {}
7047 other => body.push(other),
7048 }
7049 }
7050 if let Err(SectionError::Abort(code, message)) = self.process_section(
7051 transport,
7052 &id,
7053 status,
7054 &reason_code,
7055 effective_scopes,
7056 body,
7057 sub_end,
7058 meta,
7059 &mut report,
7060 ) {
7061 failure = Some((code, message));
7062 break;
7063 }
7064 }
7065 Frame::Lease {
7066 lease_id,
7067 expires_at_ms,
7068 } => {
7069 self.set_lease_state(Some(LeaseState {
7072 lease_id: Some(lease_id),
7073 expires_at_ms: Some(expires_at_ms),
7074 error_code: None,
7075 }));
7076 }
7077 Frame::Error { code, message, .. } => {
7078 failure = Some((code, message));
7081 break;
7082 }
7083 Frame::Unknown { .. } => {}
7084 _ => {
7085 failure = Some((
7086 "sync.invalid_request".to_owned(),
7087 "unexpected frame in response".to_owned(),
7088 ));
7089 break;
7090 }
7091 }
7092 }
7093
7094 if self.overlay_dirty.get() {
7099 self.rebuild_overlay();
7100 }
7101 self.reconcile_blob_refcounts(false);
7108
7109 if let Some((error_code, message)) = failure {
7110 return SyncOutcome::Failed {
7111 error_code,
7112 message,
7113 };
7114 }
7115 self.drain_pending_evictions();
7118 self.ack_after_pull(transport);
7119 if self.upgrading && report.bootstrapping.is_empty() {
7122 self.set_upgrading(false);
7123 }
7124 SyncOutcome::Ok(report)
7125 }
7126
7127 fn handle_push_results(
7130 &mut self,
7131 frames: &[Frame],
7132 pushed_ids: &HashSet<&str>,
7133 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
7134 report: &mut SyncReport,
7135 last_final_push_result_id: Option<&str>,
7136 ) -> Result<(), String> {
7137 self.begin_observation("syncular_push_results")?;
7138 let conflict_count = self.conflicts.len();
7139 let rejection_count = self.rejections.len();
7140 let applied_count = report.applied.len();
7141 let rejected_count = report.rejected.len();
7142 let retryable_count = report.retryable.len();
7143 let was_dirty = self.overlay_dirty.get();
7144 let mut removed = Vec::new();
7145 let mut batch = ChangeAccumulator::default();
7146 let persisted = (|| {
7147 for frame in frames {
7148 let Frame::PushResult {
7149 client_commit_id,
7150 status,
7151 results,
7152 ..
7153 } = frame
7154 else {
7155 return Err("unexpected acknowledgement frame".to_owned());
7156 };
7157 if !pushed_ids.contains(client_commit_id.as_str()) {
7158 continue;
7159 }
7160 let Some(index) = self
7161 .outbox
7162 .iter()
7163 .position(|commit| commit.client_commit_id == *client_commit_id)
7164 else {
7165 continue;
7166 };
7167 let status = *status;
7168 let rejection_details = rejection_details_by_commit.get(client_commit_id);
7169 let operations = self.outbox[index].ops.clone();
7170 match status {
7171 PushStatus::Applied | PushStatus::Cached => {
7172 let journal_results = results
7175 .iter()
7176 .map(|result| {
7177 let op_index = match result {
7178 OpResult::Applied { op_index }
7179 | OpResult::Conflict { op_index, .. }
7180 | OpResult::Error { op_index, .. } => *op_index,
7181 };
7182 CommitOperationOutcome::Applied { op_index }
7183 })
7184 .collect::<Vec<_>>();
7185 let outcome_status = if status == PushStatus::Applied {
7186 CommitOutcomeStatus::Applied
7187 } else {
7188 CommitOutcomeStatus::Cached
7189 };
7190 let persisted = self
7191 .persist_commit_outcome(
7192 client_commit_id,
7193 outcome_status,
7194 &journal_results,
7195 None,
7196 )
7197 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7198 persisted?;
7199 self.overlay_dirty.set(true);
7200 batch.status = true;
7201 batch.outcomes = true;
7202 }
7203 PushStatus::Rejected => {
7204 if results.iter().any(|result| {
7205 matches!(
7206 result,
7207 OpResult::Error {
7208 code,
7209 retryable: true,
7210 ..
7211 } if code == "sync.idempotency_cache_miss"
7212 )
7213 }) {
7214 report.retryable.push(client_commit_id.to_owned());
7217 continue;
7218 }
7219
7220 let mut journal_results = Vec::with_capacity(results.len());
7221 let mut conflicts = Vec::new();
7222 let mut rejections = Vec::new();
7223 for result in results {
7224 match result {
7225 OpResult::Applied { op_index } => {
7226 journal_results.push(CommitOperationOutcome::Applied {
7227 op_index: *op_index,
7228 });
7229 }
7230 OpResult::Conflict {
7231 op_index,
7232 code,
7233 message,
7234 server_version,
7235 server_row,
7236 } => {
7237 let operation = operations
7238 .get(*op_index as usize)
7239 .map(CommitOperation::from);
7240 let (table, row_id) = operation
7241 .as_ref()
7242 .map(|op| (op.table.clone(), op.row_id.clone()))
7243 .unwrap_or_default();
7244 let server_row_json = self
7245 .schema
7246 .table(&table)
7247 .and_then(|t| {
7248 decode_row_bytes(t, server_row, &self.encryption)
7249 .ok()
7250 .map(|row| (t, row))
7251 })
7252 .map(|(t, row)| {
7253 let mut map = Map::new();
7254 for (i, column) in t.columns.iter().enumerate() {
7255 map.insert(
7256 column.name.clone(),
7257 column_value_to_json(
7258 row.get(i).unwrap_or(&None),
7259 ),
7260 );
7261 }
7262 map
7263 })
7264 .unwrap_or_default();
7265 let conflict = ConflictRecord {
7266 client_commit_id: client_commit_id.to_owned(),
7267 op_index: *op_index,
7268 table,
7269 row_id,
7270 code: code.clone(),
7271 message: message.clone(),
7272 server_version: *server_version,
7273 server_row: server_row_json,
7274 operation,
7275 };
7276 journal_results.push(CommitOperationOutcome::Conflict {
7277 conflict: conflict.clone(),
7278 });
7279 conflicts.push(conflict);
7280 }
7281 OpResult::Error {
7282 op_index,
7283 code,
7284 message,
7285 retryable,
7286 } => {
7287 let rejection = RejectionRecord {
7288 client_commit_id: client_commit_id.to_owned(),
7289 op_index: *op_index,
7290 code: code.clone(),
7291 message: message.clone(),
7292 retryable: *retryable,
7293 details: rejection_details
7294 .and_then(|details| details.get(op_index))
7295 .cloned(),
7296 operation: operations
7297 .get(*op_index as usize)
7298 .map(CommitOperation::from),
7299 };
7300 journal_results.push(CommitOperationOutcome::Error {
7301 rejection: rejection.clone(),
7302 });
7303 rejections.push(rejection);
7304 }
7305 }
7306 }
7307 let outcome_status = if conflicts.is_empty() {
7308 CommitOutcomeStatus::Rejected
7309 } else {
7310 CommitOutcomeStatus::Conflict
7311 };
7312 let persisted = self
7313 .persist_commit_outcome(
7314 client_commit_id,
7315 outcome_status,
7316 &journal_results,
7317 Some(&operations),
7318 )
7319 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
7320 persisted?;
7321 batch.conflicts = !conflicts.is_empty();
7322 batch.rejections = !rejections.is_empty();
7323 batch.status = true;
7324 batch.outcomes = true;
7325 self.conflicts.extend(conflicts);
7326 self.rejections.extend(rejections);
7327 self.overlay_dirty.set(true);
7328 }
7329 }
7330 removed.push((index, self.outbox.remove(index)));
7331 if batch.status {
7332 for operation in &operations {
7333 if !self.record_row_scopes(
7334 &mut batch,
7335 &operation.table,
7336 &operation.row_id,
7337 false,
7338 ) {
7339 batch.table(&operation.table);
7340 }
7341 }
7342 }
7343 match status {
7344 PushStatus::Applied | PushStatus::Cached => {
7345 report.applied.push(client_commit_id.to_owned())
7346 }
7347 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
7348 }
7349 }
7350 if batch.outcomes && frames.iter().any(|frame| matches!(frame, Frame::PushResult { client_commit_id, .. } if Some(client_commit_id.as_str()) == last_final_push_result_id)) {
7351 self.prune_commit_outcomes()?;
7352 }
7353 self.finish_observation("syncular_push_results", batch)
7354 })();
7355 if let Err(error) = persisted {
7356 self.rollback_observation("syncular_push_results");
7357 for (index, commit) in removed.into_iter().rev() {
7358 self.outbox.insert(index, commit);
7359 }
7360 self.conflicts.truncate(conflict_count);
7361 self.rejections.truncate(rejection_count);
7362 self.overlay_dirty.set(was_dirty);
7363 report.applied.truncate(applied_count);
7364 report.rejected.truncate(rejected_count);
7365 report.retryable.truncate(retryable_count);
7366 return Err(error);
7367 }
7368 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
7369 Ok(())
7370 }
7371
7372 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
7373 status != PushStatus::Rejected
7374 || !results.iter().any(|result| {
7375 matches!(
7376 result,
7377 OpResult::Error {
7378 code,
7379 retryable: true,
7380 ..
7381 } if code == "sync.idempotency_cache_miss"
7382 )
7383 })
7384 }
7385
7386 #[allow(clippy::too_many_arguments)]
7389 fn process_section(
7390 &mut self,
7391 transport: &mut dyn Transport,
7392 id: &str,
7393 status: SubStatus,
7394 reason_code: &str,
7395 effective_scopes: Vec<(String, Vec<String>)>,
7396 body: Vec<Frame>,
7397 sub_end: Option<(i64, Option<String>)>,
7398 meta: &RequestMeta,
7399 report: &mut SyncReport,
7400 ) -> Result<(), SectionError> {
7401 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
7402 return Ok(()); };
7404 if status != SubStatus::Active && sub_end.is_none() {
7405 return Err(SectionError::Abort(
7406 "sync.invalid_request".into(),
7407 "subscription section without SUB_END".into(),
7408 ));
7409 }
7410 match status {
7411 SubStatus::Revoked => {
7412 self.begin_observation("syncular_revocation")
7413 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7414 let previous = self.subs[sub_index].clone();
7415 let was_dirty = self.overlay_dirty.get();
7416 let previous_outbox = self.outbox.clone();
7417 let rejection_count = self.rejections.len();
7418 let revoked_count = report.revoked.len();
7419 let failed_count = report.failed.len();
7420 let result =
7421 (|| {
7422 let mut batch = ChangeAccumulator::default();
7423 let registered = self.window_unit_by_sub_id(id);
7424 let (table, effective) = {
7426 let sub = &self.subs[sub_index];
7427 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
7428 };
7429 let purged = self.purge_scope_rows(&table, &effective);
7430 match purged {
7431 Ok(()) => {
7432 self.record_scope_map(&mut batch, &table, &effective);
7433 let sub = &mut self.subs[sub_index];
7434 sub.state = SubState::Revoked;
7435 sub.reason_code = Some(if reason_code.is_empty() {
7436 "sync.scope_revoked".to_owned()
7437 } else {
7438 reason_code.to_owned()
7439 });
7440 report.revoked.push(id.to_owned());
7441 let doomed_effective = effective;
7442 let sub_table = table;
7443 self.persist_sub(&self.subs[sub_index].clone()).map_err(
7444 |message| SectionError::Abort("storage.failed".into(), message),
7445 )?;
7446 let dropped = self
7447 .drop_doomed_outbox(&sub_table, &doomed_effective)
7448 .map_err(|message| {
7449 SectionError::Abort("storage.failed".to_owned(), message)
7450 })?;
7451 if dropped {
7452 batch.status = true;
7453 batch.rejections = true;
7454 batch.outcomes = true;
7455 }
7456 self.reconcile_blob_refcounts(true);
7459 }
7460 Err(()) => {
7461 let sub = &mut self.subs[sub_index];
7464 sub.state = SubState::Failed;
7465 sub.reason_code = Some("sync.scope_revoked".to_owned());
7466 report.failed.push(id.to_owned());
7467 self.persist_sub(&self.subs[sub_index].clone()).map_err(
7468 |message| SectionError::Abort("storage.failed".into(), message),
7469 )?;
7470 }
7471 }
7472 if let Some((base_key, unit)) = registered {
7473 batch.window(&base_key, &self.subs[sub_index].table, &unit);
7474 }
7475 self.rebuild_overlay_if_dirty();
7476 self.finish_observation("syncular_revocation", batch)
7477 .map_err(|message| {
7478 SectionError::Abort("storage.failed".to_owned(), message)
7479 })?;
7480 Ok(())
7481 })();
7482 if result.is_err() {
7483 self.rollback_observation("syncular_revocation");
7484 self.subs[sub_index] = previous;
7485 self.overlay_dirty.set(was_dirty);
7486 self.outbox = previous_outbox;
7487 self.rejections.truncate(rejection_count);
7488 report.revoked.truncate(revoked_count);
7489 report.failed.truncate(failed_count);
7490 }
7491 result
7492 }
7493 SubStatus::Reset => {
7494 self.begin_observation("syncular_reset")
7495 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
7496 let previous = self.subs[sub_index].clone();
7497 let was_dirty = self.overlay_dirty.get();
7498 let reset_count = report.resets.len();
7499 let result = (|| {
7500 let mut batch = ChangeAccumulator::default();
7501 let registered = self.window_unit_by_sub_id(id);
7502 let sub = &mut self.subs[sub_index];
7505 sub.cursor = -1;
7506 sub.bootstrap_state = None;
7507 report.resets.push(id.to_owned());
7508 self.persist_sub(&self.subs[sub_index].clone())
7509 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
7510 if let Some((base_key, unit)) = registered {
7511 batch.window(&base_key, &self.subs[sub_index].table, &unit);
7512 }
7513 self.finish_observation("syncular_reset", batch)
7514 .map_err(|message| {
7515 SectionError::Abort("storage.failed".to_owned(), message)
7516 })?;
7517 Ok(())
7518 })();
7519 if result.is_err() {
7520 self.rollback_observation("syncular_reset");
7521 self.subs[sub_index] = previous;
7522 self.overlay_dirty.set(was_dirty);
7523 report.resets.truncate(reset_count);
7524 }
7525 result
7526 }
7527 SubStatus::Active => {
7528 let fresh = meta
7529 .fresh
7530 .iter()
7531 .find(|(fid, _)| fid == id)
7532 .map(|(_, fresh)| *fresh)
7533 .unwrap_or(false);
7534 let previous = self.subs[sub_index].clone();
7535 self.subs[sub_index].effective = Some(effective_scopes);
7536 let outcome = self
7537 .apply_section_body(transport, sub_index, body, fresh, meta, report)
7538 .and_then(|()| {
7539 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
7540 SectionError::Abort(
7541 "sync.invalid_request".into(),
7542 "subscription section without SUB_END".into(),
7543 )
7544 })?;
7545 self.apply_sub_end(sub_index, cursor, bootstrap_state)
7546 .map_err(|message| {
7547 SectionError::Abort("storage.failed".into(), message)
7548 })
7549 });
7550 match outcome {
7551 Ok(()) => {
7552 if self.subs[sub_index].bootstrap_state.is_some() {
7553 report.bootstrapping.push(id.to_owned());
7554 }
7555 Ok(())
7556 }
7557 Err(SectionError::FailClosed) => {
7558 self.subs[sub_index] = previous.clone();
7559 self.begin_observation("syncular_section_failure")
7560 .map_err(|message| {
7561 SectionError::Abort("storage.failed".into(), message)
7562 })?;
7563 let persisted = (|| {
7564 let mut batch = ChangeAccumulator::default();
7565 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
7566 batch.window(&base_key, &self.subs[sub_index].table, &unit);
7567 }
7568 self.subs[sub_index].state = SubState::Failed;
7569 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
7570 self.persist_sub(&self.subs[sub_index])?;
7571 self.finish_observation("syncular_section_failure", batch)
7572 })();
7573 if let Err(message) = persisted {
7574 self.rollback_observation("syncular_section_failure");
7575 self.subs[sub_index] = previous;
7576 return Err(SectionError::Abort("storage.failed".into(), message));
7577 }
7578 report.failed.push(id.to_owned());
7579 Ok(())
7580 }
7581 Err(error) => {
7582 self.subs[sub_index] = previous;
7585 Err(error)
7586 }
7587 }
7588 }
7589 }
7590 }
7591
7592 fn apply_sub_end(
7593 &mut self,
7594 sub_index: usize,
7595 cursor: i64,
7596 bootstrap_state: Option<String>,
7597 ) -> Result<(), String> {
7598 #[cfg(feature = "bench-internals")]
7599 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
7600 let previous = self.subs[sub_index].clone();
7601 self.begin_observation("syncular_sub_end")?;
7602 let persisted = (|| {
7603 let mut batch = ChangeAccumulator::default();
7604 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
7605 && cursor >= 0
7606 && bootstrap_state.is_none();
7607 if completed {
7608 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
7609 batch.window(&base_key, &previous.table, &unit);
7610 }
7611 }
7612 let sub = &mut self.subs[sub_index];
7613 sub.cursor = cursor;
7614 sub.bootstrap_state = bootstrap_state;
7615 sub.synced_once = true;
7616 self.persist_sub(&self.subs[sub_index])?;
7617 self.finish_observation("syncular_sub_end", batch)
7618 })();
7619 if persisted.is_err() {
7620 self.rollback_observation("syncular_sub_end");
7621 self.subs[sub_index] = previous;
7622 }
7623 persisted
7624 }
7625
7626 fn apply_section_body(
7627 &mut self,
7628 transport: &mut dyn Transport,
7629 sub_index: usize,
7630 body: Vec<Frame>,
7631 fresh: bool,
7632 meta: &RequestMeta,
7633 report: &mut SyncReport,
7634 ) -> Result<(), SectionError> {
7635 let mut saw_segment = false;
7636 for frame in body {
7637 match frame {
7638 Frame::Commit {
7639 tables, changes, ..
7640 } => {
7641 self.apply_commit_frame(&tables, &changes)
7642 .map_err(|(c, m)| SectionError::Abort(c, m))?;
7643 report.commits_applied += 1;
7644 }
7645 Frame::SegmentInline { payload } => {
7646 let segment = decode_rows_segment(&payload)
7647 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
7648 let first = !saw_segment;
7649 saw_segment = true;
7650 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
7651 report.segment_rows_applied += applied;
7652 }
7653 Frame::SegmentRef {
7654 segment_id,
7655 media_type,
7656 table,
7657 row_count,
7658 as_of_commit_seq,
7659 scope_digest,
7660 row_cursor,
7661 next_row_cursor,
7662 url,
7663 url_expires_at_ms,
7664 ..
7665 } => {
7666 let advertised = match media_type {
7669 MediaType::Rows => {
7670 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
7671 || meta.accept & ACCEPT_INLINE_ROWS != 0
7672 }
7673 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
7674 };
7675 if !advertised {
7676 return Err(SectionError::Abort(
7677 "sync.invalid_request".to_owned(),
7678 format!(
7679 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
7680 media_type.name()
7681 ),
7682 ));
7683 }
7684 let bytes = if let Some(url) = url {
7685 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
7690 return Err(SectionError::Abort(
7691 "sync.invalid_request".to_owned(),
7692 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
7693 .to_owned(),
7694 ));
7695 }
7696 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
7698 return Err(SectionError::Abort(
7699 "sync.segment_expired".to_owned(),
7700 format!(
7701 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
7702 ),
7703 ));
7704 }
7705 transport
7706 .fetch_url(&url)
7707 .map_err(|e| SectionError::Abort(e.code, e.message))?
7708 } else {
7709 let requested_scopes_json =
7710 canonical_scope_json(&self.subs[sub_index].requested);
7711 transport
7712 .download_segment(&SegmentRequest {
7713 segment_id: segment_id.clone(),
7714 table,
7715 requested_scopes_json,
7716 })
7717 .map_err(|e| SectionError::Abort(e.code, e.message))?
7718 };
7719 let digest = Sha256::digest(&bytes);
7721 let expected = segment_id
7722 .strip_prefix("sha256:")
7723 .unwrap_or(segment_id.as_str());
7724 if bytes_to_hex(&digest) != expected {
7725 return Err(SectionError::Abort(
7726 "sync.invalid_request".to_owned(),
7727 "segment bytes do not match the content address (§5.1)".to_owned(),
7728 ));
7729 }
7730 if media_type == MediaType::Sqlite {
7731 if row_cursor.is_some() || next_row_cursor.is_some() {
7734 return Err(SectionError::Abort(
7735 "sync.invalid_request".to_owned(),
7736 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
7737 .to_owned(),
7738 ));
7739 }
7740 let first = !saw_segment;
7741 saw_segment = true;
7742 let applied = self.apply_sqlite_segment(
7743 sub_index,
7744 &bytes,
7745 fresh && first,
7746 row_count,
7747 as_of_commit_seq,
7748 &scope_digest,
7749 )?;
7750 report.segment_rows_applied += applied;
7751 } else {
7752 let segment = decode_rows_segment(&bytes).map_err(|e| {
7753 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
7754 })?;
7755 let first = row_cursor.is_none();
7756 saw_segment = true;
7757 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
7758 report.segment_rows_applied += applied;
7759 }
7760 }
7761 Frame::Unknown { .. } => {}
7762 Frame::Error { code, message, .. } => {
7763 return Err(SectionError::Abort(code, message))
7764 }
7765 _ => {
7766 return Err(SectionError::Abort(
7767 "sync.invalid_request".to_owned(),
7768 "unexpected frame inside a subscription section".to_owned(),
7769 ));
7770 }
7771 }
7772 }
7773 Ok(())
7774 }
7775
7776 fn apply_commit_frame(
7778 &mut self,
7779 tables: &[String],
7780 changes: &[ssp2::model::Change],
7781 ) -> Result<(), (String, String)> {
7782 #[cfg(feature = "bench-internals")]
7783 let _phase = self.benchmark_phases.start(Phase::CommitApply);
7784 let was_dirty = self.overlay_dirty.get();
7785 self.begin_observation("syncular_commit")
7786 .map_err(|message| ("storage.failed".into(), message))?;
7787 let applied = (|| {
7788 let mut batch = ChangeAccumulator::default();
7789 self.record_commit_changes(&mut batch, tables, changes);
7790 self.apply_commit_changes(tables, changes)?;
7791 self.rebuild_overlay_if_dirty();
7792 self.finish_observation("syncular_commit", batch)
7793 .map_err(|message| ("storage.failed".into(), message))
7794 })();
7795 if applied.is_err() {
7796 self.rollback_observation("syncular_commit");
7797 self.overlay_dirty.set(was_dirty);
7798 }
7799 applied
7800 }
7801
7802 fn apply_commit_changes(
7803 &mut self,
7804 tables: &[String],
7805 changes: &[ssp2::model::Change],
7806 ) -> Result<(), (String, String)> {
7807 let mirror_visible = !self.overlay_dirty.get()
7812 && (self.outbox.is_empty()
7813 || changes.iter().all(|change| {
7814 tables
7815 .get(change.table_index as usize)
7816 .and_then(|name| self.schema.table(name))
7817 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
7818 }));
7819 for change in changes {
7820 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
7821 (
7822 "sync.invalid_request".to_owned(),
7823 "change tableIndex out of range".to_owned(),
7824 )
7825 })?;
7826 let table = self.schema.table(table_name).ok_or_else(|| {
7827 (
7828 "sync.schema_mismatch".to_owned(),
7829 format!("change targets unknown table {table_name:?}"),
7830 )
7831 })?;
7832 match change.op {
7833 Op::Upsert => {
7834 let payload = change.row.as_ref().ok_or_else(|| {
7835 (
7836 "sync.invalid_request".to_owned(),
7837 "upsert change without row payload".to_owned(),
7838 )
7839 })?;
7840 let row = {
7842 #[cfg(feature = "bench-internals")]
7843 let _phase = self.benchmark_phases.start(Phase::RowDecode);
7844 decode_row_bytes(table, payload, &self.encryption)
7845 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
7846 };
7847 let version = change.row_version.unwrap_or(0);
7848 let table_name = table.name.clone();
7849 self.write_base_row(&table_name, &row, version)
7850 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
7851 if mirror_visible {
7852 self.write_row(&visible_table(&table_name), &table_name, &row, version)
7853 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
7854 }
7855 }
7856 Op::Delete => {
7857 self.delete_base_row(table_name, &change.row_id)
7858 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
7859 if mirror_visible {
7860 let sql = format!(
7861 "DELETE FROM {} WHERE {}",
7862 visible_table(table_name),
7863 row_id_predicate(table)
7864 );
7865 self.conn
7866 .prepare_cached(&sql)
7867 .and_then(|mut statement| {
7868 statement.execute(rusqlite::params![change.row_id])
7869 })
7870 .map_err(|error| {
7871 ("sync.invalid_request".to_owned(), error.to_string())
7872 })?;
7873 }
7874 }
7875 }
7876 }
7877 if mirror_visible {
7878 if !self.outbox.is_empty() {
7879 let changed_rows: HashSet<(&str, &str)> = changes
7880 .iter()
7881 .map(|change| {
7882 (
7883 tables[change.table_index as usize].as_str(),
7884 change.row_id.as_str(),
7885 )
7886 })
7887 .collect();
7888 self.apply_outbox_ops(
7889 self.outbox
7890 .iter()
7891 .flat_map(|commit| &commit.ops)
7892 .filter(|op| {
7893 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
7894 }),
7895 );
7896 }
7897 self.overlay_dirty.set(false);
7898 }
7899 Ok(())
7900 }
7901
7902 fn apply_segment(
7907 &mut self,
7908 sub_index: usize,
7909 segment: &RowsSegment,
7910 first_fresh_page: bool,
7911 ) -> Result<u32, SectionError> {
7912 let (sub_table, effective) = {
7913 let sub = &self.subs[sub_index];
7914 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
7915 };
7916 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
7917 SectionError::Abort(
7918 "sync.schema_mismatch".to_owned(),
7919 format!("subscription table {sub_table:?} is not in the client schema"),
7920 )
7921 })?;
7922 let matches = segment.table == table.name
7927 && segment.schema_version == self.schema.version
7928 && segment.columns.len() == table.wire_columns.len()
7929 && segment
7930 .columns
7931 .iter()
7932 .zip(table.wire_columns.iter())
7933 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
7934 if !matches {
7935 return Err(SectionError::Abort(
7936 "sync.schema_mismatch".to_owned(),
7937 "segment column table does not match the generated schema (§5.2)".to_owned(),
7938 ));
7939 }
7940 let mut applied = 0u32;
7941 let empty = Vec::new();
7942 let blocks = if segment.blocks.is_empty() {
7943 std::slice::from_ref(&empty)
7944 } else {
7945 &segment.blocks
7946 };
7947 for (index, block) in blocks.iter().enumerate() {
7948 let clear = first_fresh_page && index == 0;
7949 if !clear && block.is_empty() {
7950 continue;
7951 }
7952 let was_dirty = self.overlay_dirty.get();
7953 self.begin_observation("syncular_segment_block")
7954 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
7955 let outcome = (|| {
7956 let mut batch = ChangeAccumulator::default();
7957 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
7958 batch.table(&table.name);
7959 }
7960 if clear {
7961 self.purge_scope_rows(&table.name, &effective)
7962 .map_err(|()| SectionError::FailClosed)?;
7963 }
7964 for row in block {
7965 let decrypted;
7966 let values = if table.has_encrypted_columns() {
7967 let mut values = row.values.clone();
7968 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
7969 .map_err(|message| {
7970 SectionError::Abort("client.decrypt_failed".into(), message)
7971 })?;
7972 decrypted = values;
7973 &decrypted
7974 } else {
7975 &row.values
7976 };
7977 self.write_base_row(&table.name, values, row.server_version)
7978 .map_err(|message| {
7979 SectionError::Abort("sync.invalid_request".into(), message)
7980 })?;
7981 }
7982 self.rebuild_overlay_if_dirty();
7983 self.finish_observation("syncular_segment_block", batch)
7984 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
7985 })();
7986 if let Err(error) = outcome {
7987 self.rollback_observation("syncular_segment_block");
7988 self.overlay_dirty.set(was_dirty);
7989 return Err(error);
7990 }
7991 applied += block.len() as u32;
7992 }
7993 Ok(applied)
7994 }
7995
7996 fn apply_sqlite_segment(
8004 &mut self,
8005 sub_index: usize,
8006 bytes: &[u8],
8007 first_fresh_page: bool,
8008 row_count: i64,
8009 as_of_commit_seq: i64,
8010 scope_digest: &str,
8011 ) -> Result<u32, SectionError> {
8012 let invalid = |detail: &str| {
8013 SectionError::Abort(
8014 "sync.invalid_request".to_owned(),
8015 format!("sqlite segment rejected: {detail} (§5.3)"),
8016 )
8017 };
8018 let (sub_table, effective) = {
8019 let sub = &self.subs[sub_index];
8020 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
8021 };
8022 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
8023 SectionError::Abort(
8024 "sync.schema_mismatch".to_owned(),
8025 format!("subscription table {sub_table:?} is not in the client schema"),
8026 )
8027 })?;
8028
8029 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
8030 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
8031 let img = match rusqlite::Connection::open_with_flags(
8032 &path,
8033 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
8034 ) {
8035 Ok(conn) => conn,
8036 Err(_) => {
8037 let _ = std::fs::remove_file(&path);
8038 return Err(invalid("bytes do not open as a SQLite database"));
8039 }
8040 };
8041 let was_dirty = self.overlay_dirty.get();
8042 let outcome = (|| {
8043 self.begin_observation("syncular_image")
8044 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8045 let mut batch = ChangeAccumulator::default();
8046 let cleared = first_fresh_page && self.scoped_rows_exist(&table.name, &effective);
8047 let result = self.apply_sqlite_image(
8048 &img,
8049 &table,
8050 first_fresh_page,
8051 &effective,
8052 row_count,
8053 as_of_commit_seq,
8054 scope_digest,
8055 )?;
8056 if result > 0 || cleared {
8057 batch.table(&table.name);
8058 }
8059 self.rebuild_overlay_if_dirty();
8060 self.finish_observation("syncular_image", batch)
8061 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
8062 Ok(result)
8063 })();
8064 if outcome.is_err() {
8065 self.rollback_observation("syncular_image");
8066 self.overlay_dirty.set(was_dirty);
8067 }
8068 drop(img);
8069 let _ = std::fs::remove_file(&path);
8070 outcome
8071 }
8072
8073 #[allow(clippy::too_many_arguments)]
8074 fn apply_sqlite_image(
8075 &mut self,
8076 img: &rusqlite::Connection,
8077 table: &crate::schema::TableSchema,
8078 first_fresh_page: bool,
8079 effective: &[(String, Vec<String>)],
8080 row_count: i64,
8081 as_of_commit_seq: i64,
8082 scope_digest: &str,
8083 ) -> Result<u32, SectionError> {
8084 let invalid = |detail: String| {
8085 SectionError::Abort(
8086 "sync.invalid_request".to_owned(),
8087 format!("sqlite segment rejected: {detail} (§5.3)"),
8088 )
8089 };
8090
8091 type MetaRow = (i64, String, i64, i64, String, i64, i64);
8094 let meta: MetaRow = img
8095 .query_row(
8096 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
8097 \"scopeDigest\", \"rowCount\",
8098 (SELECT count(*) FROM _syncular_segment)
8099 FROM _syncular_segment",
8100 [],
8101 |row| {
8102 Ok((
8103 row.get(0)?,
8104 row.get(1)?,
8105 row.get(2)?,
8106 row.get(3)?,
8107 row.get(4)?,
8108 row.get(5)?,
8109 row.get(6)?,
8110 ))
8111 },
8112 )
8113 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
8114 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
8115 if meta_count != 1 {
8116 return Err(invalid(format!(
8117 "_syncular_segment must contain exactly one row, found {meta_count}"
8118 )));
8119 }
8120 if format != 1 {
8121 return Err(invalid(format!("format {format}")));
8122 }
8123 if meta_table != table.name {
8124 return Err(invalid(format!("image table {meta_table:?}")));
8125 }
8126 if schema_version != i64::from(self.schema.version) {
8127 return Err(invalid(format!("schemaVersion {schema_version}")));
8128 }
8129 if pin != as_of_commit_seq {
8130 return Err(invalid(format!("asOfCommitSeq {pin}")));
8131 }
8132 if digest != scope_digest {
8133 return Err(invalid("scopeDigest mismatch".to_owned()));
8134 }
8135 if meta_rows != row_count {
8136 return Err(invalid(format!("rowCount {meta_rows}")));
8137 }
8138
8139 let mut names: Vec<String> = Vec::new();
8141 {
8142 let mut stmt = img
8143 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
8144 .map_err(|_| invalid("image data table missing".to_owned()))?;
8145 let mut rows = stmt
8146 .query([])
8147 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8148 while let Some(row) = rows
8149 .next()
8150 .map_err(|_| invalid("image data table unreadable".to_owned()))?
8151 {
8152 names.push(
8153 row.get::<_, String>(1)
8154 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
8155 );
8156 }
8157 }
8158 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
8159 expected.push("_syncular_version");
8160 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
8161 return Err(SectionError::Abort(
8162 "sync.schema_mismatch".to_owned(),
8163 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
8164 ));
8165 }
8166
8167 if first_fresh_page {
8170 self.purge_scope_rows(&table.name, effective)
8171 .map_err(|()| SectionError::FailClosed)?;
8172 }
8173 self.overlay_dirty.set(true);
8179 let bulk_indexes: Vec<&crate::schema::IndexSchema> = if first_fresh_page {
8187 table.indexes.iter().filter(|i| !i.unique).collect()
8188 } else {
8189 Vec::new()
8190 };
8191 for index in &bulk_indexes {
8192 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8193 self.conn
8194 .execute(&format!("DROP INDEX IF EXISTS {index_name}"), [])
8195 .map_err(|e| invalid(e.to_string()))?;
8196 }
8197 let insert = self.insert_row_sql(&base_table(&table.name), table);
8198 let applied = {
8199 let mut ins = self
8200 .conn
8201 .prepare_cached(&insert)
8202 .map_err(|e| invalid(e.to_string()))?;
8203 let column_list: Vec<String> = names.iter().map(|n| quote_ident(n)).collect();
8204 let mut stmt = img
8205 .prepare(&format!(
8206 "SELECT {} FROM {}",
8207 column_list.join(", "),
8208 quote_ident(&table.name)
8209 ))
8210 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8211 let mut rows = stmt
8212 .query([])
8213 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
8214 let version_index = table.columns.len();
8215 let mut applied = 0u32;
8216 while let Some(row) = rows
8217 .next()
8218 .map_err(|_| invalid("image row unreadable".to_owned()))?
8219 {
8220 for (i, column) in table.columns.iter().enumerate() {
8221 let cell = row
8222 .get_ref(i)
8223 .map_err(|_| invalid("image row unreadable".to_owned()))?;
8224 let param = image_cell_param(column, cell).map_err(&invalid)?;
8225 ins.raw_bind_parameter(i + 1, param)
8226 .map_err(|e| invalid(e.to_string()))?;
8227 }
8228 let version: i64 = row
8229 .get(version_index)
8230 .map_err(|_| invalid("image row unreadable".to_owned()))?;
8231 if version < 1 {
8232 return Err(invalid(format!(
8233 "row _syncular_version must be >= 1, got {version}"
8234 )));
8235 }
8236 ins.raw_bind_parameter(version_index + 1, version)
8237 .map_err(|e| invalid(e.to_string()))?;
8238 ins.raw_execute().map_err(|e| invalid(e.to_string()))?;
8239 applied += 1;
8240 }
8241 applied
8242 };
8243 for index in &bulk_indexes {
8244 let index_name = quote_ident(&format!("_syncular_base_{}", index.name));
8245 let cols_sql = index
8246 .columns
8247 .iter()
8248 .map(|c| quote_ident(c))
8249 .collect::<Vec<_>>()
8250 .join(", ");
8251 self.conn
8252 .execute(
8253 &format!(
8254 "CREATE INDEX IF NOT EXISTS {index_name} ON {} ({cols_sql})",
8255 base_table(&table.name)
8256 ),
8257 [],
8258 )
8259 .map_err(|e| invalid(e.to_string()))?;
8260 }
8261 if i64::from(applied) != row_count {
8262 return Err(invalid(format!(
8263 "image holds {applied} rows, descriptor says {row_count}"
8264 )));
8265 }
8266 Ok(applied)
8267 }
8268
8269 fn compile_local_data_purge(
8272 &self,
8273 input: &LocalDataPurgeInput,
8274 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
8275 let invalid = |message: String| format!("sync.invalid_request: {message}");
8276 if input.purge_id.is_empty()
8277 || input.purge_id.len() > 128
8278 || !is_local_operation_code_like(&input.purge_id)
8279 {
8280 return Err(invalid(
8281 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
8282 ));
8283 }
8284 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
8285 return Err(invalid(format!(
8286 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
8287 )));
8288 }
8289
8290 let mut deduplicated: BTreeMap<
8291 String,
8292 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
8293 > = BTreeMap::new();
8294 for target in &input.targets {
8295 let table = self.schema.table(&target.table).ok_or_else(|| {
8296 invalid(format!(
8297 "local purge names unknown table {:?}",
8298 target.table
8299 ))
8300 })?;
8301 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
8302 return Err(invalid(format!(
8303 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
8304 target.table
8305 )));
8306 }
8307 let mut selectors = Vec::with_capacity(target.selectors.len());
8308 let mut canonical_selectors = BTreeMap::new();
8309 for (column_name, raw_values) in &target.selectors {
8310 let Some((column_index, column)) = table
8311 .columns
8312 .iter()
8313 .enumerate()
8314 .find(|(_, column)| column.name == *column_name)
8315 else {
8316 return Err(invalid(format!(
8317 "local purge target {:?} names unknown column {:?}",
8318 target.table, column_name
8319 )));
8320 };
8321 let encrypted = table
8322 .encrypted_columns
8323 .iter()
8324 .any(|candidate| candidate.index == column_index);
8325 if column.ty != ColumnType::String || encrypted {
8326 return Err(invalid(format!(
8327 "local purge selector {:?}.{:?} must be a plaintext string column",
8328 target.table, column_name
8329 )));
8330 }
8331 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
8332 return Err(invalid(format!(
8333 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
8334 target.table, column_name
8335 )));
8336 }
8337 let mut values = raw_values.clone();
8338 values.sort();
8339 values.dedup();
8340 if values.iter().any(|value| {
8341 value.is_empty()
8342 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
8343 || !is_local_operation_code_like(value)
8344 }) {
8345 return Err(invalid(format!(
8346 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
8347 )));
8348 }
8349 selectors.push((column_name.clone(), values.clone()));
8350 canonical_selectors.insert(column_name.clone(), values);
8351 }
8352 let canonical = LocalDataPurgeTarget {
8353 table: target.table.clone(),
8354 selectors: canonical_selectors,
8355 };
8356 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8357 deduplicated.insert(
8358 key,
8359 (
8360 CompiledLocalDataPurgeTarget {
8361 table: target.table.clone(),
8362 selectors,
8363 },
8364 canonical,
8365 ),
8366 );
8367 }
8368 let targets = deduplicated
8369 .values()
8370 .map(|(compiled, _)| compiled.clone())
8371 .collect::<Vec<_>>();
8372 let canonical = deduplicated
8373 .values()
8374 .map(|(_, target)| target.clone())
8375 .collect::<Vec<_>>();
8376 let canonical_plan =
8377 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
8378 Ok((targets, canonical_plan))
8379 }
8380
8381 fn local_purge_base_row_ids(
8382 &self,
8383 targets: &[CompiledLocalDataPurgeTarget],
8384 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
8385 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
8386 for target in targets {
8387 let table = self
8388 .schema
8389 .table(&target.table)
8390 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
8391 let mut clauses = Vec::with_capacity(target.selectors.len());
8392 let mut params = Vec::new();
8393 for (column, values) in &target.selectors {
8394 clauses.push(format!(
8395 "{} IN ({})",
8396 quote_ident(column),
8397 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
8398 ));
8399 params.extend(values.iter().cloned().map(SqlValue::Text));
8400 }
8401 let sql = format!(
8402 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
8403 quote_ident(&table.primary_key),
8404 base_table(&target.table),
8405 clauses.join(" AND ")
8406 );
8407 let mut statement = self.conn.prepare(&sql).map_err(|error| error.to_string())?;
8408 let rows = statement
8409 .query_map(rusqlite::params_from_iter(params), |row| {
8410 row.get::<_, String>(0)
8411 })
8412 .map_err(|error| error.to_string())?;
8413 let ids = by_table.entry(target.table.clone()).or_default();
8414 for row in rows {
8415 ids.insert(row.map_err(|error| error.to_string())?);
8416 }
8417 }
8418 Ok(by_table)
8419 }
8420
8421 fn local_purge_values_match(
8422 target: &CompiledLocalDataPurgeTarget,
8423 values: &Map<String, Value>,
8424 ) -> bool {
8425 target.selectors.iter().all(|(column, allowed)| {
8426 values
8427 .get(column)
8428 .and_then(Value::as_str)
8429 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
8430 })
8431 }
8432
8433 pub fn purge_local_data(
8437 &mut self,
8438 input: &LocalDataPurgeInput,
8439 ) -> Result<LocalDataPurgeResult, String> {
8440 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
8441 let meta_key = format!("localPurge:{}", input.purge_id);
8442 let applied_plan = self
8443 .conn
8444 .query_row(
8445 "SELECT value FROM _syncular_meta WHERE key = ?1",
8446 rusqlite::params![meta_key],
8447 |row| row.get::<_, String>(0),
8448 )
8449 .optional()
8450 .map_err(|error| error.to_string())?;
8451 if let Some(applied_plan) = applied_plan {
8452 if applied_plan != canonical_plan {
8453 return Err(format!(
8454 "sync.invalid_request: local purge id {:?} was already used with a different plan",
8455 input.purge_id
8456 ));
8457 }
8458 return Ok(LocalDataPurgeResult {
8459 already_applied: true,
8460 purged_rows: 0,
8461 dropped_commits: 0,
8462 });
8463 }
8464
8465 let prior_outbox = self.outbox.clone();
8466 let prior_rejection_count = self.rejections.len();
8467 let prior_overlay_dirty = self.overlay_dirty.get();
8468 self.begin_observation("syncular_local_purge")?;
8469 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
8470 let row_ids = self.local_purge_base_row_ids(&targets)?;
8471 let doomed = self
8472 .outbox
8473 .iter()
8474 .filter(|commit| {
8475 commit.ops.iter().any(|operation| {
8476 let matching_targets = targets
8477 .iter()
8478 .filter(|target| target.table == operation.table)
8479 .collect::<Vec<_>>();
8480 if matching_targets.is_empty() {
8481 return false;
8482 }
8483 if row_ids
8484 .get(&operation.table)
8485 .is_some_and(|ids| ids.contains(&operation.row_id))
8486 {
8487 return true;
8488 }
8489 operation.values.as_ref().is_some_and(|values| {
8490 matching_targets
8491 .iter()
8492 .any(|target| Self::local_purge_values_match(target, values))
8493 })
8494 })
8495 })
8496 .cloned()
8497 .collect::<Vec<_>>();
8498 let doomed_ids = doomed
8499 .iter()
8500 .map(|commit| commit.client_commit_id.clone())
8501 .collect::<BTreeSet<_>>();
8502 let mut batch = ChangeAccumulator::default();
8503 let mut rejections = Vec::new();
8504 for commit in &doomed {
8505 for operation in &commit.ops {
8506 batch.table(&operation.table);
8507 }
8508 let results = commit
8509 .ops
8510 .iter()
8511 .enumerate()
8512 .map(|(op_index, operation)| {
8513 let rejection = RejectionRecord {
8514 client_commit_id: commit.client_commit_id.clone(),
8515 op_index: op_index as i32,
8516 code: "client.local_data_purged".to_owned(),
8517 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
8518 retryable: false,
8519 details: None,
8520 operation: Some(CommitOperation::from(operation)),
8521 };
8522 rejections.push(rejection.clone());
8523 CommitOperationOutcome::Error { rejection }
8524 })
8525 .collect::<Vec<_>>();
8526 self.persist_commit_outcome(
8527 &commit.client_commit_id,
8528 CommitOutcomeStatus::Rejected,
8529 &results,
8530 Some(&commit.ops),
8531 )?;
8532 self.delete_outbox_persisted(&commit.client_commit_id)?;
8533 }
8534 if !doomed.is_empty() {
8535 self.outbox
8536 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
8537 self.rejections.extend(rejections);
8538 self.prune_commit_outcomes()?;
8539 self.overlay_dirty.set(true);
8540 batch.status = true;
8541 batch.rejections = true;
8542 batch.outcomes = true;
8543 }
8544
8545 let mut purged_rows = 0usize;
8546 for (table, ids) in &row_ids {
8547 if ids.is_empty() {
8548 continue;
8549 }
8550 batch.table(table);
8551 for row_id in ids {
8552 self.delete_base_row(table, row_id)?;
8553 purged_rows += 1;
8554 }
8555 }
8556 self.rebuild_overlay_if_dirty();
8557 self.reconcile_blob_refcounts(true);
8558 self.conn
8559 .execute(
8560 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
8561 rusqlite::params![meta_key, canonical_plan],
8562 )
8563 .map_err(|error| error.to_string())?;
8564 Ok((
8565 batch,
8566 LocalDataPurgeResult {
8567 already_applied: false,
8568 purged_rows,
8569 dropped_commits: doomed.len(),
8570 },
8571 ))
8572 })();
8573 let (batch, result) = match applied {
8574 Ok(value) => value,
8575 Err(error) => {
8576 self.rollback_observation("syncular_local_purge");
8577 self.outbox = prior_outbox;
8578 self.rejections.truncate(prior_rejection_count);
8579 self.overlay_dirty.set(prior_overlay_dirty);
8580 return Err(error);
8581 }
8582 };
8583 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
8584 self.rollback_observation("syncular_local_purge");
8585 self.outbox = prior_outbox;
8586 self.rejections.truncate(prior_rejection_count);
8587 self.overlay_dirty.set(prior_overlay_dirty);
8588 return Err(error);
8589 }
8590 Ok(result)
8591 }
8592
8593 pub fn rebootstrap_local_data(
8599 &mut self,
8600 input: &LocalDataRebootstrapInput,
8601 ) -> Result<LocalDataRebootstrapResult, String> {
8602 if input.rebootstrap_id.is_empty()
8603 || input.rebootstrap_id.len() > 128
8604 || !is_local_operation_code_like(&input.rebootstrap_id)
8605 {
8606 return Err(
8607 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
8608 .to_owned(),
8609 );
8610 }
8611 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
8612 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
8613 let (retained_commits, reset_subscriptions) =
8614 decode_local_rebootstrap_receipt(&persisted)?;
8615 return Ok(LocalDataRebootstrapResult {
8616 already_applied: true,
8617 retained_commits,
8618 reset_subscriptions,
8619 });
8620 }
8621 if self.stopped || self.schema_floor.is_some() {
8622 return Err(
8623 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
8624 .to_owned(),
8625 );
8626 }
8627
8628 let retained_commits = self.outbox.len();
8629 let reset_subscriptions = self.subs.len();
8630 let prior_subs = self.subs.clone();
8631 let prior_upgrading = self.upgrading;
8632 let prior_stopped = self.stopped;
8633 let prior_schema_floor = self.schema_floor.clone();
8634 let prior_overlay_dirty = self.overlay_dirty.get();
8635 let prior_sync_needed = self.sync_needed;
8636 let prior_sync_intents = self.sync_intent_queue.clone();
8637 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
8638
8639 self.begin_observation("syncular_local_rebootstrap")?;
8640 let mut batch = ChangeAccumulator::default();
8641 let applied = (|| -> Result<(), String> {
8642 self.run_schema_reset_observed(&mut batch, false)?;
8643 self.conn
8644 .execute(
8645 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
8646 rusqlite::params![meta_key, receipt],
8647 )
8648 .map_err(|error| error.to_string())?;
8649 self.sync_needed = true;
8650 self.sync_intent_queue.push_back(SyncIntent::Interactive);
8651 batch.status = true;
8652 Ok(())
8653 })();
8654 if let Err(error) = applied {
8655 self.rollback_observation("syncular_local_rebootstrap");
8656 self.subs = prior_subs;
8657 self.upgrading = prior_upgrading;
8658 self.stopped = prior_stopped;
8659 self.schema_floor = prior_schema_floor;
8660 self.overlay_dirty.set(prior_overlay_dirty);
8661 self.sync_needed = prior_sync_needed;
8662 self.sync_intent_queue = prior_sync_intents;
8663 return Err(error);
8664 }
8665 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
8666 self.rollback_observation("syncular_local_rebootstrap");
8667 self.subs = prior_subs;
8668 self.upgrading = prior_upgrading;
8669 self.stopped = prior_stopped;
8670 self.schema_floor = prior_schema_floor;
8671 self.overlay_dirty.set(prior_overlay_dirty);
8672 self.sync_needed = prior_sync_needed;
8673 self.sync_intent_queue = prior_sync_intents;
8674 return Err(error);
8675 }
8676
8677 Ok(LocalDataRebootstrapResult {
8678 already_applied: false,
8679 retained_commits,
8680 reset_subscriptions,
8681 })
8682 }
8683
8684 fn purge_scope_rows(
8689 &mut self,
8690 table_name: &str,
8691 effective: &[(String, Vec<String>)],
8692 ) -> Result<(), ()> {
8693 if effective.is_empty() {
8694 return Ok(());
8695 }
8696 let table = self.schema.table(table_name).ok_or(())?.clone();
8697 let mut clauses = Vec::new();
8698 let mut params: Vec<SqlValue> = Vec::new();
8699 for (variable, values) in effective {
8700 let column = table.scope_column(variable).ok_or(())?;
8701 let placeholders: Vec<String> = values
8702 .iter()
8703 .map(|v| {
8704 params.push(SqlValue::Text(v.clone()));
8705 "?".to_owned()
8706 })
8707 .collect();
8708 clauses.push(format!(
8709 "{} IN ({})",
8710 quote_ident(column),
8711 placeholders.join(", ")
8712 ));
8713 }
8714 let sql = format!(
8715 "DELETE FROM {} WHERE {}",
8716 base_table(table_name),
8717 clauses.join(" AND ")
8718 );
8719 self.overlay_dirty.set(true);
8720 self.conn
8721 .execute(&sql, rusqlite::params_from_iter(params))
8722 .map_err(|_| ())?;
8723 Ok(())
8724 }
8725
8726 fn drop_doomed_outbox(
8729 &mut self,
8730 table_name: &str,
8731 effective: &[(String, Vec<String>)],
8732 ) -> Result<bool, String> {
8733 if effective.is_empty() {
8734 return Ok(false);
8735 }
8736 let Some(table) = self.schema.table(table_name).cloned() else {
8737 return Ok(false);
8738 };
8739 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
8740 for (variable, values) in effective {
8741 match table.scope_column(variable) {
8742 Some(column) => mappings.push((column, values)),
8743 None => return Ok(false), }
8745 }
8746 let doomed: Vec<OutboxCommit> = self
8747 .outbox
8748 .iter()
8749 .filter(|commit| {
8750 commit.ops.iter().any(|op| {
8751 op.upsert
8752 && op.table == table_name
8753 && op.values.as_ref().is_some_and(|values| {
8754 mappings
8755 .iter()
8756 .all(|(column, allowed)| match values.get(*column) {
8757 Some(Value::String(s)) => allowed.contains(s),
8758 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
8759 _ => false,
8760 })
8761 })
8762 })
8763 })
8764 .cloned()
8765 .collect();
8766 if doomed.is_empty() {
8767 return Ok(false);
8768 }
8769 let mut rejections = Vec::new();
8770 for commit in &doomed {
8771 let results = commit
8772 .ops
8773 .iter()
8774 .enumerate()
8775 .map(|(op_index, operation)| {
8776 let rejection = RejectionRecord {
8777 client_commit_id: commit.client_commit_id.clone(),
8778 op_index: op_index as i32,
8779 code: "sync.scope_revoked".to_owned(),
8780 message: "the commit was dropped because its effective scope was revoked"
8781 .to_owned(),
8782 retryable: false,
8783 details: None,
8784 operation: Some(CommitOperation::from(operation)),
8785 };
8786 rejections.push(rejection.clone());
8787 CommitOperationOutcome::Error { rejection }
8788 })
8789 .collect::<Vec<_>>();
8790 self.persist_commit_outcome(
8791 &commit.client_commit_id,
8792 CommitOutcomeStatus::Rejected,
8793 &results,
8794 Some(&commit.ops),
8795 )?;
8796 self.delete_outbox_persisted(&commit.client_commit_id)?;
8797 }
8798 self.prune_commit_outcomes()?;
8799 let doomed_ids = doomed
8800 .iter()
8801 .map(|commit| commit.client_commit_id.as_str())
8802 .collect::<BTreeSet<_>>();
8803 self.outbox
8804 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
8805 self.rejections.extend(rejections);
8806 self.overlay_dirty.set(true);
8807 Ok(true)
8808 }
8809
8810 pub fn upload_blob(
8817 &mut self,
8818 bytes: &[u8],
8819 media_type: Option<String>,
8820 name: Option<String>,
8821 ) -> Result<Value, String> {
8822 let blob_id = blob_id_for(bytes);
8823 let now = self.clock_now_ms();
8824 self.conn
8825 .execute(
8826 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,?,0,?,?)
8827 ON CONFLICT(blob_id) DO UPDATE SET last_used_ms = excluded.last_used_ms",
8828 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now, now],
8829 )
8830 .map_err(|e| e.to_string())?;
8831 self.conn
8832 .execute(
8833 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
8834 rusqlite::params![blob_id, media_type, now],
8835 )
8836 .map_err(|e| e.to_string())?;
8837 self.enforce_blob_cache_cap();
8840 let mut obj = Map::new();
8841 obj.insert("blobId".to_owned(), Value::from(blob_id));
8842 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
8843 if let Some(mt) = media_type {
8844 obj.insert("mediaType".to_owned(), Value::from(mt));
8845 }
8846 if let Some(n) = name {
8847 obj.insert("name".to_owned(), Value::from(n));
8848 }
8849 Ok(Value::Object(obj))
8850 }
8851
8852 pub fn fetch_blob(
8856 &mut self,
8857 transport: &mut dyn Transport,
8858 blob_id_or_ref: &str,
8859 ) -> Result<Value, (String, String)> {
8860 let simple = |m: String| ("client.failed".to_owned(), m);
8861 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
8862 blob_id_or_ref.to_owned()
8863 } else {
8864 let value: Value = serde_json::from_str(blob_id_or_ref)
8865 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
8866 value
8867 .get("blobId")
8868 .and_then(Value::as_str)
8869 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
8870 .to_owned()
8871 };
8872 if let Some(cached) = self.get_cached_blob(&blob_id).map_err(simple)? {
8873 return Ok(cached);
8874 }
8875 #[cfg(feature = "bench-internals")]
8881 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
8882 let bytes = match transport
8883 .blob_download(&blob_id)
8884 .map_err(|e| (e.code, e.message))?
8885 {
8886 BlobDownload::Bytes(bytes) => bytes,
8887 BlobDownload::Url {
8888 url,
8889 url_expires_at_ms,
8890 } => {
8891 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
8893 return Err((
8894 "sync.segment_expired".to_owned(),
8895 format!(
8896 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
8897 ),
8898 ));
8899 }
8900 transport
8901 .fetch_blob_url(&url)
8902 .map_err(|e| (e.code, e.message))?
8903 }
8904 };
8905 #[cfg(feature = "bench-internals")]
8906 drop(download_phase);
8907 #[cfg(feature = "bench-internals")]
8908 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
8909 if blob_id_for(&bytes) != blob_id {
8911 return Err(simple(format!(
8912 "blob content address mismatch for {blob_id}"
8913 )));
8914 }
8915 #[cfg(feature = "bench-internals")]
8916 drop(validate_phase);
8917 #[cfg(feature = "bench-internals")]
8918 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
8919 let now = self.clock_now_ms();
8920 self.conn
8921 .execute(
8922 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, refcount, created_at_ms, last_used_ms) VALUES (?,?,?,NULL,0,?,?)",
8923 rusqlite::params![blob_id, bytes, bytes.len() as i64, now, now],
8924 )
8925 .map_err(|e| simple(e.to_string()))?;
8926 self.reconcile_blob_refcounts(false);
8929 self.enforce_blob_cache_cap();
8930 #[cfg(feature = "bench-internals")]
8931 drop(insert_phase);
8932 self.get_cached_blob(&blob_id)
8933 .map_err(simple)?
8934 .ok_or_else(|| simple("blob cache write failed".to_owned()))
8935 }
8936
8937 fn get_cached_blob(&self, blob_id: &str) -> Result<Option<Value>, String> {
8938 #[cfg(feature = "bench-internals")]
8939 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
8940 let _ = self.conn.execute(
8943 "UPDATE _syncular_blobs SET last_used_ms = ? WHERE blob_id = ?",
8944 rusqlite::params![self.clock_now_ms(), blob_id],
8945 );
8946 let mut stmt = self
8947 .conn
8948 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
8949 .map_err(|e| e.to_string())?;
8950 let mut rows = stmt
8951 .query(rusqlite::params![blob_id])
8952 .map_err(|e| e.to_string())?;
8953 if let Some(row) = rows.next().map_err(|e| e.to_string())? {
8954 let bytes: Vec<u8> = row.get(0).map_err(|e| e.to_string())?;
8955 let byte_length: i64 = row.get(1).map_err(|e| e.to_string())?;
8956 let media_type: Option<String> = row.get(2).map_err(|e| e.to_string())?;
8957 let mut obj = Map::new();
8958 obj.insert("blobId".to_owned(), Value::from(blob_id.to_owned()));
8959 obj.insert("byteLength".to_owned(), Value::from(byte_length));
8960 let mut bytes_obj = Map::new();
8961 let encoded = {
8962 #[cfg(feature = "bench-internals")]
8963 let _phase = self.benchmark_phases.start(Phase::BlobEncode);
8964 bytes_to_hex(&bytes)
8965 };
8966 bytes_obj.insert("$bytes".to_owned(), Value::from(encoded));
8967 obj.insert("bytes".to_owned(), Value::Object(bytes_obj));
8968 if let Some(mt) = media_type {
8969 obj.insert("mediaType".to_owned(), Value::from(mt));
8970 }
8971 return Ok(Some(Value::Object(obj)));
8972 }
8973 Ok(None)
8974 }
8975
8976 fn flush_blob_uploads(&mut self, transport: &mut dyn Transport) -> Result<(), TransportError> {
8978 let pending: Vec<(String, Option<String>)> = {
8979 let mut stmt = self
8980 .conn
8981 .prepare(
8982 "SELECT blob_id, media_type FROM _syncular_blob_uploads ORDER BY created_at_ms",
8983 )
8984 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
8985 let rows = stmt
8986 .query_map([], |row| {
8987 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
8988 })
8989 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
8990 rows.filter_map(Result::ok).collect()
8991 };
8992 for (blob_id, media_type) in pending {
8993 let bytes: Option<Vec<u8>> = self
8994 .conn
8995 .query_row(
8996 "SELECT bytes FROM _syncular_blobs WHERE blob_id = ?",
8997 rusqlite::params![blob_id],
8998 |row| row.get(0),
8999 )
9000 .ok();
9001 if let Some(bytes) = bytes {
9002 self.upload_one(transport, &blob_id, &bytes, media_type.as_deref())?;
9003 }
9004 let _ = self.conn.execute(
9005 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
9006 rusqlite::params![blob_id],
9007 );
9008 }
9009 Ok(())
9010 }
9011
9012 fn upload_one(
9019 &self,
9020 transport: &mut dyn Transport,
9021 blob_id: &str,
9022 bytes: &[u8],
9023 media_type: Option<&str>,
9024 ) -> Result<(), TransportError> {
9025 match transport.blob_upload_grant(blob_id, bytes.len() as u64, media_type)? {
9026 BlobUploadGrant::Present => return Ok(()), BlobUploadGrant::Url {
9028 url,
9029 url_expires_at_ms,
9030 } => {
9031 let live = url_expires_at_ms.is_none_or(|exp| exp > self.clock_now_ms());
9032 if live && transport.blob_put_url(&url, bytes, media_type).is_ok() {
9033 return Ok(());
9034 }
9035 }
9037 BlobUploadGrant::None => {
9038 }
9040 }
9041 transport.blob_upload(blob_id, bytes, media_type)
9042 }
9043
9044 fn enforce_blob_cache_cap(&self) {
9053 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
9054 return;
9055 };
9056 let mut total: i64 = self
9057 .conn
9058 .query_row(
9059 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
9060 [],
9061 |row| row.get(0),
9062 )
9063 .unwrap_or(0);
9064 if total <= max_bytes {
9065 return;
9066 }
9067 let candidates: Vec<(String, i64)> = {
9068 let Ok(mut stmt) = self.conn.prepare(
9069 "SELECT blob_id, byte_length FROM _syncular_blobs
9070 WHERE refcount = 0
9071 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
9072 ORDER BY last_used_ms ASC, created_at_ms ASC",
9073 ) else {
9074 return;
9075 };
9076 let Ok(rows) = stmt.query_map([], |row| {
9077 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
9078 }) else {
9079 return;
9080 };
9081 rows.filter_map(Result::ok).collect()
9082 };
9083 for (blob_id, byte_length) in candidates {
9084 if total <= max_bytes {
9085 break;
9086 }
9087 let _ = self.conn.execute(
9088 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
9089 rusqlite::params![blob_id],
9090 );
9091 total -= byte_length;
9092 }
9093 }
9094
9095 fn reconcile_blob_refcounts(&mut self, delete_orphans: bool) {
9099 #[cfg(feature = "bench-internals")]
9100 let _phase = self.benchmark_phases.start(Phase::BlobReconcile);
9101 if !self.schema_has_blobs() {
9102 return;
9103 }
9104 self.rebuild_overlay_if_dirty();
9107 let mut counts: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
9108 for table in self.schema.tables.clone() {
9109 let blob_cols: Vec<String> = table
9110 .columns
9111 .iter()
9112 .filter(|c| c.ty == ColumnType::BlobRef)
9113 .map(|c| c.name.clone())
9114 .collect();
9115 for column in blob_cols {
9116 let sql = format!(
9117 "SELECT {} FROM {} WHERE {} IS NOT NULL",
9118 quote_ident(&column),
9119 quote_ident(&table.name),
9120 quote_ident(&column)
9121 );
9122 let Ok(mut stmt) = self.conn.prepare(&sql) else {
9123 continue;
9124 };
9125 let Ok(rows) = stmt.query_map([], |row| row.get::<_, Option<String>>(0)) else {
9126 continue;
9127 };
9128 for raw in rows.flatten().flatten() {
9129 if let Ok(value) = serde_json::from_str::<Value>(&raw) {
9130 if let Some(id) = value.get("blobId").and_then(Value::as_str) {
9131 *counts.entry(id.to_owned()).or_insert(0) += 1;
9132 }
9133 }
9134 }
9135 }
9136 }
9137 let _ = self
9138 .conn
9139 .execute("UPDATE _syncular_blobs SET refcount = 0", []);
9140 for (blob_id, count) in &counts {
9141 let _ = self.conn.execute(
9142 "UPDATE _syncular_blobs SET refcount = ? WHERE blob_id = ?",
9143 rusqlite::params![count, blob_id],
9144 );
9145 }
9146 if delete_orphans {
9147 let _ = self.conn.execute(
9148 "DELETE FROM _syncular_blobs WHERE refcount = 0 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)",
9149 [],
9150 );
9151 }
9152 }
9153
9154 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
9159 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
9160 return sql.clone();
9161 }
9162 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
9163 columns.push(quote_ident("_syncular_version"));
9164 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
9165 let primary_key = quote_ident(&table.primary_key);
9166 let updates = columns
9167 .iter()
9168 .filter(|column| **column != primary_key)
9169 .map(|column| format!("{column}=excluded.{column}"))
9170 .collect::<Vec<_>>()
9171 .join(", ");
9172 let sql = format!(
9173 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
9174 columns.join(", "),
9175 placeholders.join(", ")
9176 );
9177 self.insert_sql
9178 .borrow_mut()
9179 .insert(full_table.to_owned(), sql.clone());
9180 sql
9181 }
9182
9183 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
9184 self.overlay_dirty.set(true);
9185 self.write_row(&base_table(table_name), table_name, row, version)
9186 }
9187
9188 fn write_row(
9189 &self,
9190 full_table: &str,
9191 table_name: &str,
9192 row: &Row,
9193 version: i64,
9194 ) -> Result<(), String> {
9195 #[cfg(feature = "bench-internals")]
9196 let _phase = self.benchmark_phases.start(Phase::RowWrite);
9197 let table = self
9198 .schema
9199 .table(table_name)
9200 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9201 let sql = self.insert_row_sql(full_table, table);
9202 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9203 let params = row
9204 .iter()
9205 .map(RowParam::Cell)
9206 .chain(std::iter::once(RowParam::Version(version)));
9207 stmt.execute(rusqlite::params_from_iter(params))
9208 .map_err(|e| e.to_string())?;
9209 Ok(())
9210 }
9211
9212 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
9213 let table = self
9214 .schema
9215 .table(table_name)
9216 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
9217 self.overlay_dirty.set(true);
9218 let sql = format!(
9219 "DELETE FROM {} WHERE {}",
9220 base_table(table_name),
9221 row_id_predicate(table)
9222 );
9223 let mut stmt = self.conn.prepare_cached(&sql).map_err(|e| e.to_string())?;
9224 stmt.execute(rusqlite::params![row_id])
9225 .map_err(|e| e.to_string())?;
9226 Ok(())
9227 }
9228
9229 fn rebuild_overlay_if_dirty(&mut self) {
9232 if self.overlay_dirty.get() {
9233 self.rebuild_overlay();
9234 }
9235 }
9236
9237 fn rebuild_overlay(&mut self) {
9241 #[cfg(feature = "bench-internals")]
9242 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
9243 #[cfg(test)]
9244 self.overlay_rebuild_count
9245 .set(self.overlay_rebuild_count.get() + 1);
9246 self.exec("SAVEPOINT syncular_overlay");
9247 for table in self.schema.tables.clone() {
9248 for index in &table.fts_indexes {
9249 let _ = self.drop_fts_triggers(index);
9250 }
9251 let visible = visible_table(&table.name);
9252 let base = base_table(&table.name);
9253 self.exec(&format!("DELETE FROM {visible}"));
9254 self.exec(&format!("INSERT INTO {visible} SELECT * FROM {base}"));
9255 }
9256 for commit in &self.outbox {
9257 self.apply_outbox_ops(&commit.ops);
9258 }
9259 for table in self.schema.tables.clone() {
9260 for index in &table.fts_indexes {
9261 let _ = self.rebuild_fts_projection(&table, index);
9262 let _ = self.create_fts_triggers(&table, index);
9263 }
9264 }
9265 self.exec("RELEASE syncular_overlay");
9266 self.overlay_dirty.set(false);
9267 }
9268
9269 fn apply_outbox_ops<'a>(&self, ops: impl IntoIterator<Item = &'a OutboxOp>) {
9270 #[cfg(feature = "bench-internals")]
9271 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
9272 for op in ops {
9273 #[cfg(feature = "bench-internals")]
9274 if let Some(phase) = &mut phase {
9275 phase.unit();
9276 }
9277 let Some(table) = self.schema.table(&op.table) else {
9278 continue;
9279 };
9280 if op.upsert {
9281 let Some(values) = op.values.as_ref() else {
9282 continue;
9283 };
9284 let mut row: Row = Vec::with_capacity(table.columns.len());
9285 let mut ok = true;
9286 for column in &table.columns {
9287 match json_to_column_value(column, values.get(&column.name)) {
9288 Ok(v) => row.push(v),
9289 Err(_) => {
9290 ok = false;
9291 break;
9292 }
9293 }
9294 }
9295 if ok {
9296 let _ = self.write_row(&visible_table(&table.name), &table.name, &row, -1);
9297 }
9298 } else {
9299 let sql = format!(
9300 "DELETE FROM {} WHERE {}",
9301 visible_table(&table.name),
9302 row_id_predicate(table)
9303 );
9304 let _ = self
9305 .conn
9306 .prepare_cached(&sql)
9307 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]));
9308 }
9309 }
9310 }
9311
9312 fn exec(&self, sql: &str) {
9313 let _ = self.conn.execute_batch(sql);
9314 }
9315
9316 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
9319 if self.realtime_connected {
9320 return Ok(());
9321 }
9322 transport
9323 .realtime_connect_for_client(&self.client_id)
9324 .map_err(|e| format!("{}: {}", e.code, e.message))?;
9325 self.realtime_connected = true;
9326 Ok(())
9327 }
9328
9329 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
9330 if !self.realtime_connected {
9331 return;
9332 }
9333 let _ = transport.realtime_close();
9334 self.realtime_connected = false;
9335 self.presence.clear(); }
9337
9338 pub fn set_presence(
9344 &mut self,
9345 transport: &mut dyn Transport,
9346 scope_key: &str,
9347 doc: Option<&Value>,
9348 ) -> Result<(), String> {
9349 if !self.realtime_connected {
9350 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
9351 }
9352 let text = encode_presence_publish(scope_key, doc);
9353 transport
9354 .realtime_send(&text)
9355 .map_err(|e| format!("{}: {}", e.code, e.message))
9356 }
9357
9358 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
9360 self.presence
9361 .get(scope_key)
9362 .map(|peers| peers.values().cloned().collect())
9363 .unwrap_or_default()
9364 }
9365
9366 fn apply_presence(
9368 &mut self,
9369 scope_key: String,
9370 kind: Option<PresenceKind>,
9371 actor_id: Option<String>,
9372 client_id: Option<String>,
9373 doc: Option<Value>,
9374 error: Option<String>,
9375 ) {
9376 if error.is_some() {
9379 return;
9380 }
9381 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
9382 return;
9383 };
9384 let peer_key = format!("{actor_id} {client_id}");
9385 match kind {
9386 PresenceKind::Leave => {
9387 if let Some(peers) = self.presence.get_mut(&scope_key) {
9388 peers.remove(&peer_key);
9389 if peers.is_empty() {
9390 self.presence.remove(&scope_key);
9391 }
9392 }
9393 }
9394 _ => {
9395 let doc = match doc {
9396 Some(Value::Object(_)) => doc.unwrap(),
9397 _ => return,
9398 };
9399 self.presence.entry(scope_key).or_default().insert(
9400 peer_key,
9401 PresencePeer {
9402 actor_id,
9403 client_id,
9404 doc,
9405 },
9406 );
9407 }
9408 }
9409 }
9410
9411 pub fn on_realtime_text(&mut self, text: &str) {
9413 match parse_control(text) {
9414 Ok(ControlMessage::Hello { requires_sync, .. }) => {
9415 if requires_sync {
9416 self.set_sync_needed(true, true);
9418 }
9419 }
9420 Ok(ControlMessage::Presence {
9421 scope_key,
9422 kind,
9423 actor_id,
9424 client_id,
9425 doc,
9426 error,
9427 ..
9428 }) => {
9429 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
9430 }
9431 Ok(ControlMessage::Wake { .. }) => {
9432 self.set_sync_needed(true, true);
9434 }
9435 _ => {}
9436 }
9437 }
9438
9439 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
9442 if self.stopped {
9443 return;
9444 }
9445 #[cfg(feature = "bench-internals")]
9446 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
9447 let message = match decode_message(bytes) {
9448 Ok(m) if m.msg_kind == MsgKind::Response => m,
9449 _ => {
9450 self.set_sync_needed(true, true);
9451 return;
9452 }
9453 };
9454 #[cfg(feature = "bench-internals")]
9455 drop(decode_phase);
9456 let mut frames = message.frames.into_iter();
9457 let mut applied_cursor: Option<i64> = None;
9458 let mut any_covered = false;
9459 let mut dropped = false;
9460 while let Some(frame) = frames.next() {
9461 let Frame::SubStart {
9462 id,
9463 status,
9464 effective_scopes,
9465 ..
9466 } = frame
9467 else {
9468 continue;
9469 };
9470 let mut body = Vec::new();
9471 let mut next_cursor: Option<i64> = None;
9472 for inner in frames.by_ref() {
9473 match inner {
9474 Frame::SubEnd {
9475 next_cursor: nc, ..
9476 } => {
9477 next_cursor = Some(nc);
9478 break;
9479 }
9480 Frame::Unknown { .. } => {}
9481 other => body.push(other),
9482 }
9483 }
9484 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
9485 dropped = true;
9486 continue;
9487 };
9488 let sub = &self.subs[sub_index];
9489 if status != SubStatus::Active
9492 || sub.state != SubState::Active
9493 || sub.bootstrap_state.is_some()
9494 || !sub.synced_once
9495 {
9496 dropped = true;
9497 continue;
9498 }
9499 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
9500 any_covered = true;
9502 continue;
9503 }
9504 let previous_effective = self.subs[sub_index].effective.clone();
9505 let previous_cursor = self.subs[sub_index].cursor;
9506 self.subs[sub_index].effective = Some(effective_scopes);
9507 let mut failed = false;
9508 for inner in body {
9509 match inner {
9510 Frame::Commit {
9511 tables, changes, ..
9512 } => {
9513 if self.apply_commit_frame(&tables, &changes).is_err() {
9514 failed = true;
9515 break;
9516 }
9517 }
9518 _ => {
9519 failed = true;
9520 break;
9521 }
9522 }
9523 }
9524 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
9525 self.apply_sub_end(sub_index, cursor, None)
9526 .ok()
9527 .map(|()| cursor)
9528 });
9529 let Some(next_cursor) = committed else {
9530 self.subs[sub_index].effective = previous_effective;
9531 self.subs[sub_index].cursor = previous_cursor;
9532 dropped = true;
9533 continue;
9534 };
9535 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
9536 }
9537 if let Some(cursor) = applied_cursor {
9538 self.reconcile_blob_refcounts(false);
9539 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
9541 let _ = transport.realtime_send(&ack);
9542 } else if !any_covered || dropped {
9543 self.set_sync_needed(true, true);
9545 }
9546 }
9547
9548 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
9552 if !self.realtime_connected {
9553 return;
9554 }
9555 let floor = self
9556 .subs
9557 .iter()
9558 .filter(|s| {
9559 s.state == SubState::Active
9560 && s.bootstrap_state.is_none()
9561 && s.synced_once
9562 && s.cursor >= 0
9563 })
9564 .map(|s| s.cursor)
9565 .min();
9566 if let Some(cursor) = floor {
9567 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
9568 let _ = transport.realtime_send(&ack);
9569 }
9570 }
9571}