1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12#[cfg(test)]
13use std::cell::Cell;
14use std::cell::RefCell;
15use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
16
17use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
18use rusqlite::{Connection, OpenFlags, OptionalExtension};
19use serde::{Deserialize, Serialize};
20use serde_json::{json, Map, Value};
21use sha2::{Digest, Sha256};
22use ssp2::decode::WIRE_VERSION;
23use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
24use ssp2::primitives::RawJson;
25use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
26use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
27
28use crate::api::{
29 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
30 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
31 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
32 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
33 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
34 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
35 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
36 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
37 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
38 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
39 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
40 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
41 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
42 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
43 MAX_DIAGNOSTIC_QUERY_FAILURES,
44};
45use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
46#[cfg(feature = "bench-internals")]
47use crate::bench::{Phase, Recorder};
48use crate::previous_version::{
49 capture_previous_version_from_replica, first_incompatibility,
50 reconcile_previous_version_at_boot, set_local_schema_descriptor,
51 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
52 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
53};
54use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
55use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
56use crate::values::{
57 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
58 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
59 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
60 sort_scope_map,
61};
62
63const DEFAULT_ACCEPT: u8 = 0b0111;
67const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
68const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
69const ACCEPT_SQLITE: u8 = 1 << 2;
70const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
71const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
72
73fn recommended_action_of(details: &Option<Value>) -> Option<String> {
75 details
76 .as_ref()?
77 .get("recommendedAction")?
78 .as_str()
79 .map(str::to_owned)
80}
81
82fn coalesced_delete_keys(
90 frames: &[Frame],
91 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
92) -> HashSet<(i64, String, String)> {
93 let mut sections = Vec::new();
94 let mut open: Option<(usize, String, SubStatus)> = None;
95 for (index, frame) in frames.iter().enumerate() {
96 match frame {
97 Frame::SubStart { id, status, .. } => {
98 open = Some((index, id.clone(), *status));
99 }
100 Frame::SubEnd { next_cursor, .. } => {
101 if let Some((start, id, status)) = open.take() {
102 sections.push((start, index, id, status, Some(*next_cursor)));
103 }
104 }
105 _ => {}
106 }
107 }
108 type Key = (i64, String, String);
109 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
110 let mut both_in_frame: HashSet<Key> = HashSet::new();
111 let mut frame_ordinal = 0usize;
112 for (start, end, id, status, next_cursor) in sections {
113 if !applies(&id, status, next_cursor) {
114 continue;
115 }
116 for frame in &frames[start + 1..end] {
117 let Frame::Commit {
118 commit_seq,
119 tables,
120 changes,
121 ..
122 } = frame
123 else {
124 continue;
125 };
126 frame_ordinal += 1;
127 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
128 for change in changes {
129 let Some(table) = tables.get(change.table_index as usize) else {
130 continue;
131 };
132 let key = (*commit_seq, table.clone(), change.row_id.clone());
133 let entry = ops.entry(key).or_default();
134 let local_entry = local
135 .entry((table.clone(), change.row_id.clone()))
136 .or_default();
137 match change.op {
138 Op::Upsert => {
139 entry.0.insert(frame_ordinal);
140 local_entry.0 = true;
141 }
142 Op::Delete => {
143 entry.1.insert(frame_ordinal);
144 local_entry.1 = true;
145 }
146 }
147 }
148 for ((table, row_id), (has_upsert, has_delete)) in local {
149 if has_upsert && has_delete {
150 both_in_frame.insert((*commit_seq, table, row_id));
151 }
152 }
153 }
154 }
155 ops.into_iter()
156 .filter_map(|(key, (upserts, deletes))| {
157 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
158 .then_some(key)
159 })
160 .collect()
161}
162
163pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
165const LOCAL_REVISION_KEY: &str = "localRevision";
166const CLIENT_ID_KEY: &str = "clientId";
167const LEASE_STATE_KEY: &str = "leaseState";
168const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
169pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
170const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
175const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
176const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
177const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
180const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
183const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
184const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
186 "the persisted commit carries values the current codec refuses";
187const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
189
190#[derive(Debug, Serialize, Deserialize)]
191#[serde(rename_all = "camelCase", deny_unknown_fields)]
192struct PersistedLocalDataRebootstrapReceipt {
193 version: u8,
194 retained_commits: u64,
195 reset_subscriptions: u64,
196}
197
198fn invalid_local_rebootstrap_receipt() -> String {
199 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
200}
201
202fn encode_local_rebootstrap_receipt(
203 retained_commits: usize,
204 reset_subscriptions: usize,
205) -> Result<String, String> {
206 let retained_commits =
207 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
208 let reset_subscriptions =
209 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
210 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
211 return Err(invalid_local_rebootstrap_receipt());
212 }
213 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
214 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
215 retained_commits,
216 reset_subscriptions,
217 })
218 .map_err(|_| invalid_local_rebootstrap_receipt())
219}
220
221fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
222 if value == "v1" {
224 return Ok((0, 0));
225 }
226 let receipt: PersistedLocalDataRebootstrapReceipt =
227 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
228 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
229 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
230 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
231 {
232 return Err(invalid_local_rebootstrap_receipt());
233 }
234 Ok((
235 usize::try_from(receipt.retained_commits)
236 .map_err(|_| invalid_local_rebootstrap_receipt())?,
237 usize::try_from(receipt.reset_subscriptions)
238 .map_err(|_| invalid_local_rebootstrap_receipt())?,
239 ))
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq)]
243enum SubState {
244 Active,
245 Revoked,
246 Failed,
247}
248
249#[cfg(test)]
250mod observation_tests {
251 use super::*;
252 use crate::native_transport::HostTransport;
253 use crate::values::encode_row_json;
254 use crate::BlobUploadGrant;
255 use serde_json::json;
256
257 #[test]
258 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
259 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
260 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
261 ]}]});
262 for name in [
263 "secret",
264 "missing",
265 "*",
266 "id FROM authority; DELETE FROM authority",
267 "_syncular_version",
268 ] {
269 let limits = ClientLimits {
270 authority_reads: vec![crate::api::AuthorityReadDeclaration {
271 table: "authority".into(),
272 columns: vec!["id".into(), "actor_id".into(), name.into()],
273 scopes: [("actor_id".into(), vec!["a".into()])].into(),
274 }],
275 ..Default::default()
276 };
277 let error = SyncClient::new("authority".into(), &schema, limits)
278 .err()
279 .expect("forbidden policy");
280 assert!(
281 error.starts_with("client.authority_read_forbidden:"),
282 "{error}"
283 );
284 }
285 }
286
287 #[test]
288 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
289 let path = std::env::temp_dir().join(format!(
290 "syncular-authority-snapshot-{}.db",
291 uuid::Uuid::new_v4()
292 ));
293 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
294 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
295 ]}]});
296 let limits = ClientLimits {
297 authority_reads: vec![crate::api::AuthorityReadDeclaration {
298 table: "authority".into(),
299 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
300 scopes: [("actor_id".into(), vec!["a".into()])].into(),
301 }],
302 ..Default::default()
303 };
304 let mut client =
305 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
306 .unwrap();
307 client.begin_security_preflight();
308 let update = |conn: &Connection, revision: i64| {
309 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
310 conn.execute(
311 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
312 [revision],
313 )
314 .unwrap();
315 conn.execute(
316 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
317 [revision.to_string()],
318 )
319 .unwrap();
320 conn.execute("INSERT OR REPLACE INTO _syncular_subscriptions VALUES('s','authority',?1)", [json!({"requested": {"actor_id": ["a"]}, "effectiveScopes": {"actor_id": ["a"]}, "status": "active", "cursor": revision}).to_string()]).unwrap();
321 conn.execute_batch("COMMIT").unwrap();
322 };
323 update(&client.conn, 1);
324 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
325 let writer_barrier = barrier.clone();
326 let writer_path = path.clone();
327 let writer = std::thread::spawn(move || {
328 let conn = Connection::open(writer_path).unwrap();
329 conn.busy_timeout(std::time::Duration::from_secs(5))
330 .unwrap();
331 writer_barrier.wait();
332 for revision in 2..=100 {
333 update(&conn, revision);
334 }
335 });
336 barrier.wait();
337 for _ in 0..100 {
338 let snapshot = client.authority_snapshot().unwrap();
339 let revision = snapshot["revision"]
340 .as_str()
341 .unwrap()
342 .parse::<i64>()
343 .unwrap();
344 assert_eq!(
345 snapshot["tables"][0]["rows"][0]["values"]["revision"],
346 revision
347 );
348 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
349 assert_eq!(snapshot["complete"], true);
350 }
351 writer.join().unwrap();
352 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
353 drop(client);
354 let _ = std::fs::remove_file(path);
355 }
356
357 fn client() -> SyncClient {
358 SyncClient::new(
359 "retry-test".to_owned(),
360 &json!({
361 "version": 1,
362 "tables": [{
363 "name": "tasks",
364 "primaryKey": "id",
365 "columns": [
366 { "name": "id", "type": "string", "nullable": false },
367 { "name": "project_id", "type": "string", "nullable": false }
368 ],
369 "scopes": [{ "pattern": "project:{project_id}" }]
370 }]
371 }),
372 ClientLimits::default(),
373 )
374 .expect("test client")
375 }
376
377 fn round_error(outcome: SyncOutcome) -> TransportError {
378 match outcome {
379 SyncOutcome::Failed {
380 error_code,
381 message,
382 details,
383 ..
384 } => TransportError {
385 code: error_code,
386 message,
387 details,
388 },
389 other => TransportError::new(
390 "client.failed",
391 format!("unexpected round outcome: {other:?}"),
392 ),
393 }
394 }
395
396 fn flush_blob_uploads(
399 client: &mut SyncClient,
400 transport: &mut dyn Transport,
401 ) -> Result<(), TransportError> {
402 let mut prepared = match client.prepare_sync_round(false) {
403 Ok(prepared) => prepared,
404 Err(outcome) => return Err(round_error(*outcome)),
405 };
406 loop {
407 if prepared.uploads.is_empty() {
408 client.cancel_sync_round();
409 return Ok(());
410 }
411 let completed = crate::round::test_support::drive_round(prepared, transport);
412 prepared = match client.apply_sync_round(completed) {
413 crate::AppliedSyncRound::Continue(prepared) => prepared,
414 crate::AppliedSyncRound::Complete { outcome, .. } => {
415 return match outcome {
416 SyncOutcome::Ok(_) => Ok(()),
417 other => Err(round_error(other)),
418 };
419 }
420 };
421 }
422 }
423
424 #[test]
425 fn subscribe_many_registers_all_or_nothing() {
426 let request = |id: &str, project: &str| SubscriptionRequest {
427 id: id.to_owned(),
428 table: "tasks".to_owned(),
429 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
430 params: None,
431 };
432 let mut client = client();
433 client
434 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
435 .unwrap();
436 let persisted = |client: &SyncClient| -> i64 {
437 client
438 .conn
439 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
440 row.get(0)
441 })
442 .unwrap()
443 };
444 assert_eq!(persisted(&client), 2);
445 let error = client
447 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
448 .unwrap_err();
449 assert!(error.starts_with("client.subscription_intent_mismatch:"));
450 assert!(client.subscription_state("c").is_none());
451 assert_eq!(persisted(&client), 2);
452 assert!(client.conn.is_autocommit());
453 }
454
455 #[cfg(feature = "e2ee")]
456 #[test]
457 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
458 let schema = json!({
459 "version": 1,
460 "tables": [{
461 "name": "secrets",
462 "primaryKey": "id",
463 "columns": [
464 { "name": "id", "type": "string", "nullable": false },
465 { "name": "encryption_key_id", "type": "string", "nullable": true },
466 { "name": "note", "type": "bytes", "nullable": true,
467 "encrypted": true, "declaredType": "string" }
468 ],
469 "scopes": []
470 }]
471 });
472 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
473 .expect("test client");
474 let mut config = crate::values::EncryptionConfig::default();
475 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
476 config
477 .key_id_columns
478 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
479 client.set_encryption(config);
480 client
483 .write_base_row(
484 "secrets",
485 &vec![Some(ColumnValue::String("ghost".into())), None, None],
486 1,
487 )
488 .unwrap();
489 client.rebuild_overlay().expect("rebuild overlay");
490 let commit_id = client
491 .patch(
492 "secrets",
493 "ghost",
494 Map::from_iter([("note".to_owned(), json!("x"))]),
495 None,
496 )
497 .expect("patch records without an author-time encrypt failure");
498 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
499 assert!(client.drop_unencodable_outbox(true, None).unwrap());
501 assert!(client.pending_commit_ids().is_empty());
502 let rejections = client.rejections();
503 assert_eq!(rejections.len(), 1);
504 assert_eq!(rejections[0].code, "client.encrypt_failed");
505 assert_eq!(rejections[0].client_commit_id, commit_id);
506 assert_eq!(
507 client
508 .commit_outcome(&commit_id)
509 .unwrap()
510 .map(|outcome| outcome.status),
511 Some(CommitOutcomeStatus::Rejected)
512 );
513 }
514
515 #[test]
516 fn patch_scope_read_preserves_sqlite_failure() {
517 let mut client = client();
518 client
519 .conn
520 .execute_batch(
521 "DROP TABLE tasks;
522 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
523 )
524 .unwrap();
525 let error = client
526 .patch(
527 "tasks",
528 "t1",
529 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
530 None,
531 )
532 .expect_err("the scope read must fail");
533 assert_ne!(error.code, "sync.invalid_request");
534 assert_eq!(error.code, "client.failed");
535 assert!(error.details.as_ref().unwrap()["legacyCause"]
536 .as_str()
537 .unwrap()
538 .contains("integer overflow"));
539 assert!(client.pending_commit_ids().is_empty());
540 assert!(client.conn.is_autocommit());
541 }
542
543 #[test]
544 fn patch_with_a_scope_column_matches_the_stored_local_row() {
545 let mut client = client();
546 client.create_synced_tables().unwrap();
547 client
548 .conn
549 .execute(
550 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
551 [],
552 )
553 .unwrap();
554
555 client
558 .patch(
559 "tasks",
560 "t1",
561 Map::from_iter([
562 ("project_id".to_owned(), json!("p1")),
563 ("id".to_owned(), json!("t1")),
564 ]),
565 None,
566 )
567 .expect("an equal scope column is a no-op");
568 let values = client.outbox[0].ops[0]
569 .values
570 .clone()
571 .expect("the patch records values");
572 assert_eq!(values.get("id"), Some(&json!("t1")));
573 assert!(!values.contains_key("project_id"));
574
575 let differing = client.patch(
577 "tasks",
578 "t1",
579 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
580 None,
581 );
582 let differing = differing.expect_err("a differing scope column is rejected");
583 assert_eq!(differing.code, "sync.invalid_request");
584 assert_eq!(differing.message, "the authoring request is invalid");
585 assert!(
586 differing
587 .details
588 .as_ref()
589 .and_then(|details| details["legacyCause"].as_str())
590 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
591 "{differing:?}"
592 );
593
594 let absent = client.patch(
597 "tasks",
598 "ghost",
599 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
600 None,
601 );
602 let absent = absent.expect_err("an absent local row is rejected");
603 assert_eq!(absent.code, "sync.row_missing");
604 assert!(absent.message.contains("requires a local row"));
605 assert_eq!(client.pending_commit_ids().len(), 1);
606 }
607
608 #[test]
609 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
610 let schema = json!({
611 "version": 1,
612 "tables": [
613 {
614 "name": "tenants",
615 "primaryKey": "tenant_id",
616 "columns": [
617 { "name": "tenant_id", "type": "string", "nullable": false },
618 { "name": "body", "type": "string", "nullable": false }
619 ],
620 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
621 },
622 {
623 "name": "buckets",
624 "primaryKey": "id",
625 "columns": [
626 { "name": "id", "type": "string", "nullable": false },
627 { "name": "bucket", "type": "float", "nullable": false },
628 { "name": "body", "type": "string", "nullable": false }
629 ],
630 "scopes": [{ "pattern": "bucket:{bucket}" }]
631 }
632 ]
633 });
634 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
635 .expect("test client");
636 client.create_synced_tables().unwrap();
637
638 client
640 .conn
641 .execute(
642 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
643 [],
644 )
645 .unwrap();
646 client
647 .patch(
648 "tenants",
649 "t1",
650 Map::from_iter([
651 ("tenant_id".to_owned(), json!("t1")),
652 ("body".to_owned(), json!("created")),
653 ]),
654 None,
655 )
656 .expect("the primary-key scope column is proven equal by construction");
657
658 client
660 .conn
661 .execute(
662 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
663 [],
664 )
665 .unwrap();
666 client
667 .patch(
668 "buckets",
669 "b1",
670 Map::from_iter([
671 ("bucket".to_owned(), json!(2)),
672 ("body".to_owned(), json!("coerced")),
673 ]),
674 None,
675 )
676 .expect("the stored REAL value coerces from a supplied integer");
677 let values = client.outbox[1].ops[0]
678 .values
679 .clone()
680 .expect("the patch records values");
681 assert!(!values.contains_key("bucket"));
682
683 let differing = client.patch(
685 "buckets",
686 "b1",
687 Map::from_iter([("bucket".to_owned(), json!(3))]),
688 None,
689 );
690 let differing = differing.expect_err("a differing scope value is rejected");
691 assert_eq!(differing.code, "sync.invalid_request");
692 assert_eq!(differing.message, "the authoring request is invalid");
693 assert!(
694 differing
695 .details
696 .as_ref()
697 .and_then(|details| details["legacyCause"].as_str())
698 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
699 "{differing:?}"
700 );
701 }
702
703 #[cfg(feature = "e2ee")]
704 #[test]
705 fn stored_key_fallback_resolves_an_integer_primary_key() {
706 let schema = json!({
707 "version": 1,
708 "tables": [{
709 "name": "patients",
710 "primaryKey": "id",
711 "columns": [
712 { "name": "id", "type": "integer", "nullable": false },
713 { "name": "encryption_key_id", "type": "string", "nullable": true },
714 { "name": "note", "type": "bytes", "nullable": true,
715 "encrypted": true, "declaredType": "string" }
716 ],
717 "scopes": []
718 }]
719 });
720 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
721 .expect("test client");
722 client.create_synced_tables().unwrap();
723 client
724 .conn
725 .execute(
726 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
727 VALUES (5, 'k1', NULL, 1)",
728 [],
729 )
730 .unwrap();
731 let mut config = crate::values::EncryptionConfig::default();
732 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
733 config
734 .key_id_columns
735 .insert("patients".to_owned(), "encryption_key_id".to_owned());
736 client.set_encryption(config);
737 let table = client.schema.table("patients").unwrap();
738 let fallback = client
739 .stored_key_fallback(table, "5")
740 .expect("the stored integer key resolves the fallback");
741 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
742 }
743
744 #[test]
745 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
746 let schema = json!({
747 "version": 1,
748 "tables": [{
749 "name": "counters",
750 "primaryKey": "id",
751 "columns": [
752 { "name": "id", "type": "integer", "nullable": false },
753 { "name": "title", "type": "string", "nullable": false }
754 ],
755 "scopes": []
756 }]
757 });
758 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
759 .expect("test client");
760 client
761 .write_base_row(
762 "counters",
763 &vec![
764 Some(ColumnValue::Integer(42)),
765 Some(ColumnValue::String("before".into())),
766 ],
767 1,
768 )
769 .unwrap();
770 client.rebuild_overlay().expect("rebuild overlay");
771 client
772 .patch(
773 "counters",
774 "42",
775 Map::from_iter([
776 ("id".to_owned(), json!(42)),
777 ("title".to_owned(), json!("after")),
778 ]),
779 None,
780 )
781 .expect("an integer key that renders as the rowId is not a change");
782 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
783 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
784 for row_id in ["042", "+42", "42.0"] {
785 let error = client
786 .patch(
787 "counters",
788 row_id,
789 Map::from_iter([("title".to_owned(), json!("x"))]),
790 None,
791 )
792 .expect_err("a non-canonical integer rowId names no row");
793 assert_eq!(error.code, "sync.row_missing", "{row_id}");
794 }
795 }
796
797 #[test]
798 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
799 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
800 {"name":"id","type":"string","nullable":false},
801 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
802 for state in ["absent", "cached", "pinned"] {
803 let path = std::env::temp_dir().join(format!(
804 "syncular-blob-stage-{}.sqlite",
805 uuid::Uuid::new_v4()
806 ));
807 let mut client = SyncClient::open_path(
808 "stage-test".into(),
809 &schema,
810 Default::default(),
811 path.to_str().unwrap(),
812 )
813 .unwrap();
814 let bytes = b"atomic stage";
815 if state != "absent" {
816 client
817 .upload_blob(bytes, Some("text/plain".into()), None)
818 .unwrap();
819 if state == "cached" {
820 client
821 .conn
822 .execute("DELETE FROM _syncular_blob_uploads", [])
823 .unwrap();
824 }
825 }
826 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
827 client
828 .conn
829 .execute_batch(
830 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
831 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
832 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
833 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
834 )
835 .unwrap();
836 let error = client
837 .upload_blob(bytes, Some("application/octet-stream".into()), None)
838 .unwrap_err();
839 assert!(error.contains("injected stage failure"), "{state}: {error}");
840 assert!(client.conn.is_autocommit());
841 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
842 drop(client);
843 let mut reopened = SyncClient::open_path(
844 "stage-test".into(),
845 &schema,
846 Default::default(),
847 path.to_str().unwrap(),
848 )
849 .unwrap();
850 let durable = reopened
851 .query("SELECT * FROM _syncular_blobs", &[])
852 .unwrap();
853 reopened
854 .conn
855 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
856 .unwrap();
857 let retried = reopened.upload_blob(bytes, None, None).unwrap();
858 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
859 assert_eq!(
860 reopened
861 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
862 .unwrap(),
863 vec![Map::from_iter([(
864 "blob_id".into(),
865 Value::from(blob_id_for(bytes)),
866 )])]
867 );
868 drop(reopened);
869 std::fs::remove_file(&path).unwrap();
870 assert_eq!(durable, bodies, "{state}: reopened body state");
871 assert_eq!(immediate, bodies, "{state}: immediate body state");
872 }
873 }
874
875 #[test]
876 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
877 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
878 {"name":"id","type":"string","nullable":false},
879 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
880 for fault in [
881 "missing",
882 "body-type",
883 "length",
884 "hash",
885 "pin-metadata",
886 "read",
887 "pin-delete",
888 ] {
889 let path = std::env::temp_dir().join(format!(
890 "syncular-blob-upload-{}.sqlite",
891 uuid::Uuid::new_v4()
892 ));
893 let mut client = SyncClient::open_path(
894 "upload-test".into(),
895 &schema,
896 Default::default(),
897 path.to_str().unwrap(),
898 )
899 .unwrap();
900 let bytes = b"durable upload";
901 let ref_value = client
902 .upload_blob(bytes, Some("text/plain".into()), None)
903 .unwrap();
904 let commit = client
905 .mutate(vec![Mutation::Upsert {
906 table: "attachments".into(),
907 values: Map::from_iter([
908 ("id".into(), Value::from("pending")),
909 (
910 "file".into(),
911 Value::from(format!(
912 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
913 blob_id_for(bytes),
914 bytes.len()
915 )),
916 ),
917 ]),
918 base_version: None,
919 }])
920 .unwrap();
921 client
922 .conn
923 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
924 .unwrap();
925 client.conn.execute_batch(match fault {
926 "missing" => "DELETE FROM _syncular_blobs",
927 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
928 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
929 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
930 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
931 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
932 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
933 }).unwrap();
934 let state = if fault == "read" {
935 Vec::new()
936 } else {
937 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
938 };
939 client.set_meta(LOG_EPOCH_KEY, "epoch");
940 let mut transport = CountingRealtimeTransport::default();
941 let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
942 assert_eq!(
943 error.code,
944 if matches!(fault, "read" | "pin-delete") {
945 "client.failed"
946 } else {
947 "sync.local_corrupt"
948 },
949 "{fault}"
950 );
951 assert_eq!(
952 transport.blob_uploads.len(),
953 usize::from(fault == "pin-delete"),
954 "{fault}: corrupt bytes must never be uploaded"
955 );
956 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
957 if fault != "read" {
958 assert_eq!(
959 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
960 state
961 );
962 } else {
963 client
964 .conn
965 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
966 .unwrap();
967 }
968 drop(client);
969 let mut reopened = SyncClient::open_path(
970 "upload-test".into(),
971 &schema,
972 Default::default(),
973 path.to_str().unwrap(),
974 )
975 .unwrap();
976 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
977 if fault != "read" {
978 assert_eq!(
979 reopened
980 .query("SELECT * FROM _syncular_blobs", &[])
981 .unwrap(),
982 state
983 );
984 }
985 if fault == "pin-delete" {
986 reopened
987 .conn
988 .execute_batch("DROP TRIGGER fail_pin_delete")
989 .unwrap();
990 }
991 if fault == "pin-metadata" {
992 reopened
993 .conn
994 .execute(
995 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
996 [],
997 )
998 .unwrap();
999 }
1000 reopened
1001 .conn
1002 .execute_batch(
1003 "DELETE FROM _syncular_blobs;
1004 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1005 )
1006 .unwrap();
1007 transport.blob_uploads.clear();
1008 flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1009 assert_eq!(
1010 transport.blob_uploads,
1011 [(blob_id_for(bytes), bytes.to_vec())]
1012 );
1013 assert_eq!(
1014 reopened
1015 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1016 .unwrap(),
1017 Vec::<Map<String, Value>>::new()
1018 );
1019 assert_eq!(reopened.pending_commit_ids(), [commit]);
1020 assert_eq!(
1021 reopened
1022 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1023 .unwrap()
1024 .unwrap()
1025 .bytes,
1026 bytes
1027 );
1028 drop(reopened);
1029 std::fs::remove_file(path).unwrap();
1030 }
1031 }
1032
1033 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1037 let mut client = client();
1038 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1039 let mut response = Message {
1040 wire_version: WIRE_VERSION,
1041 msg_kind: MsgKind::Response,
1042 frames: vec![Frame::RespHeader {
1043 required_schema_version: None,
1044 latest_schema_version: None,
1045 log_epoch: Some("epoch-1".into()),
1046 reset_required: Some(false),
1047 }],
1048 };
1049 let mut seq = 0;
1050 for section in 0..sections {
1051 let project = format!("p{section}");
1052 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1053 client
1054 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1055 .unwrap();
1056 let sub = client.subs.last_mut().unwrap();
1057 sub.cursor = 0;
1058 sub.synced_once = true;
1059 sub.effective = Some(scopes.clone());
1060 let sub = sub.clone();
1061 client.persist_sub(&sub).unwrap();
1062 response.frames.push(Frame::SubStart {
1063 id: format!("s{section}"),
1064 status: SubStatus::Active,
1065 reason_code: "".into(),
1066 effective_scopes: scopes,
1067 bootstrap: false,
1068 });
1069 for _ in 0..commits {
1070 seq += 1;
1071 let changes = (0..rows)
1072 .map(|row| {
1073 let id = format!("{project}-{seq}-{row}");
1074 let values = Map::from_iter([
1075 ("id".into(), Value::from(id.clone())),
1076 ("project_id".into(), Value::from(project.clone())),
1077 ]);
1078 ssp2::model::Change {
1079 table_index: 0,
1080 row_id: id.clone(),
1081 op: Op::Upsert,
1082 row_version: Some(seq),
1083 scopes: vec![("project_id".into(), project.clone())],
1084 row: Some(
1085 encode_row_json(
1086 client.schema.table("tasks").unwrap(),
1087 &id,
1088 &values,
1089 &client.encryption,
1090 )
1091 .unwrap(),
1092 ),
1093 }
1094 })
1095 .collect();
1096 response.frames.push(Frame::Commit {
1097 commit_seq: seq,
1098 actor_id: "writer".into(),
1099 created_at_ms: 1,
1100 tables: vec!["tasks".into()],
1101 changes,
1102 });
1103 }
1104 response.frames.push(Frame::SubEnd {
1105 next_cursor: seq,
1106 bootstrap_state: None,
1107 });
1108 }
1109 client.drain_change_batches();
1110 (client, response)
1111 }
1112
1113 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1114 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1115 let counter = commits.clone();
1116 client
1117 .conn
1118 .commit_hook(Some(move || {
1119 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1120 false
1121 }))
1122 .expect("commit hook");
1123 commits
1124 }
1125
1126 #[test]
1127 fn a_response_of_many_small_sections_commits_once() {
1128 for realtime in [false, true] {
1129 let (mut client, response) = coalesced_fixture(70, 1, 1);
1130 client.realtime_state = RealtimeState::Connected;
1131 client.realtime_reason_code = None;
1132 let mut transport = CountingRealtimeTransport::default();
1133 let (_, meta) = client.build_request(false).unwrap();
1134 let commits = count_commits(&client);
1135 if realtime {
1136 client.on_realtime_binary(&mut transport, &encode_message(&response));
1137 } else {
1138 assert!(matches!(
1139 client.process_response(&mut transport, response, &meta),
1140 SyncOutcome::Ok(_)
1141 ));
1142 }
1143 assert_eq!(
1144 commits.load(std::sync::atomic::Ordering::SeqCst),
1145 1,
1146 "{realtime}"
1147 );
1148 assert!(client.conn.is_autocommit());
1149 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1150 let revisions = client
1152 .drain_change_batches()
1153 .into_iter()
1154 .filter(|batch| !batch.tables.is_empty())
1155 .map(|batch| batch.revision)
1156 .collect::<Vec<_>>();
1157 assert_eq!(
1158 revisions,
1159 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1160 );
1161 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1162 assert_eq!(
1164 transport
1165 .messages
1166 .iter()
1167 .filter(|m| m.contains("\"ack\""))
1168 .count(),
1169 1,
1170 "{realtime}"
1171 );
1172 }
1173 }
1174
1175 #[test]
1176 fn a_response_past_the_row_bound_commits_per_bound() {
1177 let (mut client, response) = coalesced_fixture(1, 3, 600);
1179 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1180 let (_, meta) = client.build_request(false).unwrap();
1181 let commits = count_commits(&client);
1182 assert!(matches!(
1183 client.process_response(&mut transport, response, &meta),
1184 SyncOutcome::Ok(_)
1185 ));
1186 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1187 assert_eq!(
1188 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1189 1800
1190 );
1191 assert_eq!(client.subs[0].cursor, 3);
1192 }
1193
1194 #[test]
1195 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1196 let (mut client, response) = coalesced_fixture(2, 2, 600);
1200 client
1201 .conn
1202 .execute_batch(
1203 "PRAGMA foreign_keys = ON; CREATE TABLE fail_parent(id INTEGER PRIMARY KEY); CREATE TABLE fail_child(id INTEGER REFERENCES fail_parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_commit AFTER INSERT ON _syncular_base_tasks WHEN NEW.id = 'p0-1-0' BEGIN INSERT INTO fail_child VALUES(1); END",
1204 )
1205 .unwrap();
1206 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1207 let (_, meta) = client.build_request(false).unwrap();
1208 let subs = client.subs.clone();
1209 let revision = client.local_revision();
1210 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1211 assert!(
1212 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1213 "{outcome:?}"
1214 );
1215 assert!(client.conn.is_autocommit());
1216 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1217 assert_eq!(client.local_revision(), revision);
1218 assert!(client
1219 .drain_change_batches()
1220 .iter()
1221 .all(|batch| batch.tables.is_empty()));
1222 assert!(client
1223 .query("SELECT id FROM tasks", &[])
1224 .unwrap()
1225 .is_empty());
1226 assert_eq!(
1227 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1228 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1229 );
1230 client
1232 .conn
1233 .execute_batch("DROP TRIGGER fail_commit")
1234 .unwrap();
1235 let (_, meta) = client.build_request(false).unwrap();
1236 assert!(matches!(
1237 client.process_response(&mut transport, response, &meta),
1238 SyncOutcome::Ok(_)
1239 ));
1240 assert_eq!(
1241 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1242 2400
1243 );
1244 }
1245
1246 #[test]
1247 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1248 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1249 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1250 "scopes":[{"pattern":"project:{project_id}"}]}]});
1251 for realtime in [false, true] {
1252 for fault in [
1253 "none",
1254 "row",
1255 "revision",
1256 "commit",
1257 "cursor",
1258 "error",
1259 "missing-end",
1260 ] {
1261 if realtime && fault == "missing-end" {
1262 continue;
1263 }
1264 let path = std::env::temp_dir()
1265 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1266 let mut client = SyncClient::open_path(
1267 "frame-test".into(),
1268 &schema,
1269 Default::default(),
1270 path.to_str().unwrap(),
1271 )
1272 .unwrap();
1273 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1274 client
1275 .subscribe(
1276 "tasks".into(),
1277 "tasks".into(),
1278 vec![("project_id".into(), vec!["p1".into()])],
1279 None,
1280 )
1281 .unwrap();
1282 client.subs[0].cursor = 0;
1283 client.subs[0].synced_once = true;
1284 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1285 client.persist_sub(&client.subs[0]).unwrap();
1286 client.realtime_state = RealtimeState::Connected;
1287 client.realtime_reason_code = None;
1288 client.drain_change_batches();
1289 let mut transport = CountingRealtimeTransport::default();
1290 let (_, meta) = client.build_request(false).unwrap();
1291 let mut response = Message {
1292 wire_version: WIRE_VERSION,
1293 msg_kind: MsgKind::Response,
1294 frames: vec![
1295 Frame::RespHeader {
1296 required_schema_version: None,
1297 latest_schema_version: None,
1298 log_epoch: Some("epoch-1".into()),
1299 reset_required: Some(false),
1300 },
1301 Frame::SubStart {
1302 id: "tasks".into(),
1303 status: SubStatus::Active,
1304 reason_code: "".into(),
1305 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1306 bootstrap: false,
1307 },
1308 ],
1309 };
1310 for (index, id) in ["first", "second"].iter().enumerate() {
1311 let values = Map::from_iter([
1312 ("id".into(), Value::from(*id)),
1313 ("project_id".into(), Value::from("p1")),
1314 ]);
1315 response.frames.push(Frame::Commit {
1316 commit_seq: index as i64 + 1,
1317 actor_id: "writer".into(),
1318 created_at_ms: 1,
1319 tables: vec!["tasks".into()],
1320 changes: vec![ssp2::model::Change {
1321 table_index: 0,
1322 row_id: (*id).into(),
1323 op: Op::Upsert,
1324 row_version: Some(index as i64 + 1),
1325 scopes: vec![("project_id".into(), "p1".into())],
1326 row: Some(
1327 encode_row_json(
1328 client.schema.table("tasks").unwrap(),
1329 id,
1330 &values,
1331 &client.encryption,
1332 )
1333 .unwrap(),
1334 ),
1335 }],
1336 });
1337 }
1338 response.frames.push(Frame::SubEnd {
1339 next_cursor: 2,
1340 bootstrap_state: None,
1341 });
1342 let valid = response.clone();
1343 match fault {
1344 "row" => {
1345 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1346 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1347 },
1348 "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(),
1349 "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(),
1350 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1351 "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});},
1352 "missing-end" => {response.frames.truncate(3);},
1353 _ => {}
1354 }
1355 if realtime {
1356 client.on_realtime_binary(&mut transport, &encode_message(&response));
1357 } else {
1358 assert_eq!(
1359 matches!(
1360 client.process_response(&mut transport, response, &meta),
1361 SyncOutcome::Ok(_)
1362 ),
1363 fault == "none",
1364 "{fault}"
1365 );
1366 }
1367 assert!(
1368 client.conn.is_autocommit(),
1369 "no open transaction after {realtime}/{fault}"
1370 );
1371 let count = match fault {
1375 "none" | "cursor" => 2,
1376 "commit" => 0,
1377 _ => 1,
1378 };
1379 let expected_revision = count + u64::from(realtime && fault != "none");
1380 assert_eq!(
1381 client.local_revision(),
1382 expected_revision,
1383 "{realtime}/{fault}"
1384 );
1385 let batches = client.drain_change_batches();
1386 assert_eq!(
1387 batches.len(),
1388 expected_revision as usize,
1389 "{realtime}/{fault}"
1390 );
1391 let row_batches: Vec<_> = batches
1392 .iter()
1393 .filter(|batch| !batch.tables.is_empty())
1394 .collect();
1395 assert_eq!(row_batches.len(), count as usize);
1396 assert_eq!(
1397 row_batches
1398 .iter()
1399 .map(|b| b.revision.clone())
1400 .collect::<Vec<_>>(),
1401 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1402 );
1403 for table in ["tasks", "_syncular_base_tasks"] {
1404 assert_eq!(
1405 client
1406 .conn
1407 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1408 .get::<_, u64>(0))
1409 .unwrap(),
1410 count,
1411 "{realtime}/{fault}/{table}"
1412 );
1413 }
1414 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1415 assert_eq!(
1416 transport
1417 .messages
1418 .iter()
1419 .filter(|m| m.contains("\"ack\""))
1420 .count(),
1421 usize::from(fault == "none")
1422 );
1423 if matches!(fault, "revision" | "commit" | "cursor") {
1424 client
1425 .conn
1426 .execute_batch("DROP TRIGGER fail_frame")
1427 .unwrap();
1428 }
1429 drop(client);
1430 let mut reopened = SyncClient::open_path(
1431 "frame-test".into(),
1432 &schema,
1433 Default::default(),
1434 path.to_str().unwrap(),
1435 )
1436 .unwrap();
1437 assert_eq!(reopened.local_revision(), expected_revision);
1438 assert_eq!(
1439 reopened
1440 .query("SELECT id FROM tasks ORDER BY id", &[])
1441 .unwrap()
1442 .len(),
1443 count as usize
1444 );
1445 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1446 let (_, meta) = reopened.build_request(false).unwrap();
1447 assert!(matches!(
1448 reopened.process_response(&mut transport, valid, &meta),
1449 SyncOutcome::Ok(_)
1450 ));
1451 assert_eq!(
1452 reopened
1453 .query("SELECT id FROM tasks ORDER BY id", &[])
1454 .unwrap()
1455 .len(),
1456 2
1457 );
1458 assert_eq!(reopened.subs[0].cursor, 2);
1459 drop(reopened);
1460 std::fs::remove_file(path).unwrap();
1461 }
1462 }
1463 }
1464
1465 #[test]
1466 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1467 for status in [SubStatus::Reset, SubStatus::Revoked] {
1468 for fault in ["subscription", "revision"] {
1469 let mut client = client();
1470 client
1471 .subscribe(
1472 "tasks".into(),
1473 "tasks".into(),
1474 vec![("project_id".into(), vec!["p1".into()])],
1475 None,
1476 )
1477 .unwrap();
1478 client.subs[0].cursor = 41;
1479 client.subs[0].synced_once = true;
1480 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1481 client.persist_sub(&client.subs[0]).unwrap();
1482 client
1483 .mutate(vec![Mutation::Upsert {
1484 table: "tasks".into(),
1485 values: Map::from_iter([
1486 ("id".into(), Value::from("pending")),
1487 ("project_id".into(), Value::from("p1")),
1488 ]),
1489 base_version: None,
1490 }])
1491 .unwrap();
1492 let ids = client.pending_commit_ids();
1493 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1494 let revision = client.local_revision();
1495 client.drain_change_batches();
1496 let (_, meta) = client.build_request(false).unwrap();
1497 client.conn.execute_batch(if fault == "subscription" {
1498 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1499 } else {
1500 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1501 }).unwrap();
1502 let mut transport = CountingRealtimeTransport::default();
1503 let mut report = SyncReport::default();
1504 if status == SubStatus::Reset {
1506 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1507 }
1508 let result = client.process_section(
1509 &mut transport,
1510 "tasks",
1511 status,
1512 "sync.scope_revoked",
1513 vec![],
1514 vec![],
1515 Some((42, None)),
1516 &meta,
1517 &mut report,
1518 &HashSet::new(),
1519 );
1520 assert!(result.is_err(), "{status:?}/{fault}");
1521 assert!(client.conn.is_autocommit());
1522 assert_eq!(client.subs[0].cursor, 41);
1523 assert_eq!(client.subs[0].state, SubState::Active);
1524 assert_eq!(client.pending_commit_ids(), ids);
1525 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1526 assert!(client.rejections.is_empty());
1527 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1528 assert_eq!(client.local_revision(), revision);
1529 assert!(client.drain_change_batches().is_empty());
1530 assert!(report.revoked.is_empty() && report.resets.is_empty());
1531 }
1532 }
1533 }
1534
1535 #[test]
1536 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1537 let mut client = client();
1538 client
1539 .subscribe(
1540 "tasks".into(),
1541 "tasks".into(),
1542 vec![("project_id".into(), vec!["p1".into()])],
1543 None,
1544 )
1545 .unwrap();
1546 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1547 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1548 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1549 let row = |id: &str| ssp2::segment::SegmentRow {
1550 server_version: 1,
1551 values: vec![
1552 Some(ColumnValue::String(id.into())),
1553 Some(ColumnValue::String("p1".into())),
1554 ],
1555 };
1556 let segment = RowsSegment {
1557 table: "tasks".into(),
1558 schema_version: 1,
1559 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1560 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1561 };
1562 assert!(client.apply_segment(0, &segment, true).is_err());
1563 assert!(client.conn.is_autocommit());
1564 assert_eq!(
1565 client
1566 .query("SELECT id FROM tasks ORDER BY id", &[])
1567 .unwrap(),
1568 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1569 );
1570 assert_eq!(client.local_revision(), 1);
1571 assert_eq!(client.drain_change_batches().len(), 1);
1572 assert_eq!(client.subs[0].cursor, -1);
1573 client
1574 .conn
1575 .execute_batch("DROP TRIGGER fail_block")
1576 .unwrap();
1577 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1578 assert_eq!(client.local_revision(), 3);
1579 assert_eq!(client.drain_change_batches().len(), 2);
1580 }
1581
1582 struct SegmentBatchTransport {
1586 response: Message,
1587 bodies: BTreeMap<String, Vec<u8>>,
1588 batches: Vec<Vec<String>>,
1589 single_fetches: usize,
1590 }
1591
1592 impl Transport for SegmentBatchTransport {
1593 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1594 Ok(encode_message(&self.response))
1595 }
1596
1597 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1598 self.sync(request)
1599 }
1600
1601 fn download_segment(
1602 &mut self,
1603 _request: &SegmentRequest,
1604 _on_progress: &mut dyn FnMut(u64),
1605 ) -> Result<Vec<u8>, TransportError> {
1606 self.single_fetches += 1;
1607 Err(TransportError::new(
1608 "sync.transport_failed",
1609 "per-descriptor fetch",
1610 ))
1611 }
1612
1613 fn download_segments(
1614 &mut self,
1615 fetches: &[crate::SegmentFetch],
1616 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1617 ) -> Vec<Result<Vec<u8>, TransportError>> {
1618 self.batches.push(
1619 fetches
1620 .iter()
1621 .map(|fetch| match fetch {
1622 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1623 crate::SegmentFetch::Url(url) => url.clone(),
1624 })
1625 .collect(),
1626 );
1627 fetches
1628 .iter()
1629 .enumerate()
1630 .map(|(index, fetch)| {
1631 let crate::SegmentFetch::Direct(request) = fetch else {
1632 panic!("the descriptors are direct-endpoint requests")
1633 };
1634 let bytes = self.bodies[&request.segment_id].clone();
1635 on_progress(index, bytes.len() as u64);
1636 Ok(bytes)
1637 })
1638 .collect()
1639 }
1640
1641 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1642 Ok(())
1643 }
1644
1645 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1646 Ok(())
1647 }
1648
1649 fn realtime_close(&mut self) -> Result<(), TransportError> {
1650 Ok(())
1651 }
1652 }
1653
1654 #[test]
1655 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1656 let mut client = client();
1657 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1658 client
1659 .subscribe(
1660 "tasks".into(),
1661 "tasks".into(),
1662 vec![("project_id".into(), vec!["p1".into()])],
1663 None,
1664 )
1665 .unwrap();
1666 client.subs[0].cursor = 0;
1667 client.subs[0].synced_once = true;
1668 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1669 client.persist_sub(&client.subs[0]).unwrap();
1670 client.drain_change_batches();
1671 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1672
1673 let pages = ["one", "two", "three", "four"].map(|id| {
1677 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1678 table: "tasks".into(),
1679 schema_version: 1,
1680 columns: columns.clone(),
1681 blocks: vec![vec![ssp2::segment::SegmentRow {
1682 server_version: 1,
1683 values: vec![
1684 Some(ColumnValue::String(id.into())),
1685 Some(ColumnValue::String("p1".into())),
1686 ],
1687 }]],
1688 });
1689 (
1690 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1691 bytes,
1692 )
1693 });
1694
1695 let mut frames = vec![
1696 Frame::RespHeader {
1697 required_schema_version: None,
1698 latest_schema_version: None,
1699 log_epoch: Some("epoch-1".into()),
1700 reset_required: Some(false),
1701 },
1702 Frame::SubStart {
1703 id: "tasks".into(),
1704 status: SubStatus::Active,
1705 reason_code: String::new(),
1706 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1707 bootstrap: false,
1708 },
1709 ];
1710 for (segment_id, bytes) in &pages {
1711 frames.push(Frame::SegmentRef {
1712 segment_id: segment_id.clone(),
1713 media_type: MediaType::Rows,
1714 table: "tasks".into(),
1715 byte_length: bytes.len() as i64,
1716 row_count: 1,
1717 as_of_commit_seq: 1,
1718 scope_digest: "digest".into(),
1719 row_cursor: None,
1720 next_row_cursor: None,
1721 url: None,
1722 url_expires_at_ms: None,
1723 });
1724 }
1725 frames.push(Frame::SubEnd {
1726 next_cursor: 1,
1727 bootstrap_state: None,
1728 });
1729 let mut transport = SegmentBatchTransport {
1730 response: Message {
1731 wire_version: WIRE_VERSION,
1732 msg_kind: MsgKind::Response,
1733 frames,
1734 },
1735 bodies: pages
1736 .iter()
1737 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1738 .collect(),
1739 batches: Vec::new(),
1740 single_fetches: 0,
1741 };
1742
1743 let outcome = client.sync(&mut transport);
1744 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1745 assert_eq!(
1746 transport.batches,
1747 vec![pages
1748 .iter()
1749 .map(|(segment_id, _)| segment_id.clone())
1750 .collect::<Vec<_>>()],
1751 "one batch per response, in wire order"
1752 );
1753 assert_eq!(
1754 transport.single_fetches, 0,
1755 "the batch seam replaces the per-descriptor calls"
1756 );
1757 assert_eq!(
1758 client
1759 .query("SELECT id FROM tasks ORDER BY id", &[])
1760 .unwrap(),
1761 ["four", "one", "three", "two"]
1762 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1763 .to_vec()
1764 );
1765 assert_eq!(client.subs[0].cursor, 1);
1766 }
1767
1768 #[test]
1769 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1770 assert_eq!(
1771 client().diagnostics_storage().blob_cache_bytes_approx,
1772 Some(0)
1773 );
1774 let client = SyncClient::new(
1775 "diagnostic-storage".to_owned(),
1776 &json!({
1777 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1778 "columns": [{"name": "id", "type": "string", "nullable": false},
1779 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1780 }),
1781 ClientLimits {
1782 blob_cache_max_bytes: Some(5),
1783 ..Default::default()
1784 },
1785 )
1786 .unwrap();
1787 let empty = client.diagnostics_storage();
1788 assert_eq!(empty.status, "healthy");
1789 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1790 assert_eq!(empty.retained_outcome_entries, Some(0));
1791 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1792 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1793 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1794 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1795 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1796 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1797 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1798 let written = client.diagnostics_storage();
1799 assert_eq!(written.status, "pressure");
1800 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1801 assert_eq!(written.retained_outcome_entries, Some(2));
1802 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1803 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1804 assert_eq!(
1805 written.pressure_reason_code.as_deref(),
1806 Some("client.blob_cache_over_limit")
1807 );
1808 client
1809 .conn
1810 .execute_batch(
1811 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1812 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1813 )
1814 .unwrap();
1815 let staged = client.diagnostics_storage();
1816 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1817 assert_eq!(staged.retained_outcome_entries, Some(0));
1818 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1819 assert_eq!(staged.status, "healthy");
1820 client.conn.execute_batch("ROLLBACK").unwrap();
1821 assert_eq!(client.diagnostics_storage(), written);
1822 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1823 let unreadable = client.diagnostics_storage();
1824 assert_eq!(unreadable.status, "unreadable");
1825 assert!(unreadable.database_bytes_approx.is_none());
1826 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1827 assert!(unreadable.retained_outcome_entries.is_none());
1828 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1829 assert!(unreadable.blob_cache_bytes_approx.is_none());
1830 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1831 let restored = client.diagnostics_storage();
1832 assert_eq!(restored.status, written.status);
1833 assert_eq!(
1834 restored.retained_outcome_bytes_approx,
1835 written.retained_outcome_bytes_approx
1836 );
1837 client.conn.set_prepared_statement_cache_capacity(0);
1838 assert_eq!(client.diagnostics_storage(), restored);
1839 }
1840
1841 struct ServerErrorTransport {
1843 code: &'static str,
1844 retryable: bool,
1845 }
1846
1847 impl Transport for ServerErrorTransport {
1848 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1849 Ok(encode_message(&Message {
1850 wire_version: WIRE_VERSION,
1851 msg_kind: MsgKind::Response,
1852 frames: vec![
1853 Frame::RespHeader {
1854 required_schema_version: None,
1855 latest_schema_version: None,
1856 log_epoch: Some("epoch-1".to_owned()),
1857 reset_required: Some(false),
1858 },
1859 Frame::Error {
1860 code: self.code.to_owned(),
1861 message: "server error".to_owned(),
1862 category: "internal".to_owned(),
1863 retryable: self.retryable,
1864 recommended_action: "retryLater".to_owned(),
1865 details: None,
1866 },
1867 ],
1868 }))
1869 }
1870
1871 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1872 self.sync(request)
1873 }
1874
1875 fn download_segment(
1876 &mut self,
1877 _request: &SegmentRequest,
1878 _on_progress: &mut dyn FnMut(u64),
1879 ) -> Result<Vec<u8>, TransportError> {
1880 Err(TransportError::new("sync.transport_failed", "offline"))
1881 }
1882
1883 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1884 Ok(())
1885 }
1886
1887 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1888 Ok(())
1889 }
1890
1891 fn realtime_close(&mut self) -> Result<(), TransportError> {
1892 Ok(())
1893 }
1894 }
1895
1896 #[test]
1897 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1898 let mut client = client();
1899 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1900 let mut transport = ServerErrorTransport {
1901 code: "sync.schema_not_ready",
1902 retryable: true,
1903 };
1904 client.connect_realtime(&mut transport).expect("connect");
1905 assert!(matches!(
1906 client.sync(&mut transport),
1907 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1908 ));
1909 assert_eq!(
1910 client.realtime_state(),
1911 RealtimeState::Connected,
1912 "the socket delivered the answer, so it is not lost"
1913 );
1914 assert!(
1915 matches!(
1916 client.drain_sync_intents().as_slice(),
1917 [SyncIntent::Background { delay_ms: 250 }]
1918 ),
1919 "the retry deadline is a sync round and no reconnect satisfies it"
1920 );
1921 assert_eq!(
1922 client.progress().snapshot().unwrap().retry_delay_ms,
1923 Some(250)
1924 );
1925 }
1926
1927 #[test]
1928 fn retryable_server_error_schedules_a_background_retry() {
1929 let mut client = client();
1930 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1931 let mut transport = ServerErrorTransport {
1932 code: "sync.internal_error",
1933 retryable: true,
1934 };
1935 assert!(matches!(
1936 client.sync(&mut transport),
1937 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1938 ));
1939 assert!(matches!(
1940 client.drain_sync_intents().as_slice(),
1941 [SyncIntent::Background { delay_ms: 250 }]
1942 ));
1943 assert_eq!(
1944 client.progress().snapshot().unwrap().retry_delay_ms,
1945 Some(250)
1946 );
1947 let mut transport = ServerErrorTransport {
1948 code: "sync.invalid_request",
1949 retryable: false,
1950 };
1951 assert!(matches!(
1952 client.sync(&mut transport),
1953 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1954 ));
1955 assert!(client.drain_sync_intents().is_empty());
1956 let progress = client.progress().snapshot().unwrap();
1957 assert_eq!(progress.state, ProgressState::Failed);
1958 assert_eq!(progress.retry_delay_ms, None);
1959 }
1960
1961 #[test]
1962 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1963 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1966 assert_eq!(exhausted["ok"], json!(true));
1967 assert_eq!(exhausted["budgetExhausted"], json!(true));
1968 assert!(exhausted["report"].is_object());
1969
1970 let mut client = client();
1971 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1972 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1974 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1975 assert!(
1976 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1977 "{zero:?}"
1978 );
1979 let mut erroring = ServerErrorTransport {
1981 code: "sync.internal_error",
1982 retryable: false,
1983 };
1984 let failed = client.sync_until_idle(&mut erroring, Some(3));
1985 assert!(
1986 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1987 "{failed:?}"
1988 );
1989 }
1990
1991 struct ScriptedSyncTransport {
1994 responses: std::collections::VecDeque<Message>,
1995 calls: usize,
1996 }
1997
1998 impl Transport for ScriptedSyncTransport {
1999 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2000 self.calls += 1;
2001 let response = self.responses.pop_front().expect("scripted response");
2002 Ok(encode_message(&response))
2003 }
2004
2005 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2006 self.sync(request)
2007 }
2008
2009 fn download_segment(
2010 &mut self,
2011 _request: &SegmentRequest,
2012 _on_progress: &mut dyn FnMut(u64),
2013 ) -> Result<Vec<u8>, TransportError> {
2014 Err(TransportError::new("sync.transport_failed", "offline"))
2015 }
2016
2017 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2018 Ok(())
2019 }
2020
2021 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2022 Ok(())
2023 }
2024
2025 fn realtime_close(&mut self) -> Result<(), TransportError> {
2026 Ok(())
2027 }
2028 }
2029
2030 #[test]
2031 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2032 let path = std::env::temp_dir().join(format!(
2033 "syncular-budget-script-{}.sqlite",
2034 uuid::Uuid::new_v4()
2035 ));
2036 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2037 {"name":"id","type":"string","nullable":false},
2038 {"name":"project_id","type":"string","nullable":false}],
2039 "scopes":[{"pattern":"project:{project_id}"}]}]});
2040 let mut client = SyncClient::open_path(
2041 "budget-script".into(),
2042 &schema,
2043 ClientLimits::default(),
2044 path.to_str().unwrap(),
2045 )
2046 .expect("open client");
2047 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2048 client
2049 .subscribe(
2050 "tasks".into(),
2051 "tasks".into(),
2052 vec![("project_id".into(), vec!["p1".into()])],
2053 None,
2054 )
2055 .unwrap();
2056 client.subs[0].cursor = 0;
2057 client.subs[0].synced_once = true;
2058 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2059 client.persist_sub(&client.subs[0]).unwrap();
2060 client.drain_change_batches();
2061
2062 let row = encode_row_json(
2063 client.schema.table("tasks").unwrap(),
2064 "kept",
2065 &Map::from_iter([
2066 ("id".into(), json!("kept")),
2067 ("project_id".into(), json!("p1")),
2068 ]),
2069 &client.encryption,
2070 )
2071 .unwrap();
2072 let success = Message {
2073 wire_version: WIRE_VERSION,
2074 msg_kind: MsgKind::Response,
2075 frames: vec![
2076 Frame::RespHeader {
2077 required_schema_version: None,
2078 latest_schema_version: None,
2079 log_epoch: Some("epoch-1".into()),
2080 reset_required: Some(false),
2081 },
2082 Frame::SubStart {
2083 id: "tasks".into(),
2084 status: SubStatus::Active,
2085 reason_code: String::new(),
2086 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2087 bootstrap: false,
2088 },
2089 Frame::Commit {
2090 commit_seq: 1,
2091 created_at_ms: 1,
2092 actor_id: "writer".into(),
2093 tables: vec!["tasks".into()],
2094 changes: vec![ssp2::model::Change {
2095 table_index: 0,
2096 row_id: "kept".into(),
2097 op: Op::Upsert,
2098 row_version: Some(1),
2099 scopes: vec![("project_id".into(), "p1".into())],
2100 row: Some(row),
2101 }],
2102 },
2103 Frame::SubEnd {
2104 next_cursor: 1,
2105 bootstrap_state: None,
2106 },
2107 ],
2108 };
2109 let failure = Message {
2110 wire_version: WIRE_VERSION,
2111 msg_kind: MsgKind::Response,
2112 frames: vec![
2113 Frame::RespHeader {
2114 required_schema_version: None,
2115 latest_schema_version: None,
2116 log_epoch: Some("epoch-1".into()),
2117 reset_required: Some(false),
2118 },
2119 Frame::Error {
2120 code: "sync.internal_error".into(),
2121 message: "second round failed".into(),
2122 category: "internal".into(),
2123 retryable: false,
2124 recommended_action: "retryLater".into(),
2125 details: None,
2126 },
2127 ],
2128 };
2129 let mut transport = ScriptedSyncTransport {
2130 responses: std::collections::VecDeque::from([success, failure]),
2131 calls: 0,
2132 };
2133 let outcome = client.sync_until_idle(&mut transport, Some(2));
2134 let SyncOutcome::Failed {
2135 error_code,
2136 recommended_action,
2137 ..
2138 } = outcome
2139 else {
2140 panic!("expected the in-band failure");
2141 };
2142 assert_eq!(error_code, "sync.internal_error");
2143 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2144 assert_eq!(
2145 transport.calls, 2,
2146 "one successful round then the failing round"
2147 );
2148 assert_eq!(
2149 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2150 json!("kept"),
2151 "the earlier round's durable work survives the later failure"
2152 );
2153 drop(client);
2154 let _ = std::fs::remove_file(&path);
2155 }
2156
2157 #[test]
2158 fn sync_until_idle_stops_on_a_schema_floor() {
2159 let mut client = client();
2160 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2161 let floor = Message {
2162 wire_version: WIRE_VERSION,
2163 msg_kind: MsgKind::Response,
2164 frames: vec![Frame::RespHeader {
2165 required_schema_version: Some(2),
2166 latest_schema_version: Some(2),
2167 log_epoch: Some("epoch-1".into()),
2168 reset_required: Some(false),
2169 }],
2170 };
2171 let mut transport = ScriptedSyncTransport {
2172 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2173 calls: 0,
2174 };
2175 let outcome = client.sync_until_idle(&mut transport, Some(3));
2176 match outcome {
2177 SyncOutcome::Ok(report) => assert_eq!(
2178 report
2179 .schema_floor
2180 .as_ref()
2181 .and_then(|floor| floor.required_schema_version),
2182 Some(2),
2183 "the stop retains the latest schema floor"
2184 ),
2185 other => panic!("expected a schema-floor stop, got {other:?}"),
2186 }
2187 assert_eq!(
2188 transport.calls, 1,
2189 "a schema floor stops the budget loop after one round"
2190 );
2191 }
2192
2193 struct FailureScriptTransport {
2196 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2197 requests: Vec<Message>,
2198 }
2199
2200 impl Transport for FailureScriptTransport {
2201 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2202 self.requests
2203 .push(decode_message(request).expect("the client encodes a legal request"));
2204 self.rounds.pop_front().expect("scripted round")
2205 }
2206
2207 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2208 self.sync(request)
2209 }
2210
2211 fn download_segment(
2212 &mut self,
2213 _request: &SegmentRequest,
2214 _on_progress: &mut dyn FnMut(u64),
2215 ) -> Result<Vec<u8>, TransportError> {
2216 Err(TransportError::new("transport.timeout", "segment stalled"))
2217 }
2218
2219 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2220 Ok(())
2221 }
2222
2223 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2224 Ok(())
2225 }
2226
2227 fn realtime_close(&mut self) -> Result<(), TransportError> {
2228 Ok(())
2229 }
2230 }
2231
2232 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2234 let mut all = vec![Frame::RespHeader {
2235 required_schema_version: None,
2236 latest_schema_version: None,
2237 log_epoch: Some("epoch-1".into()),
2238 reset_required: Some(false),
2239 }];
2240 all.extend(frames);
2241 encode_message(&Message {
2242 wire_version: WIRE_VERSION,
2243 msg_kind: MsgKind::Response,
2244 frames: all,
2245 })
2246 }
2247
2248 fn carried_round_failures(request: &Message) -> Vec<String> {
2249 request
2250 .frames
2251 .iter()
2252 .filter_map(|frame| match frame {
2253 Frame::RoundFailure { code } => Some(code.clone()),
2254 _ => None,
2255 })
2256 .collect()
2257 }
2258
2259 fn failure_transport(
2260 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2261 ) -> FailureScriptTransport {
2262 FailureScriptTransport {
2263 rounds: rounds.into_iter().collect(),
2264 requests: Vec::new(),
2265 }
2266 }
2267
2268 #[test]
2269 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2270 let mut client = client();
2271 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2272 let mut transport = failure_transport([
2273 Err(TransportError::new("transport.timeout", "round timed out")),
2274 Ok(completed_response(Vec::new())),
2275 Ok(completed_response(Vec::new())),
2276 ]);
2277 for _ in 0..3 {
2278 client.sync(&mut transport);
2279 }
2280 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2281 assert_eq!(
2282 carried_round_failures(&transport.requests[1]),
2283 vec!["transport.timeout"]
2284 );
2285 assert!(
2286 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2287 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2288 "the report sits immediately after REQ_HEADER"
2289 );
2290 assert!(
2291 carried_round_failures(&transport.requests[2]).is_empty(),
2292 "a complete response ends the report"
2293 );
2294 }
2295
2296 #[test]
2297 fn a_newer_round_failure_replaces_the_kept_code() {
2298 let mut client = client();
2299 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2300 let mut transport = failure_transport([
2301 Err(TransportError::new("transport.timeout", "round timed out")),
2302 Err(TransportError::new("transport.failed", "socket closed")),
2303 Ok(completed_response(Vec::new())),
2304 Ok(completed_response(Vec::new())),
2305 ]);
2306 for _ in 0..4 {
2307 client.sync(&mut transport);
2308 }
2309 assert_eq!(
2310 carried_round_failures(&transport.requests[1]),
2311 vec!["transport.timeout"]
2312 );
2313 assert_eq!(
2314 carried_round_failures(&transport.requests[2]),
2315 vec!["transport.failed"]
2316 );
2317 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2318 }
2319
2320 #[test]
2321 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2322 let mut client = client();
2323 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2324 let mut transport = failure_transport([
2325 Err(TransportError::new("transport.timeout", "round timed out")),
2326 Ok(completed_response(vec![Frame::Error {
2327 code: "sync.internal_error".into(),
2328 message: "server error".into(),
2329 category: "internal".into(),
2330 retryable: false,
2331 recommended_action: "retryLater".into(),
2332 details: None,
2333 }])),
2334 Ok(completed_response(Vec::new())),
2335 ]);
2336 client.sync(&mut transport);
2337 assert!(matches!(
2338 client.sync(&mut transport),
2339 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2340 ));
2341 client.sync(&mut transport);
2342 assert_eq!(
2343 carried_round_failures(&transport.requests[1]),
2344 vec!["transport.timeout"]
2345 );
2346 assert!(
2347 carried_round_failures(&transport.requests[2]).is_empty(),
2348 "the server already recorded an in-band ERROR"
2349 );
2350 }
2351
2352 #[test]
2353 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2354 let mut client = client();
2355 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2356 client
2357 .subscribe(
2358 "tasks".into(),
2359 "tasks".into(),
2360 vec![("project_id".into(), vec!["p1".into()])],
2361 None,
2362 )
2363 .unwrap();
2364 client.subs[0].cursor = 0;
2365 client.subs[0].synced_once = true;
2366 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2367 client.persist_sub(&client.subs[0]).unwrap();
2368 client.drain_change_batches();
2369 client.drain_sync_intents();
2370 let mut transport = failure_transport([
2371 Ok(completed_response(vec![
2372 Frame::SubStart {
2373 id: "tasks".into(),
2374 status: SubStatus::Active,
2375 reason_code: String::new(),
2376 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2377 bootstrap: false,
2378 },
2379 Frame::SegmentRef {
2380 segment_id: "sha256:stalled".into(),
2381 media_type: MediaType::Rows,
2382 table: "tasks".into(),
2383 byte_length: 1,
2384 row_count: 1,
2385 as_of_commit_seq: 1,
2386 scope_digest: "digest".into(),
2387 row_cursor: None,
2388 next_row_cursor: None,
2389 url: None,
2390 url_expires_at_ms: None,
2391 },
2392 Frame::SubEnd {
2393 next_cursor: 1,
2394 bootstrap_state: None,
2395 },
2396 ])),
2397 Ok(completed_response(Vec::new())),
2398 Ok(completed_response(Vec::new())),
2399 ]);
2400
2401 assert!(matches!(
2402 client.sync(&mut transport),
2403 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2404 ));
2405 assert!(
2406 matches!(
2407 client.drain_sync_intents().as_slice(),
2408 [SyncIntent::Background { delay_ms: 250 }]
2409 ),
2410 "a silent transfer asks the host for a Background retry"
2411 );
2412 client.sync(&mut transport);
2413 client.sync(&mut transport);
2414 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2415 assert_eq!(
2416 carried_round_failures(&transport.requests[1]),
2417 vec!["transport.timeout"],
2418 "the round's own response was complete, so the fetch failure rides the next request"
2419 );
2420 assert!(
2421 carried_round_failures(&transport.requests[2]).is_empty(),
2422 "a complete response ends the report"
2423 );
2424 }
2425
2426 #[test]
2427 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2428 let mut client = client();
2429 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2430 let mut transport = failure_transport([
2431 Err(TransportError::new("offline", "no dotted code")),
2432 Ok(completed_response(Vec::new())),
2433 ]);
2434 client.sync(&mut transport);
2435 client.sync(&mut transport);
2436 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2437 }
2438
2439 #[test]
2440 fn sync_report_merge_schema_floor_is_latest_state() {
2441 let mut aggregate = SyncReport {
2442 schema_floor: Some(SchemaFloor {
2443 required_schema_version: Some(2),
2444 latest_schema_version: Some(2),
2445 }),
2446 ..SyncReport::default()
2447 };
2448 aggregate.merge(&SyncReport::default());
2449 assert!(
2450 aggregate.schema_floor.is_none(),
2451 "a later round without a floor clears it"
2452 );
2453 }
2454
2455 #[test]
2456 fn push_capacity_limits_are_hard_and_keep_intent() {
2457 let mut c = client();
2459 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2460 c.limits.max_push_operations_per_request = Some(1);
2461 let id = c
2462 .mutate(vec![
2463 Mutation::Upsert {
2464 table: "tasks".into(),
2465 values: Map::from_iter([
2466 ("id".into(), json!("a")),
2467 ("project_id".into(), json!("p1")),
2468 ]),
2469 base_version: None,
2470 },
2471 Mutation::Upsert {
2472 table: "tasks".into(),
2473 values: Map::from_iter([
2474 ("id".into(), json!("b")),
2475 ("project_id".into(), json!("p1")),
2476 ]),
2477 base_version: None,
2478 },
2479 ])
2480 .unwrap();
2481 let error = c.build_request(false).unwrap_err();
2482 let SyncOutcome::Failed {
2483 error_code,
2484 details,
2485 ..
2486 } = *error
2487 else {
2488 panic!("expected a capacity failure");
2489 };
2490 assert_eq!(error_code, "client.push_request_too_large");
2491 let details = details.expect("capacity details");
2492 assert_eq!(details["kind"], json!("operations"));
2493 assert_eq!(details["limit"], json!(1));
2494 assert_eq!(details["size"], json!(2));
2495 assert_eq!(details["clientCommitId"], json!(id));
2496 assert_eq!(c.pending_commit_ids(), vec![id]);
2497
2498 let mut c = client();
2500 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2501 c.limits.max_push_commits_per_request = Some(1);
2502 let first = c
2503 .mutate(vec![Mutation::Upsert {
2504 table: "tasks".into(),
2505 values: Map::from_iter([
2506 ("id".into(), json!("one")),
2507 ("project_id".into(), json!("p1")),
2508 ]),
2509 base_version: None,
2510 }])
2511 .unwrap();
2512 let second = c
2513 .mutate(vec![Mutation::Upsert {
2514 table: "tasks".into(),
2515 values: Map::from_iter([
2516 ("id".into(), json!("two")),
2517 ("project_id".into(), json!("p1")),
2518 ]),
2519 base_version: None,
2520 }])
2521 .unwrap();
2522 let (_, meta) = c.build_request(false).unwrap();
2523 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2524 assert_eq!(meta.deferred_commits, 1);
2525 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2526 }
2527
2528 #[test]
2529 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2530 let mut c = client();
2533 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2534 let first = c
2535 .mutate(vec![Mutation::Upsert {
2536 table: "tasks".into(),
2537 values: Map::from_iter([
2538 ("id".into(), json!("one")),
2539 ("project_id".into(), json!("p1")),
2540 ]),
2541 base_version: None,
2542 }])
2543 .unwrap();
2544 let second = c
2545 .mutate(vec![Mutation::Upsert {
2546 table: "tasks".into(),
2547 values: Map::from_iter([
2548 ("id".into(), json!("two")),
2549 ("project_id".into(), json!("p1")),
2550 ]),
2551 base_version: None,
2552 }])
2553 .unwrap();
2554 let (message, meta) = c.build_request(false).unwrap();
2555 assert_eq!(meta.pushed_ids.len(), 2);
2556 let full = encode_message(&message).len();
2557 c.limits.max_push_request_bytes = Some(full - 1);
2558 let (_, meta) = c.build_request(false).unwrap();
2559 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2560 assert_eq!(meta.deferred_commits, 1);
2561 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2562
2563 let mut c = client();
2566 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2567 let huge = c
2568 .mutate(vec![Mutation::Upsert {
2569 table: "tasks".into(),
2570 values: Map::from_iter([
2571 ("id".into(), json!("x".repeat(20_000))),
2572 ("project_id".into(), json!("p1")),
2573 ]),
2574 base_version: None,
2575 }])
2576 .unwrap();
2577 let (message, meta) = c.build_request(false).unwrap();
2578 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2579 let with_huge = encode_message(&message).len();
2580 c.limits.max_push_request_bytes = Some(with_huge - 1);
2581 let error = c.build_request(false).unwrap_err();
2582 let SyncOutcome::Failed {
2583 error_code,
2584 details,
2585 ..
2586 } = *error
2587 else {
2588 panic!("expected a capacity failure");
2589 };
2590 assert_eq!(error_code, "client.push_request_too_large");
2591 let details = details.expect("capacity details");
2592 assert_eq!(details["kind"], json!("bytes"));
2593 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2594 assert_eq!(details["size"], json!(with_huge as u64));
2595 assert_eq!(details["clientCommitId"], json!(huge));
2596 assert_eq!(c.pending_commit_ids(), vec![huge]);
2597
2598 let mut c = client();
2600 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2601 c.limits.max_push_request_bytes = Some(1);
2602 let error = c.build_request(false).unwrap_err();
2603 let SyncOutcome::Failed { details, .. } = *error else {
2604 panic!("expected a capacity failure");
2605 };
2606 let details = details.expect("capacity details");
2607 assert_eq!(details["kind"], json!("bytes"));
2608 assert!(details.get("clientCommitId").is_none(), "{details}");
2609 }
2610
2611 #[test]
2612 fn push_limits_validate_at_construction() {
2613 for limits in [
2614 ClientLimits {
2615 max_push_commits_per_request: Some(0),
2616 ..Default::default()
2617 },
2618 ClientLimits {
2619 max_push_operations_per_request: Some(0),
2620 ..Default::default()
2621 },
2622 ClientLimits {
2623 max_push_request_bytes: Some(0),
2624 ..Default::default()
2625 },
2626 ClientLimits {
2627 max_push_request_bytes: Some(u32::MAX as usize + 1),
2628 ..Default::default()
2629 },
2630 ] {
2631 let schema = json!({"version":1,"tables":[]});
2632 let error = SyncClient::with_connection(
2633 "bad-limits".into(),
2634 &schema,
2635 limits,
2636 Connection::open_in_memory().unwrap(),
2637 )
2638 .err();
2639 assert_eq!(
2640 error.as_deref(),
2641 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2642 );
2643 }
2644 }
2645
2646 #[test]
2647 fn open_path_rejects_invalid_push_limits_before_storage() {
2648 for identity in [false, true] {
2649 let path = std::env::temp_dir().join(format!(
2650 "syncular-bad-limits-{}.sqlite",
2651 uuid::Uuid::new_v4()
2652 ));
2653 let schema = json!({"version":1,"tables":[]});
2654 let limits = ClientLimits {
2655 max_push_request_bytes: Some(0),
2656 ..Default::default()
2657 };
2658 let error = if identity {
2659 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2660 } else {
2661 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2662 }
2663 .err();
2664 assert_eq!(
2665 error.as_deref(),
2666 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2667 );
2668 assert!(
2669 !path.exists(),
2670 "an invalid limit is rejected before the database file is created"
2671 );
2672 }
2673 }
2674
2675 #[test]
2676 fn failed_progress_retry_delay_doubles_to_the_cap() {
2677 let mut client = client();
2678 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2679 let mut transport = ServerErrorTransport {
2680 code: "sync.internal_error",
2681 retryable: true,
2682 };
2683 let delays: Vec<Option<u64>> = (0..9)
2684 .map(|_| {
2685 client.sync(&mut transport);
2686 client.progress().snapshot().unwrap().retry_delay_ms
2687 })
2688 .collect();
2689 assert_eq!(
2690 delays,
2691 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2692 );
2693 }
2694
2695 #[test]
2696 fn background_retry_deadlines_back_off_and_reset() {
2697 let mut client = client();
2698 client.schedule_background_retry();
2699 assert!(matches!(
2700 client.drain_sync_intents().as_slice(),
2701 [SyncIntent::Background { delay_ms: 250 }]
2702 ));
2703 client.schedule_background_retry();
2704 assert!(matches!(
2705 client.drain_sync_intents().as_slice(),
2706 [SyncIntent::Background { delay_ms: 500 }]
2707 ));
2708 client.reset_background_retry();
2709 client.schedule_background_retry();
2710 assert!(matches!(
2711 client.drain_sync_intents().as_slice(),
2712 [SyncIntent::Background { delay_ms: 250 }]
2713 ));
2714 }
2715
2716 #[test]
2717 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2718 let mut client = client();
2719 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2720 client
2721 .subscribe(
2722 "epoch-tasks".to_owned(),
2723 "tasks".to_owned(),
2724 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2725 None,
2726 )
2727 .expect("subscribe");
2728 client.drain_sync_intents();
2729
2730 client
2731 .run_log_epoch_reset("epoch-2")
2732 .expect("reset partition log epoch");
2733
2734 assert!(matches!(
2735 client.drain_sync_intents().as_slice(),
2736 [SyncIntent::Interactive]
2737 ));
2738 }
2739
2740 #[derive(Default)]
2741 struct CountingRealtimeTransport {
2742 connects: usize,
2743 closes: usize,
2744 messages: Vec<String>,
2745 blob_uploads: Vec<(String, Vec<u8>)>,
2746 blob_download: Option<Vec<u8>>,
2747 sync_calls: usize,
2748 realtime_sync_calls: usize,
2749 refuse_connect: bool,
2750 }
2751
2752 impl Transport for CountingRealtimeTransport {
2753 fn blob_upload(
2754 &mut self,
2755 blob_id: &str,
2756 bytes: &[u8],
2757 _media_type: Option<&str>,
2758 ) -> Result<(), TransportError> {
2759 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2760 Ok(())
2761 }
2762
2763 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2764 self.blob_download
2765 .clone()
2766 .map(BlobDownload::Bytes)
2767 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2768 }
2769
2770 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2771 self.sync_calls += 1;
2772 Err(TransportError::new("sync.transport_failed", "offline"))
2773 }
2774
2775 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2776 self.realtime_sync_calls += 1;
2777 Err(TransportError::new("sync.transport_failed", "offline"))
2778 }
2779
2780 fn download_segment(
2781 &mut self,
2782 _request: &SegmentRequest,
2783 _on_progress: &mut dyn FnMut(u64),
2784 ) -> Result<Vec<u8>, TransportError> {
2785 Err(TransportError::new("sync.transport_failed", "offline"))
2786 }
2787
2788 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2789 self.connects += 1;
2790 if self.refuse_connect {
2791 return Err(TransportError::new("transport.failed", "refused"));
2792 }
2793 Ok(())
2794 }
2795
2796 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2797 self.messages.push(text.to_owned());
2798 Ok(())
2799 }
2800
2801 fn realtime_close(&mut self) -> Result<(), TransportError> {
2802 self.closes += 1;
2803 Ok(())
2804 }
2805 }
2806
2807 #[test]
2808 fn blob_results_own_downloaded_bytes() {
2809 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2810 {"name":"id","type":"string","nullable":false},
2811 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2812 let bytes = b"owned typed result".to_vec();
2813 let blob_id = blob_id_for(&bytes);
2814 let mut transport = CountingRealtimeTransport {
2815 blob_download: Some(bytes.clone()),
2816 ..Default::default()
2817 };
2818 let result = {
2819 let mut client =
2820 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2821 .unwrap();
2822 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2823 assert_eq!(result.blob_id, blob_id);
2824 assert_eq!(result.byte_length, bytes.len() as i64);
2825 assert_eq!(result.bytes, bytes);
2826 assert_eq!(result.media_type, None);
2827 assert_eq!(
2828 client
2829 .conn
2830 .query_row(
2831 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2832 rusqlite::params![blob_id],
2833 |row| row.get::<_, i64>(0),
2834 )
2835 .unwrap(),
2836 0
2837 );
2838 result
2839 };
2840 assert_eq!(result.bytes, bytes);
2841
2842 let mut client =
2843 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2844 assert_eq!(
2845 client
2846 .fetch_blob_bytes(&mut transport, "not json")
2847 .unwrap_err(),
2848 (
2849 "client.failed".to_owned(),
2850 "blob ref is not JSON".to_owned()
2851 )
2852 );
2853 }
2854
2855 #[test]
2856 fn visible_blob_references_survive_cache_trimming() {
2857 let schema = json!({"version":1,"tables":[
2858 {"name":"attachments","primaryKey":"id","columns":[
2859 {"name":"id","type":"string","nullable":false},
2860 {"name":"body","type":"blob_ref","nullable":true},
2861 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2862 {"name":"avatars","primaryKey":"id","columns":[
2863 {"name":"id","type":"string","nullable":false},
2864 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2865 ]});
2866 let bytes = b"referenced above cap".to_vec();
2867 let blob_id = blob_id_for(&bytes);
2868 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2869 let mut client = SyncClient::new(
2870 "visible-blob-refs".to_owned(),
2871 &schema,
2872 ClientLimits {
2873 blob_cache_max_bytes: Some(1),
2874 ..Default::default()
2875 },
2876 )
2877 .unwrap();
2878 client.conn.execute(
2879 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2880 rusqlite::params![reference, reference, reference, reference],
2881 ).unwrap();
2882 client
2883 .conn
2884 .execute(
2885 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2886 rusqlite::params![reference],
2887 )
2888 .unwrap();
2889 client.conn.execute(
2890 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2891 [],
2892 ).unwrap();
2893 let mut transport = CountingRealtimeTransport {
2894 blob_download: Some(bytes.clone()),
2895 ..Default::default()
2896 };
2897
2898 assert_eq!(
2899 client
2900 .fetch_blob_bytes(&mut transport, &blob_id)
2901 .unwrap()
2902 .bytes,
2903 bytes
2904 );
2905 let rows = client
2906 .conn
2907 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
2908 .unwrap()
2909 .query_map([], |row| row.get::<_, String>(0))
2910 .unwrap()
2911 .collect::<Result<Vec<_>, _>>()
2912 .unwrap();
2913 assert_eq!(rows, vec![blob_id]);
2914 }
2915
2916 #[test]
2917 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
2918 let mut transport = CountingRealtimeTransport::default();
2919 let path = std::env::temp_dir().join(format!(
2920 "syncular-transport-gate-{}.db",
2921 uuid::Uuid::new_v4()
2922 ));
2923 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2924 {"name":"id","type":"string","nullable":false},
2925 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
2926 let mut client = SyncClient::open_path(
2927 "gate".into(),
2928 &schema,
2929 ClientLimits::default(),
2930 path.to_str().unwrap(),
2931 )
2932 .unwrap();
2933 client.connect_realtime(&mut transport).unwrap();
2934 client.set_transport_enabled(&mut transport, false);
2935 assert_eq!(transport.closes, 1);
2936 client.begin_security_preflight();
2937 client.activate_security(Default::default()).unwrap();
2938 client
2939 .subscribe(
2940 "tasks".into(),
2941 "tasks".into(),
2942 vec![("project_id".into(), vec!["p1".into()])],
2943 None,
2944 )
2945 .unwrap();
2946 for id in ["one", "two"] {
2947 client
2948 .mutate(vec![Mutation::Upsert {
2949 table: "tasks".into(),
2950 values: Map::from_iter([
2951 ("id".into(), json!(id)),
2952 ("project_id".into(), json!("p1")),
2953 ]),
2954 base_version: None,
2955 }])
2956 .unwrap();
2957 }
2958 assert_eq!(
2959 client
2960 .query("SELECT id FROM tasks ORDER BY id", &[])
2961 .unwrap()
2962 .len(),
2963 2
2964 );
2965 assert!(
2966 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
2967 );
2968 assert!(client
2969 .connect_realtime(&mut transport)
2970 .unwrap_err()
2971 .starts_with("sync.offline:"));
2972 assert!(client
2973 .set_presence(&mut transport, "project:p1", None)
2974 .unwrap_err()
2975 .starts_with("sync.offline:"));
2976 assert_eq!(transport.sync_calls, 0);
2977 assert_eq!(transport.realtime_sync_calls, 0);
2978 assert_eq!(transport.connects, 1);
2979 assert!(transport.messages.is_empty());
2980 assert!(matches!(
2981 client.drain_sync_intents().as_slice(),
2982 [SyncIntent::None]
2983 ));
2984 let pending = client.pending_commit_ids();
2985 drop(client);
2986 let mut reopened = SyncClient::open_path(
2987 "gate".into(),
2988 &schema,
2989 ClientLimits::default(),
2990 path.to_str().unwrap(),
2991 )
2992 .unwrap();
2993 assert!(reopened.transport_enabled());
2994 reopened.set_transport_enabled(&mut transport, false);
2995 assert_eq!(reopened.pending_commit_ids(), pending);
2996 assert_eq!(
2997 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
2998 2
2999 );
3000 reopened.set_transport_enabled(&mut transport, true);
3001 assert!(matches!(
3002 reopened.drain_sync_intents().as_slice(),
3003 [SyncIntent::Interactive]
3004 ));
3005 reopened.set_transport_enabled(&mut transport, true);
3006 assert!(reopened.drain_sync_intents().is_empty());
3007 drop(reopened);
3008 std::fs::remove_file(path).unwrap();
3009 }
3010
3011 #[test]
3012 fn realtime_connection_ownership_is_idempotent() {
3013 let mut client = client();
3014 let mut transport = CountingRealtimeTransport {
3015 connects: 0,
3016 closes: 0,
3017 ..Default::default()
3018 };
3019 client
3020 .connect_realtime(&mut transport)
3021 .expect("first connect");
3022 client
3023 .connect_realtime(&mut transport)
3024 .expect("idempotent connect");
3025 assert_eq!(transport.connects, 1);
3026 client.disconnect_realtime(&mut transport);
3027 client.disconnect_realtime(&mut transport);
3028 assert_eq!(transport.closes, 1);
3029 client
3030 .connect_realtime(&mut transport)
3031 .expect("deliberate reconnect");
3032 assert_eq!(transport.connects, 2);
3033 }
3034
3035 #[test]
3036 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3037 let mut client = client();
3038 client
3039 .set_realtime_policy(RealtimePolicy::Required)
3040 .expect("set policy");
3041 let mut transport = CountingRealtimeTransport::default();
3042 match client.sync(&mut transport) {
3043 SyncOutcome::RealtimeUnavailable {
3044 state,
3045 reason_code,
3046 retry_delay_ms,
3047 } => {
3048 assert_eq!(state, RealtimeState::Disconnected);
3049 assert_eq!(reason_code, None);
3050 assert_eq!(retry_delay_ms, 250);
3051 }
3052 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3053 }
3054 assert_eq!(transport.sync_calls, 0);
3055 assert!(matches!(
3056 client.drain_sync_intents().as_slice(),
3057 [SyncIntent::Background { delay_ms: 250 }]
3058 ));
3059 let snapshot = client
3060 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3061 .expect("diagnostics");
3062 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3063 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3064 assert_eq!(snapshot.host.realtime_reason_code, None);
3065 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3066 assert_eq!(
3067 snapshot
3068 .last_round
3069 .as_ref()
3070 .and_then(|round| round.error_code.clone()),
3071 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3072 );
3073 }
3074
3075 #[test]
3076 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3077 let mut client = client();
3078 client
3079 .set_realtime_policy(RealtimePolicy::Required)
3080 .expect("set policy");
3081 let mut transport = CountingRealtimeTransport {
3082 refuse_connect: true,
3083 ..Default::default()
3084 };
3085 assert!(client.connect_realtime(&mut transport).is_err());
3086 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3087 let snapshot = client
3088 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3089 .expect("diagnostics");
3090 assert_eq!(
3091 snapshot.host.realtime_reason_code.as_deref(),
3092 Some("transport.failed")
3093 );
3094 match client.sync(&mut transport) {
3095 SyncOutcome::RealtimeUnavailable {
3096 state, reason_code, ..
3097 } => {
3098 assert_eq!(state, RealtimeState::Refused);
3099 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3100 }
3101 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3102 }
3103 assert_eq!(transport.sync_calls, 0);
3104 assert_eq!(transport.realtime_sync_calls, 0);
3105 }
3106
3107 #[test]
3108 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3109 let mut client = client();
3110 let mut transport = CountingRealtimeTransport::default();
3111 let ended = TransportError::new("transport.failed", "realtime connection closed");
3112 client.realtime_lost(&mut transport, &ended);
3113 assert_eq!(
3114 client.realtime_state(),
3115 RealtimeState::Disconnected,
3116 "a socket that was never connected has nothing to lose"
3117 );
3118 assert!(client.drain_sync_intents().is_empty());
3119
3120 client.connect_realtime(&mut transport).expect("connect");
3121 client.realtime_lost(&mut transport, &ended);
3122 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3123 assert_eq!(transport.closes, 1);
3124 assert!(matches!(
3125 client.drain_sync_intents().as_slice(),
3126 [SyncIntent::Background { delay_ms: 250 }]
3127 ));
3128 let snapshot = client
3129 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3130 .expect("diagnostics");
3131 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3132 assert_eq!(
3133 snapshot.host.realtime_reason_code.as_deref(),
3134 Some("transport.failed")
3135 );
3136 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3137
3138 client.realtime_lost(&mut transport, &ended);
3139 assert!(
3140 client.drain_sync_intents().is_empty(),
3141 "a second report for the same loss schedules nothing"
3142 );
3143 client.connect_realtime(&mut transport).expect("reconnect");
3144 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3145 }
3146
3147 #[test]
3148 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3149 let mut client = client();
3150 client
3151 .set_realtime_policy(RealtimePolicy::Required)
3152 .expect("set policy");
3153 let mut transport = CountingRealtimeTransport::default();
3154 client.connect_realtime(&mut transport).expect("connect");
3155 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3156 assert!(
3157 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3158 "a policy change cannot silently re-route a connected socket"
3159 );
3160 assert!(matches!(
3161 client.sync(&mut transport),
3162 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3163 ));
3164 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3165 assert_eq!(transport.realtime_sync_calls, 1);
3166 assert_eq!(transport.sync_calls, 0);
3167 let snapshot = client
3168 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3169 .expect("diagnostics");
3170 assert_eq!(
3171 snapshot.host.realtime_reason_code.as_deref(),
3172 Some("sync.transport_failed")
3173 );
3174 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3175 match client.sync(&mut transport) {
3176 SyncOutcome::RealtimeUnavailable {
3177 state, reason_code, ..
3178 } => {
3179 assert_eq!(state, RealtimeState::Lost);
3180 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3181 }
3182 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3183 }
3184 assert_eq!(transport.sync_calls, 0);
3185 client.disconnect_realtime(&mut transport);
3186 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3187 assert_eq!(transport.closes, 1);
3188 client.connect_realtime(&mut transport).expect("reconnect");
3189 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3190 assert_eq!(transport.connects, 2);
3191 }
3192
3193 #[test]
3194 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3195 let mut client = client();
3196 client
3197 .set_realtime_policy(RealtimePolicy::Off)
3198 .expect("set policy");
3199 let mut transport = CountingRealtimeTransport::default();
3200 assert!(client.connect_realtime(&mut transport).is_err());
3201 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3202 assert!(matches!(
3203 client.sync(&mut transport),
3204 SyncOutcome::Failed { .. }
3205 ));
3206 assert_eq!(transport.sync_calls, 1);
3207 assert_eq!(transport.realtime_sync_calls, 0);
3208 let snapshot = client
3209 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3210 .expect("diagnostics");
3211 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3212 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3213 }
3214
3215 #[test]
3216 fn optional_policy_keeps_the_http_round() {
3217 let mut client = client();
3218 let mut transport = CountingRealtimeTransport::default();
3219 assert!(matches!(
3220 client.sync(&mut transport),
3221 SyncOutcome::Failed { .. }
3222 ));
3223 assert_eq!(transport.sync_calls, 1);
3224 assert_eq!(transport.realtime_sync_calls, 0);
3225 let snapshot = client
3226 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3227 .expect("diagnostics");
3228 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3229 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3230 }
3231
3232 #[test]
3233 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3234 let mut client = client();
3235 client
3236 .subscribe(
3237 "repair-tasks".to_owned(),
3238 "tasks".to_owned(),
3239 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3240 None,
3241 )
3242 .expect("subscribe");
3243 {
3244 let sub = client
3245 .subs
3246 .iter_mut()
3247 .find(|sub| sub.id == "repair-tasks")
3248 .expect("subscription");
3249 sub.cursor = 42;
3250 sub.synced_once = true;
3251 let persisted = sub.clone();
3252 client.persist_sub(&persisted).unwrap();
3253 }
3254 for table in ["_syncular_base_tasks", "tasks"] {
3255 client
3256 .conn
3257 .execute(
3258 &format!(
3259 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3260 ),
3261 rusqlite::params!["server-row", "p1"],
3262 )
3263 .expect("seed server row");
3264 }
3265 let pending = client
3266 .mutate(vec![Mutation::Upsert {
3267 table: "tasks".to_owned(),
3268 values: Map::from_iter([
3269 ("id".to_owned(), Value::from("offline-row")),
3270 ("project_id".to_owned(), Value::from("p1")),
3271 ]),
3272 base_version: None,
3273 }])
3274 .expect("queue offline work");
3275 client.drain_change_batches();
3276 client.drain_sync_intents();
3277
3278 assert_eq!(
3279 client
3280 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3281 rebootstrap_id: "support-case-001".to_owned(),
3282 })
3283 .expect("rebootstrap"),
3284 LocalDataRebootstrapResult {
3285 already_applied: false,
3286 retained_commits: 1,
3287 reset_subscriptions: 1,
3288 }
3289 );
3290 let visible_ids = client
3291 .conn
3292 .prepare("SELECT id FROM tasks ORDER BY id")
3293 .expect("prepare visible ids")
3294 .query_map([], |row| row.get::<_, String>(0))
3295 .expect("query visible ids")
3296 .collect::<Result<Vec<_>, _>>()
3297 .expect("collect visible ids");
3298 assert_eq!(visible_ids, vec!["offline-row"]);
3299 assert_eq!(client.pending_commit_ids(), vec![pending]);
3300 assert_eq!(
3301 client
3302 .subscription_state("repair-tasks")
3303 .expect("subscription")
3304 .cursor,
3305 -1
3306 );
3307 assert!(client.upgrading());
3308 assert!(client.sync_needed());
3309 assert!(matches!(
3310 client.drain_sync_intents().as_slice(),
3311 [SyncIntent::Interactive]
3312 ));
3313 assert_eq!(client.drain_change_batches().len(), 1);
3314
3315 assert_eq!(
3316 client
3317 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3318 rebootstrap_id: "support-case-001".to_owned(),
3319 })
3320 .expect("idempotent retry"),
3321 LocalDataRebootstrapResult {
3322 already_applied: true,
3323 retained_commits: 1,
3324 reset_subscriptions: 1,
3325 }
3326 );
3327 assert!(client.drain_change_batches().is_empty());
3328 }
3329
3330 #[test]
3331 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3332 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3333 assert_eq!(
3334 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3335 (3, 4)
3336 );
3337 assert_eq!(
3338 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3339 (0, 0)
3340 );
3341 for malformed in [
3342 "",
3343 "{}",
3344 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3345 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3346 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3347 ] {
3348 assert_eq!(
3349 decode_local_rebootstrap_receipt(malformed)
3350 .expect_err("malformed receipt must fail"),
3351 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3352 );
3353 }
3354 }
3355
3356 #[test]
3357 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3358 let path = std::env::temp_dir().join(format!(
3359 "syncular-rebootstrap-receipt-{}.db",
3360 uuid::Uuid::new_v4()
3361 ));
3362 let schema = json!({
3363 "version": 1,
3364 "tables": [{
3365 "name": "tasks",
3366 "primaryKey": "id",
3367 "columns": [
3368 { "name": "id", "type": "string", "nullable": false },
3369 { "name": "project_id", "type": "string", "nullable": false }
3370 ],
3371 "scopes": [{ "pattern": "project:{project_id}" }]
3372 }]
3373 });
3374 let path_string = path.to_str().expect("UTF-8 temp path");
3375
3376 {
3377 let mut first = SyncClient::open_path(
3378 "repair-restart-client".to_owned(),
3379 &schema,
3380 ClientLimits::default(),
3381 path_string,
3382 )
3383 .expect("first open");
3384 first
3385 .subscribe(
3386 "repair-tasks".to_owned(),
3387 "tasks".to_owned(),
3388 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3389 None,
3390 )
3391 .expect("subscribe");
3392 first
3393 .mutate(vec![Mutation::Upsert {
3394 table: "tasks".to_owned(),
3395 values: Map::from_iter([
3396 ("id".to_owned(), Value::from("offline-row")),
3397 ("project_id".to_owned(), Value::from("p1")),
3398 ]),
3399 base_version: None,
3400 }])
3401 .expect("queue offline work");
3402 assert_eq!(
3403 first
3404 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3405 rebootstrap_id: "restart-receipt".to_owned(),
3406 })
3407 .expect("first rebootstrap"),
3408 LocalDataRebootstrapResult {
3409 already_applied: false,
3410 retained_commits: 1,
3411 reset_subscriptions: 1,
3412 }
3413 );
3414 }
3415
3416 let mut reopened = SyncClient::open_path(
3417 "repair-restart-client".to_owned(),
3418 &schema,
3419 ClientLimits::default(),
3420 path_string,
3421 )
3422 .expect("reopen");
3423 reopened.drain_change_batches();
3424 reopened.drain_sync_intents();
3425 assert_eq!(
3426 reopened
3427 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3428 rebootstrap_id: "restart-receipt".to_owned(),
3429 })
3430 .expect("receipt replay"),
3431 LocalDataRebootstrapResult {
3432 already_applied: true,
3433 retained_commits: 1,
3434 reset_subscriptions: 1,
3435 }
3436 );
3437 assert!(reopened.drain_change_batches().is_empty());
3438 assert!(reopened.drain_sync_intents().is_empty());
3439 drop(reopened);
3440 std::fs::remove_file(path).expect("remove temp database");
3441 }
3442
3443 #[test]
3444 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3445 let mut malformed = client();
3446 malformed
3447 .subscribe(
3448 "repair-tasks".to_owned(),
3449 "tasks".to_owned(),
3450 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3451 None,
3452 )
3453 .expect("subscribe");
3454 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3455 malformed.drain_change_batches();
3456 malformed.drain_sync_intents();
3457 let malformed_error = malformed
3458 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3459 rebootstrap_id: "malformed".to_owned(),
3460 })
3461 .expect_err("malformed receipt must fail closed");
3462 assert_eq!(
3463 malformed_error,
3464 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3465 );
3466 assert!(!malformed.upgrading());
3467 assert_eq!(
3468 malformed
3469 .subscription_state("repair-tasks")
3470 .expect("unchanged subscription")
3471 .cursor,
3472 -1
3473 );
3474 assert!(malformed.drain_change_batches().is_empty());
3475 assert!(malformed.drain_sync_intents().is_empty());
3476
3477 let mut unreadable = client();
3478 unreadable
3479 .conn
3480 .execute(
3481 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3482 rusqlite::params!["server-row", "p1"],
3483 )
3484 .expect("seed visible row");
3485 unreadable
3486 .conn
3487 .execute("DROP TABLE _syncular_meta", [])
3488 .expect("break marker storage");
3489 let unreadable_error = unreadable
3490 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3491 rebootstrap_id: "unreadable".to_owned(),
3492 })
3493 .expect_err("unreadable marker storage must fail closed");
3494 assert_eq!(
3495 unreadable_error,
3496 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3497 );
3498 let visible_rows = unreadable
3499 .conn
3500 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3501 .expect("visible projection remains");
3502 assert_eq!(visible_rows, 1);
3503 assert!(!unreadable.upgrading());
3504 assert!(unreadable.drain_change_batches().is_empty());
3505 assert!(unreadable.drain_sync_intents().is_empty());
3506 }
3507
3508 #[test]
3509 fn local_rebootstrap_cannot_bypass_schema_floor() {
3510 let mut client = client();
3511 client.set_schema_floor(Some(SchemaFloor {
3512 required_schema_version: Some(2),
3513 latest_schema_version: Some(2),
3514 }));
3515 let error = client
3516 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3517 rebootstrap_id: "blocked-floor".to_owned(),
3518 })
3519 .expect_err("schema floor must block repair");
3520 assert!(error.contains("cannot bypass an active schema-floor stop"));
3521 }
3522
3523 #[test]
3524 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3525 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3526 "columns":[{"name":"id","type":"string","nullable":false},
3527 {"name":"project_id","type":"string","nullable":false},
3528 {"name":"starts_at_ms","type":"integer","nullable":false},
3529 {"name":"version","type":"integer","nullable":false}],
3530 "scopes":[{"pattern":"project:{project_id}"}]}]});
3531 let mut client =
3532 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3533 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3534 client
3535 .write_base_row(
3536 "tasks",
3537 &vec![
3538 Some(ColumnValue::String("t1".into())),
3539 Some(ColumnValue::String("p1".into())),
3540 Some(ColumnValue::Integer(100)),
3541 Some(ColumnValue::Integer(1)),
3542 ],
3543 1,
3544 )
3545 .unwrap();
3546 client.rebuild_overlay().expect("rebuild overlay");
3547 let first = client
3548 .patch(
3549 "tasks",
3550 "t1",
3551 Map::from_iter([
3552 ("starts_at_ms".into(), json!(200)),
3553 ("version".into(), json!(2)),
3554 ]),
3555 None,
3556 )
3557 .unwrap();
3558 let (_, first_request) = client.build_request(false).unwrap();
3559 let second = client
3561 .patch(
3562 "tasks",
3563 "t1",
3564 Map::from_iter([
3565 ("starts_at_ms".into(), json!(300)),
3566 ("version".into(), json!(3)),
3567 ]),
3568 None,
3569 )
3570 .unwrap();
3571 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3572 for (id, seq, meta) in [
3573 (first, 2, first_request),
3574 (second, 3, client.build_request(false).unwrap().1),
3575 ] {
3576 let response = Message {
3577 wire_version: WIRE_VERSION,
3578 msg_kind: MsgKind::Response,
3579 frames: vec![
3580 Frame::RespHeader {
3581 required_schema_version: None,
3582 latest_schema_version: None,
3583 log_epoch: Some("epoch-1".into()),
3584 reset_required: Some(false),
3585 },
3586 Frame::PushResult {
3587 client_commit_id: id,
3588 status: PushStatus::Applied,
3589 commit_seq: Some(seq),
3590 results: vec![OpResult::Applied { op_index: 0 }],
3591 },
3592 ],
3593 };
3594 assert!(matches!(
3595 client.process_response(&mut transport, response, &meta),
3596 SyncOutcome::Ok(_)
3597 ));
3598 let row = &client
3599 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3600 .unwrap()[0];
3601 assert_eq!(
3602 row["starts_at_ms"],
3603 json!(300),
3604 "an applied ACK without a pull image must not expose the old base"
3605 );
3606 assert_eq!(
3607 row["version"],
3608 json!(3),
3609 "the queued domain version must remain monotonic"
3610 );
3611 }
3612 }
3613
3614 #[test]
3615 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3616 for stored in [None, Some("epoch-old")] {
3617 let mut client = client();
3618 client
3619 .subscribe(
3620 "tasks".into(),
3621 "tasks".into(),
3622 vec![("project_id".into(), vec!["p1".into()])],
3623 None,
3624 )
3625 .unwrap();
3626 client
3627 .conn
3628 .execute_batch(
3629 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3630 )
3631 .unwrap();
3632 let id = client
3633 .mutate(vec![Mutation::Upsert {
3634 table: "tasks".into(),
3635 values: Map::from_iter([
3636 ("id".into(), json!("offline")),
3637 ("project_id".into(), json!("p1")),
3638 ]),
3639 base_version: None,
3640 }])
3641 .unwrap();
3642 if let Some(epoch) = stored {
3643 client.set_meta(LOG_EPOCH_KEY, epoch);
3644 }
3645 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3646 let (_, meta) = client.build_request(false).unwrap();
3647 client.drain_change_batches();
3648 client.drain_sync_intents();
3649 let response = Message {
3650 wire_version: WIRE_VERSION,
3651 msg_kind: MsgKind::Response,
3652 frames: vec![Frame::RespHeader {
3653 required_schema_version: None,
3654 latest_schema_version: None,
3655 log_epoch: Some("epoch-new".into()),
3656 reset_required: Some(true),
3657 }],
3658 };
3659 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3660 let outcome = client.process_response(&mut transport, response, &meta);
3661 let SyncOutcome::Ok(report) = outcome else {
3662 panic!("epoch round failed: {outcome:?}");
3663 };
3664 assert_eq!(
3665 report.resets,
3666 if stored.is_none() {
3667 Vec::<String>::new()
3668 } else {
3669 vec!["tasks".into()]
3670 }
3671 );
3672 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3673 assert_eq!(client.pending_commit_ids(), vec![id]);
3674 assert_eq!(
3675 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3676 json!("offline")
3677 );
3678 let trigger: i64 = client
3679 .conn
3680 .query_row(
3681 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3682 [],
3683 |row| row.get(0),
3684 )
3685 .unwrap();
3686 if stored.is_none() {
3687 assert!(!client.upgrading);
3688 assert!(client
3689 .drain_change_batches()
3690 .iter()
3691 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3692 assert_eq!(
3693 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3694 before
3695 );
3696 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3697 } else {
3698 assert!(client.upgrading);
3699 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3700 }
3701 assert!(matches!(
3702 client.drain_sync_intents().as_slice(),
3703 [SyncIntent::Interactive]
3704 ));
3705 }
3706 }
3707
3708 #[test]
3709 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3710 for key in ["logEpoch", "localRevision"] {
3711 let mut client = client();
3712 let id = client
3713 .mutate(vec![Mutation::Upsert {
3714 table: "tasks".into(),
3715 values: Map::from_iter([
3716 ("id".into(), json!("offline")),
3717 ("project_id".into(), json!("p1")),
3718 ]),
3719 base_version: None,
3720 }])
3721 .unwrap();
3722 let revision = client.local_revision();
3723 let needed = client.sync_needed;
3724 client.drain_change_batches();
3725 client.drain_sync_intents();
3726 client.conn.execute_batch(&format!("CREATE TRIGGER fail_acquisition BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected acquisition failure'); END")).unwrap();
3727 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3728 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3729 assert!(!client.upgrading);
3730 assert_eq!(client.sync_needed, needed);
3731 assert_eq!(client.local_revision(), revision);
3732 assert_eq!(client.pending_commit_ids(), [id]);
3733 assert!(client.drain_change_batches().is_empty());
3734 assert!(client.drain_sync_intents().is_empty());
3735 }
3736 }
3737
3738 #[test]
3739 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3740 for key in [
3741 "localSchemaVersion",
3742 "localSchemaDescriptor",
3743 "logEpoch",
3744 "localRevision",
3745 ] {
3746 let mut client = client();
3747 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3748 let commit = client
3749 .mutate(vec![Mutation::Upsert {
3750 table: "tasks".into(),
3751 values: Map::from_iter([
3752 ("id".into(), json!("offline")),
3753 ("project_id".into(), json!("p1")),
3754 ]),
3755 base_version: None,
3756 }])
3757 .unwrap();
3758 client.active_round = Some(uuid::Uuid::new_v4());
3759 client.overlay_dirty.set(false);
3760 let round = client.active_round;
3761 let needed = client.sync_needed;
3762 let revision = client.local_revision();
3763 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3764 client.drain_change_batches();
3765 client.drain_sync_intents();
3766 client.conn.execute_batch(&format!("CREATE TRIGGER fail_reset BEFORE INSERT ON _syncular_meta WHEN NEW.key='{key}' BEGIN SELECT RAISE(FAIL,'injected reset failure'); END")).unwrap();
3767 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3768 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3769 assert!(!client.upgrading);
3770 assert_eq!(client.active_round, round);
3771 assert!(!client.overlay_dirty.get());
3772 assert_eq!(client.sync_needed, needed);
3773 assert_eq!(client.local_revision(), revision);
3774 assert_eq!(
3775 client.pending_commit_ids().as_slice(),
3776 std::slice::from_ref(&commit)
3777 );
3778 assert_eq!(
3779 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3780 rows
3781 );
3782 assert!(client.drain_change_batches().is_empty());
3783 assert!(client.drain_sync_intents().is_empty());
3784 assert!(client.conn.is_autocommit());
3785 client
3786 .conn
3787 .execute_batch("DROP TRIGGER fail_reset")
3788 .unwrap();
3789 client.run_log_epoch_reset("epoch-new").unwrap();
3790 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3791 assert_eq!(client.pending_commit_ids(), [commit]);
3792 }
3793 }
3794
3795 #[test]
3796 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3797 for subscriptions in [0, 2, 6, 12] {
3798 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3799 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3800 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3801 ] }), ClientLimits::default()).unwrap();
3802 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3803 client.conn.execute_batch("CREATE TABLE ack_writes(kind TEXT); CREATE TRIGGER count_ack_restore AFTER DELETE ON tasks BEGIN INSERT INTO ack_writes VALUES('restore'); END; CREATE TRIGGER count_ack_replay AFTER INSERT ON tasks BEGIN INSERT INTO ack_writes VALUES('replay'); END;").unwrap();
3804 for index in 0..32 {
3805 client
3806 .mutate(vec![Mutation::Upsert {
3807 table: "tasks".into(),
3808 values: Map::from_iter([
3809 ("id".into(), json!(format!("task-{index}"))),
3810 ("project_id".into(), json!("p1")),
3811 ]),
3812 base_version: None,
3813 }])
3814 .unwrap();
3815 }
3816 let (_, meta) = client.build_request(false).unwrap();
3817 let mut frames = vec![Frame::RespHeader {
3818 required_schema_version: None,
3819 latest_schema_version: None,
3820 log_epoch: Some("epoch-1".into()),
3821 reset_required: Some(false),
3822 }];
3823 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3824 Frame::PushResult {
3825 client_commit_id: id.clone(),
3826 status: PushStatus::Applied,
3827 commit_seq: Some(index as i64 + 1),
3828 results: vec![OpResult::Applied { op_index: 0 }],
3829 }
3830 }));
3831 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3832 client.acknowledged_replay_count.set(0);
3833 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3834 assert!(matches!(
3835 client.process_response(
3836 &mut transport,
3837 Message {
3838 wire_version: WIRE_VERSION,
3839 msg_kind: MsgKind::Response,
3840 frames
3841 },
3842 &meta
3843 ),
3844 SyncOutcome::Ok(_)
3845 ));
3846 assert_eq!(client.acknowledged_replay_count.get(), 32);
3847 assert_eq!(
3848 client
3849 .query(
3850 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3851 &[]
3852 )
3853 .unwrap()[0]["n"],
3854 json!(32)
3855 );
3856 assert_eq!(
3857 client
3858 .query(
3859 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3860 &[]
3861 )
3862 .unwrap()[0]["n"],
3863 json!(32)
3864 );
3865 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3866 client.acknowledged_replay_count.set(0);
3867 for index in 0..subscriptions {
3868 client
3869 .subscribe(
3870 format!("docs-{index}"),
3871 "docs".into(),
3872 vec![("project_id".into(), vec![format!("scope-{index}")])],
3873 None,
3874 )
3875 .unwrap();
3876 let sub = client.subs.len() - 1;
3877 client.subs[sub].effective =
3878 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3879 let segment = RowsSegment {
3880 table: "docs".into(),
3881 schema_version: 1,
3882 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3883 blocks: if index % 2 == 0 {
3884 vec![vec![ssp2::segment::SegmentRow {
3885 values: vec![
3886 Some(ColumnValue::String(format!("doc-{index}"))),
3887 Some(ColumnValue::String(format!("scope-{index}"))),
3888 ],
3889 server_version: 1,
3890 }]]
3891 } else {
3892 vec![]
3893 },
3894 };
3895 assert!(client.apply_segment(sub, &segment, true).is_ok());
3896 }
3897 assert_eq!(
3898 client
3899 .query("SELECT count(*) AS n FROM ack_writes", &[])
3900 .unwrap()[0]["n"],
3901 json!(0),
3902 "unrelated bootstrap must not restore/replay tasks"
3903 );
3904 assert_eq!(
3905 client.acknowledged_replay_count.get(),
3906 0,
3907 "unrelated bootstrap must not decode task ACKs"
3908 );
3909 assert_eq!(
3910 client
3911 .query("SELECT count(*) AS n FROM tasks", &[])
3912 .unwrap()[0]["n"],
3913 json!(32)
3914 );
3915 assert_eq!(
3916 client
3917 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
3918 .unwrap()[0]["n"],
3919 json!(32)
3920 );
3921 }
3922 }
3923
3924 #[test]
3925 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
3926 let mut client = client();
3927 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3928 const COMMIT_COUNT: usize = 32;
3929
3930 for index in 0..COMMIT_COUNT {
3931 client
3932 .mutate(vec![Mutation::Upsert {
3933 table: "tasks".to_owned(),
3934 values: Map::from_iter([
3935 ("id".to_owned(), Value::from(format!("task-{index}"))),
3936 ("project_id".to_owned(), Value::from("project-1")),
3937 ]),
3938 base_version: None,
3939 }])
3940 .expect("queue commit");
3941 }
3942
3943 let (_, request_meta) = client.build_request(false).unwrap();
3944 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
3945 let mut frames = vec![Frame::RespHeader {
3946 required_schema_version: None,
3947 latest_schema_version: None,
3948 log_epoch: Some("epoch-1".to_owned()),
3949 reset_required: Some(false),
3950 }];
3951 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
3952 |(index, client_commit_id)| Frame::PushResult {
3953 client_commit_id: client_commit_id.clone(),
3954 status: PushStatus::Applied,
3955 commit_seq: Some(index as i64 + 1),
3956 results: vec![OpResult::Applied { op_index: 0 }],
3957 },
3958 ));
3959 let response = Message {
3960 wire_version: WIRE_VERSION,
3961 msg_kind: MsgKind::Response,
3962 frames,
3963 };
3964 let mut transport =
3965 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
3966
3967 client.overlay_rebuild_count.set(0);
3968 client.outcome_prune_count.set(0);
3969 let outcome = client.process_response(&mut transport, response, &request_meta);
3970 assert!(matches!(outcome, SyncOutcome::Ok(_)));
3971 assert!(client.pending_commit_ids().is_empty());
3972 assert_eq!(
3973 client.overlay_rebuild_count.get(),
3974 1,
3975 "one response must reconcile its acknowledged commits with one overlay rebuild"
3976 );
3977 assert_eq!(
3978 client.outcome_prune_count.get(),
3979 1,
3980 "one response must enforce outcome retention once"
3981 );
3982 }
3983
3984 #[test]
3985 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
3986 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
3987 "columns": [
3988 {"name":"id", "type":"string", "nullable":false},
3989 {"name":"project_id", "type":"string", "nullable":false},
3990 {"name":"title", "type":"string", "nullable":false}
3991 ], "scopes": [{"pattern":"project:{project_id}"}],
3992 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
3993 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
3994 }] });
3995 let mut incremental =
3996 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3997 let mut reference =
3998 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
3999 let tables = vec!["tasks".to_owned()];
4000 for index in 0..32 {
4001 let id = format!("task-{}", index % 8);
4002 let values = Map::from_iter([
4003 ("id".to_owned(), Value::from(id.clone())),
4004 (
4005 "project_id".to_owned(),
4006 Value::from(format!("p{}", index % 2)),
4007 ),
4008 ("title".to_owned(), Value::from(format!("needle{index}"))),
4009 ]);
4010 let payload = encode_row_json(
4011 incremental.schema.table("tasks").unwrap(),
4012 &id,
4013 &values,
4014 &incremental.encryption,
4015 )
4016 .unwrap();
4017 let change = ssp2::model::Change {
4018 table_index: 0,
4019 row_id: id,
4020 op: if index % 5 == 0 {
4021 Op::Delete
4022 } else {
4023 Op::Upsert
4024 },
4025 row_version: Some(index + 1),
4026 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4027 row: Some(payload),
4028 };
4029 reference.overlay_dirty.set(true);
4030 for instance in [&mut incremental, &mut reference] {
4031 instance.begin_observation("mirror_test").unwrap();
4032 let mut batch = ChangeAccumulator::default();
4033 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4034 instance
4035 .apply_commit_changes(&tables, std::slice::from_ref(&change))
4036 .unwrap();
4037 instance
4038 .rebuild_overlay_if_dirty()
4039 .expect("rebuild overlay");
4040 instance.finish_observation("mirror_test", batch).unwrap();
4041 }
4042 for sql in [
4043 "SELECT * FROM tasks ORDER BY id",
4044 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4045 ] {
4046 assert_eq!(
4047 incremental.query(sql, &[]).unwrap(),
4048 reference.query(sql, &[]).unwrap()
4049 );
4050 }
4051 assert_eq!(incremental.local_revision(), reference.local_revision());
4052 assert_eq!(
4053 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4054 serde_json::to_value(reference.drain_change_batches()).unwrap()
4055 );
4056 }
4057 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4058 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4059 }
4060
4061 #[test]
4062 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4063 let cases = [
4064 (
4065 "string",
4066 vec![
4067 json!("1"),
4068 json!("01"),
4069 json!(""),
4070 json!("nul\u{0}id"),
4071 json!("é"),
4072 ],
4073 ),
4074 (
4075 "integer",
4076 vec![
4077 json!(i64::MIN),
4078 json!(-1),
4079 json!(0),
4080 json!(1),
4081 json!(i64::MAX),
4082 ],
4083 ),
4084 ("boolean", vec![json!(false), json!(true)]),
4085 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4086 ];
4087 for (kind, ids) in cases {
4088 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4089 "columns":[{"name":"id","type":kind,"nullable":false},
4090 {"name":"project_id","type":"string","nullable":false}],
4091 "scopes":[{"pattern":"project:{project_id}"}]}]});
4092 let mut client =
4093 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4094 for (index, id) in ids.iter().enumerate() {
4095 let table = client.schema.table("tasks").unwrap();
4096 client
4097 .write_base_row(
4098 "tasks",
4099 &vec![
4100 if kind == "integer" {
4101 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4104 } else {
4105 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4106 },
4107 Some(ColumnValue::String(format!("p{index}"))),
4108 ],
4109 1,
4110 )
4111 .unwrap();
4112 }
4113 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4114 for base in [true, false] {
4115 let full = if base {
4116 base_table("tasks")
4117 } else {
4118 visible_table("tasks")
4119 };
4120 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4121 for row_id in [
4122 "1",
4123 "01",
4124 "1.0",
4125 "1.0000000000000002",
4126 "+1",
4127 "1e0",
4128 " 1",
4129 "-1",
4130 "0",
4131 "9223372036854775807",
4132 "-9223372036854775808",
4133 "true",
4134 "false",
4135 "",
4136 "nul\u{0}id",
4137 "é",
4138 "null",
4139 "{\"a\":1}",
4140 ] {
4141 let old_sql =
4142 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4143 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4144 let old = client
4145 .conn
4146 .prepare(&old_sql)
4147 .unwrap()
4148 .query_map([row_id], |row| row.get::<_, String>(0))
4149 .unwrap()
4150 .collect::<Result<Vec<_>, _>>()
4151 .unwrap();
4152 let new = client
4153 .conn
4154 .prepare_cached(&new_sql)
4155 .unwrap()
4156 .query_map([row_id], |row| row.get::<_, String>(0))
4157 .unwrap()
4158 .collect::<Result<Vec<_>, _>>()
4159 .unwrap();
4160 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4161 let mut batch = ChangeAccumulator::default();
4162 assert_eq!(
4163 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4164 !old.is_empty()
4165 );
4166 let expected = old.first().map(|scope| {
4167 BTreeMap::from([(
4168 "tasks".to_owned(),
4169 Some(BTreeSet::from([format!("project:{scope}")])),
4170 )])
4171 });
4172 assert_eq!(batch.tables, expected.unwrap_or_default());
4173 client
4174 .conn
4175 .execute_batch("SAVEPOINT lookup_delete")
4176 .unwrap();
4177 if base {
4178 client.delete_base_row("tasks", row_id).unwrap();
4179 } else {
4180 client
4181 .apply_outbox_ops(&[OutboxOp {
4182 upsert: false,
4183 table: "tasks".into(),
4184 row_id: row_id.into(),
4185 base_version: None,
4186 values: None,
4187 }])
4188 .expect("replay delete");
4189 }
4190 let remaining: i64 = client
4191 .conn
4192 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4193 row.get(0)
4194 })
4195 .unwrap();
4196 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4197 client
4198 .conn
4199 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4200 .unwrap();
4201 client.overlay_dirty.set(false);
4202 }
4203 for (predicate, expected) in [
4204 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4205 (predicate, "SEARCH"),
4206 ] {
4207 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4208 let details = client
4209 .conn
4210 .prepare(&sql)
4211 .unwrap()
4212 .query_map(["1"], |row| row.get::<_, String>(3))
4213 .unwrap()
4214 .collect::<Result<Vec<_>, _>>()
4215 .unwrap();
4216 assert!(
4217 details.iter().any(|detail| detail.contains(expected)),
4218 "{kind}: {details:?}"
4219 );
4220 }
4221 }
4222 }
4223 }
4224
4225 #[test]
4226 fn primary_keys_reject_every_ineligible_column_type() {
4227 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4231 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4232 "columns":[{"name":"id","type":ty,"nullable":false},
4233 {"name":"project_id","type":"string","nullable":false}],
4234 "scopes":[{"pattern":"project:{project_id}"}]}]});
4235 let error =
4236 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4237 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4238 Err(error) => error,
4239 };
4240 assert!(
4241 error.contains("has an ineligible column type (§2.4)"),
4242 "{ty}: {error}"
4243 );
4244 }
4245 for ty in ["string", "integer", "boolean", "json"] {
4246 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4247 "columns":[{"name":"id","type":ty,"nullable":false},
4248 {"name":"project_id","type":"string","nullable":false}],
4249 "scopes":[{"pattern":"project:{project_id}"}]}]});
4250 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4251 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4252 }
4253 }
4254
4255 #[test]
4256 fn pending_row_reconciliation_matches_full_fifo_replay() {
4257 for unique in [false, true] {
4258 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4259 "name": name, "primaryKey": "id", "columns": [
4260 {"name":"id", "type":"string", "nullable":false},
4261 {"name":"project_id", "type":"string", "nullable":false},
4262 {"name":"title", "type":"string", "nullable":false}],
4263 "scopes": [{"pattern":"project:{project_id}"}],
4264 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4265 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4266 })))});
4267 let mut incremental =
4268 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4269 let mut reference =
4270 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4271 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4272 for instance in [&mut incremental, &mut reference] {
4273 for table in &tables {
4274 for index in 0..12 {
4275 instance
4276 .write_base_row(
4277 table,
4278 &vec![
4279 Some(ColumnValue::String(format!("row{index}"))),
4280 Some(ColumnValue::String("p0".into())),
4281 Some(ColumnValue::String(format!("base{index}"))),
4282 ],
4283 1,
4284 )
4285 .unwrap();
4286 }
4287 }
4288 instance
4289 .rebuild_overlay_if_dirty()
4290 .expect("rebuild overlay");
4291 }
4292 for index in 0..80 {
4293 let mutations = (0..3)
4294 .map(|offset| {
4295 let table = tables[(index + offset) % 2].clone();
4296 let id = format!("row{}", (index + offset) % 16);
4297 if (index + offset) % 7 == 0 {
4298 Mutation::Delete {
4299 table,
4300 row_id: id,
4301 base_version: None,
4302 }
4303 } else {
4304 Mutation::Upsert {
4305 table,
4306 values: Map::from_iter([
4307 ("id".into(), Value::from(id)),
4308 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4309 (
4310 "title".into(),
4311 Value::from(format!("needle{}", (index + offset) % 16)),
4312 ),
4313 ]),
4314 base_version: None,
4315 }
4316 }
4317 })
4318 .collect::<Vec<_>>();
4319 for instance in [&mut incremental, &mut reference] {
4320 instance.mutate(mutations.clone()).unwrap();
4321 }
4322 let changes = (0..3)
4325 .map(|offset| {
4326 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4327 let id = format!("row{}", (index + offset) % 16);
4328 let values = Map::from_iter([
4329 ("id".into(), Value::from(id.clone())),
4330 (
4331 "project_id".into(),
4332 Value::from(format!("p{}", (index + 1) % 3)),
4333 ),
4334 (
4335 "title".into(),
4336 Value::from(format!("remote{index}_{offset}")),
4337 ),
4338 ]);
4339 ssp2::model::Change {
4340 table_index: table_index as u16,
4341 row_id: id.clone(),
4342 op: if (index + offset) % 5 == 0 {
4343 Op::Delete
4344 } else {
4345 Op::Upsert
4346 },
4347 row_version: Some(index as i64 + 2),
4348 scopes: vec![],
4349 row: Some(
4350 encode_row_json(
4351 incremental.schema.table(&tables[table_index]).unwrap(),
4352 &id,
4353 &values,
4354 &incremental.encryption,
4355 )
4356 .unwrap(),
4357 ),
4358 }
4359 })
4360 .collect::<Vec<_>>();
4361 reference.overlay_dirty.set(true);
4362 incremental
4363 .apply_commit_frame(&tables, &changes, None)
4364 .unwrap();
4365 reference
4366 .apply_commit_frame(&tables, &changes, None)
4367 .unwrap();
4368 for table in &tables {
4369 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4370 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4371 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4372 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4373 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4374 }
4375 }
4376 assert_eq!(incremental.local_revision(), reference.local_revision());
4377 assert_eq!(
4378 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4379 serde_json::to_value(reference.drain_change_batches()).unwrap()
4380 );
4381 }
4382 assert_eq!(
4383 incremental.overlay_rebuild_count.get(),
4384 if unique { 81 } else { 21 }
4385 );
4386 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4387 }
4388 }
4389
4390 #[test]
4391 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4392 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4393 "columns":[{"name":"id", "type":"string", "nullable":false},
4394 {"name":"title", "type":"string", "nullable":false}],
4395 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4396 let mut client =
4397 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4398 for (id, title) in [("a", "original"), ("b", "occupied")] {
4399 client
4400 .write_base_row(
4401 "tasks",
4402 &vec![
4403 Some(ColumnValue::String(id.into())),
4404 Some(ColumnValue::String(title.into())),
4405 ],
4406 1,
4407 )
4408 .unwrap();
4409 }
4410 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4411 let pending = client
4412 .mutate(vec![Mutation::Upsert {
4413 table: "tasks".into(),
4414 values: Map::from_iter([
4415 ("id".into(), Value::from("a")),
4416 ("title".into(), Value::from("available")),
4417 ]),
4418 base_version: None,
4419 }])
4420 .unwrap();
4421 assert_eq!(
4422 client
4423 .conn
4424 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4425 .get::<_, String>(
4426 0
4427 ))
4428 .unwrap(),
4429 "available"
4430 );
4431 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4432 let change = ssp2::model::Change {
4433 table_index: 0,
4434 row_id: "b".into(),
4435 op: Op::Upsert,
4436 row_version: Some(2),
4437 scopes: vec![],
4438 row: Some(
4439 encode_row_json(
4440 client.schema.table("tasks").unwrap(),
4441 "b",
4442 &Map::from_iter([
4443 ("id".into(), Value::from("b")),
4444 ("title".into(), Value::from(title)),
4445 ]),
4446 &client.encryption,
4447 )
4448 .unwrap(),
4449 ),
4450 };
4451 client
4452 .apply_commit_frame(&["tasks".into()], &[change], None)
4453 .unwrap();
4454 assert_eq!(
4455 client
4456 .conn
4457 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4458 .get::<_, String>(
4459 0
4460 ))
4461 .unwrap(),
4462 expected
4463 );
4464 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4465 }
4466 assert_eq!(client.overlay_rebuild_count.get(), 3);
4467 }
4468
4469 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4470 let schema = json!({ "version": 1, "tables": [
4471 { "name":"tasks", "primaryKey":"id", "columns":[
4472 {"name":"id","type":"string","nullable":false},
4473 {"name":"project_id","type":"string","nullable":false}],
4474 "scopes":[{"pattern":"project:{project_id}"}] },
4475 { "name":"catalogue", "primaryKey":"id", "columns":[
4476 {"name":"id","type":"string","nullable":false},
4477 {"name":"project_id","type":"string","nullable":false},
4478 {"name":"title","type":"string","nullable":false}],
4479 "scopes":[{"pattern":"project:{project_id}"}],
4480 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4481 ] });
4482 let mut client =
4483 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4484 if unchanged_rows > 0 {
4485 client.conn.execute_batch(&format!("WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i<{unchanged_rows}) INSERT INTO _syncular_base_catalogue SELECT 'c'||i,'p1','needle '||i,1 FROM n")).unwrap();
4486 }
4487 client.rebuild_overlay().expect("rebuild overlay");
4488 client.conn.execute_batch("CREATE TABLE audit(tbl TEXT); CREATE TRIGGER catalogue_changed AFTER DELETE ON catalogue BEGIN INSERT INTO audit VALUES ('catalogue'); END;").unwrap();
4489 let mut elapsed = Vec::new();
4490 for round in 1..=rounds {
4491 let pending = client
4492 .mutate(vec![Mutation::Upsert {
4493 table: "tasks".into(),
4494 values: Map::from_iter([
4495 ("id".into(), json!("t1")),
4496 ("project_id".into(), json!("p1")),
4497 ]),
4498 base_version: None,
4499 }])
4500 .unwrap();
4501 let started = std::time::Instant::now();
4502 client
4503 .write_base_row(
4504 "tasks",
4505 &vec![
4506 Some(ColumnValue::String("t1".into())),
4507 Some(ColumnValue::String("p1".into())),
4508 ],
4509 round,
4510 )
4511 .unwrap();
4512 let mut report = SyncReport::default();
4513 client
4514 .handle_push_results(
4515 &[Frame::PushResult {
4516 client_commit_id: pending.clone(),
4517 status: PushStatus::Applied,
4518 commit_seq: Some(round),
4519 results: vec![OpResult::Applied { op_index: 0 }],
4520 }],
4521 &HashSet::from([pending.as_str()]),
4522 &HashMap::new(),
4523 &mut report,
4524 Some(&pending),
4525 )
4526 .unwrap();
4527 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4528 elapsed.push(started.elapsed().as_nanos());
4529 }
4530 assert_eq!(
4531 client
4532 .conn
4533 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4534 .unwrap(),
4535 0,
4536 "unchanged catalogue must never be copied"
4537 );
4538 assert_eq!(
4539 client
4540 .conn
4541 .query_row(
4542 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4543 [],
4544 |r| r.get::<_, i64>(0)
4545 )
4546 .unwrap(),
4547 unchanged_rows
4548 );
4549 assert_eq!(
4550 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4551 json!(rounds)
4552 );
4553 elapsed
4554 }
4555
4556 #[test]
4557 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4558 let elapsed = measure_scoped_overlay(100_000, 1);
4559 eprintln!(
4560 "table-scoped overlay with 100k unchanged rows: {} ns",
4561 elapsed[0]
4562 );
4563 }
4564
4565 #[test]
4566 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4567 fn benchmark_scoped_overlay() {
4568 for rows in [0, 100_000, 200_000] {
4569 let mut elapsed = measure_scoped_overlay(rows, 30);
4570 elapsed.sort_unstable();
4571 eprintln!(
4572 "{}",
4573 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4574 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4575 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4576 );
4577 }
4578 }
4579
4580 #[test]
4581 fn released_context_discards_in_flight_ack_and_server_rows() {
4582 for barrier in [
4583 "preflight",
4584 "purge",
4585 "rebootstrap",
4586 "subscription",
4587 "failed-purge-replay",
4588 "failed-purge-publish",
4589 "failed-rebootstrap",
4590 ] {
4591 let mut client = client();
4592 client.set_meta(LOG_EPOCH_KEY, "epoch");
4593 client
4594 .subscribe(
4595 "tasks".into(),
4596 "tasks".into(),
4597 vec![("project_id".into(), vec!["p1".into()])],
4598 None,
4599 )
4600 .unwrap();
4601 let commit = client
4602 .mutate(vec![Mutation::Upsert {
4603 table: "tasks".into(),
4604 values: Map::from_iter([
4605 ("id".into(), json!("local")),
4606 ("project_id".into(), json!("p1")),
4607 ]),
4608 base_version: None,
4609 }])
4610 .unwrap();
4611 let prepared = client.prepare_sync_round(false).unwrap();
4612 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4613 let payload = encode_row_json(
4614 &client.schema.tables[0],
4615 "server",
4616 &Map::from_iter([
4617 ("id".into(), json!("server")),
4618 ("project_id".into(), json!("p1")),
4619 ]),
4620 &client.encryption,
4621 )
4622 .unwrap();
4623 let completed = crate::CompletedSyncRound {
4624 prepared,
4625 exchange: crate::round::ExchangeResult::Reply {
4626 transport_failed: false,
4627 downloads: Default::default(),
4628 response: Ok(Message {
4629 wire_version: WIRE_VERSION,
4630 msg_kind: MsgKind::Response,
4631 frames: vec![
4632 Frame::RespHeader {
4633 required_schema_version: None,
4634 latest_schema_version: None,
4635 log_epoch: Some("epoch".into()),
4636 reset_required: Some(false),
4637 },
4638 Frame::PushResult {
4639 client_commit_id: commit.clone(),
4640 status: PushStatus::Applied,
4641 commit_seq: Some(1),
4642 results: vec![OpResult::Applied { op_index: 0 }],
4643 },
4644 Frame::SubStart {
4645 id: "tasks".into(),
4646 status: SubStatus::Active,
4647 reason_code: String::new(),
4648 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4649 bootstrap: true,
4650 },
4651 Frame::Commit {
4652 commit_seq: 1,
4653 created_at_ms: 0,
4654 actor_id: "actor".into(),
4655 tables: vec!["tasks".into()],
4656 changes: vec![ssp2::model::Change {
4657 table_index: 0,
4658 row_id: "server".into(),
4659 op: Op::Upsert,
4660 row_version: Some(1),
4661 scopes: vec![("project_id".into(), "p1".into())],
4662 row: Some(payload),
4663 }],
4664 },
4665 Frame::SubEnd {
4666 next_cursor: 1,
4667 bootstrap_state: None,
4668 },
4669 ],
4670 }),
4671 },
4672 };
4673 match barrier {
4674 "preflight" => client.begin_security_preflight(),
4675 "purge" => {
4676 client
4677 .purge_local_data(&LocalDataPurgeInput {
4678 purge_id: "pending-round".into(),
4679 targets: vec![LocalDataPurgeTarget {
4680 table: "tasks".into(),
4681 selectors: BTreeMap::from([(
4682 "project_id".into(),
4683 vec!["p1".into()],
4684 )]),
4685 }],
4686 })
4687 .unwrap();
4688 }
4689 "rebootstrap" => {
4690 client
4691 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4692 rebootstrap_id: "pending-round".into(),
4693 })
4694 .unwrap();
4695 }
4696 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4697 let trigger = if barrier == "failed-purge-replay" {
4698 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4699 } else {
4700 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4701 };
4702 client.conn.execute_batch(trigger).unwrap();
4703 let revision = client.local_revision();
4704 if barrier == "failed-rebootstrap" {
4705 assert!(client
4706 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4707 rebootstrap_id: "failed-round".into(),
4708 })
4709 .is_err());
4710 } else {
4711 assert!(client
4712 .purge_local_data(&LocalDataPurgeInput {
4713 purge_id: "failed-round".into(),
4714 targets: vec![LocalDataPurgeTarget {
4715 table: "tasks".into(),
4716 selectors: BTreeMap::from([(
4717 "project_id".into(),
4718 vec!["p1".into()]
4719 )]),
4720 }],
4721 })
4722 .is_err());
4723 }
4724 assert_eq!(client.local_revision(), revision);
4725 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4726 client
4727 .conn
4728 .execute_batch("DROP TRIGGER fail_local")
4729 .unwrap();
4730 let applied = client.apply_sync_round(completed);
4731 assert!(
4732 matches!(
4733 applied,
4734 crate::AppliedSyncRound::Complete {
4735 outcome: SyncOutcome::Ok(_),
4736 ..
4737 }
4738 ),
4739 "{barrier}"
4740 );
4741 assert!(client.pending_commit_ids().is_empty());
4742 assert_eq!(
4743 client
4744 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4745 .unwrap()
4746 .len(),
4747 1
4748 );
4749 continue;
4750 }
4751 "subscription" => {
4752 client.unsubscribe("tasks");
4753 client
4754 .subscribe(
4755 "tasks".into(),
4756 "tasks".into(),
4757 vec![("project_id".into(), vec!["p2".into()])],
4758 None,
4759 )
4760 .unwrap();
4761 }
4762 _ => unreachable!(),
4763 }
4764 let pending_before_reply = client.pending_commit_ids();
4765 let revision = client.local_revision();
4766 let applied = client.apply_sync_round(completed);
4767 assert!(
4768 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4769 );
4770 assert_eq!(client.local_revision(), revision);
4771 assert!(client
4772 .conn
4773 .query_row(
4774 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4775 [],
4776 |r| r.get::<_, bool>(0)
4777 )
4778 .unwrap());
4779 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4780 }
4781 }
4782
4783 #[test]
4784 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4785 struct DeadlineRecording {
4786 budget: std::time::Duration,
4787 seen: Vec<Option<std::time::Instant>>,
4788 }
4789 impl Transport for DeadlineRecording {
4790 fn round_deadline(&self) -> Option<std::time::Duration> {
4791 Some(self.budget)
4792 }
4793 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4794 self.seen.push(deadline);
4795 }
4796 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4797 Err(TransportError::new("transport.failed", "injected"))
4798 }
4799 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4800 self.sync(request)
4801 }
4802 fn download_segment(
4803 &mut self,
4804 _: &SegmentRequest,
4805 _: &mut dyn FnMut(u64),
4806 ) -> Result<Vec<u8>, TransportError> {
4807 Err(TransportError::new("transport.failed", "injected"))
4808 }
4809 fn blob_upload_grant(
4810 &mut self,
4811 _: &str,
4812 _: u64,
4813 _: Option<&str>,
4814 ) -> Result<crate::BlobUploadGrant, TransportError> {
4815 Ok(crate::BlobUploadGrant::Present)
4816 }
4817 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4818 Ok(())
4819 }
4820 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4821 Ok(())
4822 }
4823 fn realtime_close(&mut self) -> Result<(), TransportError> {
4824 Ok(())
4825 }
4826 }
4827
4828 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4829 {"name":"id","type":"string","nullable":false},
4830 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4831 let mut client =
4832 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4833 client.set_meta(LOG_EPOCH_KEY, "epoch");
4834 client
4835 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4836 .unwrap();
4837
4838 let mut transport = DeadlineRecording {
4839 budget: std::time::Duration::from_secs(30),
4840 seen: Vec::new(),
4841 };
4842 let prepared = client.prepare_sync_round(false).unwrap();
4843 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4844 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4846 crate::AppliedSyncRound::Continue(prepared) => prepared,
4847 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4848 };
4849 let completed = prepared.exchange(&mut transport);
4851 let anchor = completed
4852 .prepared
4853 .round_deadline_at
4854 .expect("the upload round anchors a deadline");
4855 assert!(matches!(
4856 client.apply_sync_round(completed),
4857 crate::AppliedSyncRound::Complete { .. }
4858 ));
4859 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4860 assert_eq!(
4861 transport.seen[0],
4862 Some(anchor),
4863 "the upload anchors the round"
4864 );
4865 assert_eq!(
4866 transport.seen[1], None,
4867 "the upload exchange clears its scope"
4868 );
4869 assert_eq!(
4870 transport.seen[2],
4871 Some(anchor),
4872 "a continuation reuses the carried anchor instead of refreshing it"
4873 );
4874 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4875
4876 transport.budget = std::time::Duration::from_secs(60);
4879 let prepared = client.prepare_sync_round(false).unwrap();
4880 let completed = prepared.exchange(&mut transport);
4881 let fresh = completed
4882 .prepared
4883 .round_deadline_at
4884 .expect("the next round anchors a deadline");
4885 client.apply_sync_round(completed);
4886 assert!(
4887 fresh >= anchor + std::time::Duration::from_secs(30),
4888 "the next round anchors a fresh deadline from the new budget"
4889 );
4890 assert_eq!(transport.seen[4], Some(fresh));
4891 assert_eq!(
4892 transport.seen[5], None,
4893 "the new round clears its scope too"
4894 );
4895 }
4896
4897 #[derive(Default)]
4900 struct RoundDeadlineProbe {
4901 budget: Option<std::time::Duration>,
4902 budget_queries: std::cell::Cell<usize>,
4903 scoped: Vec<Option<std::time::Instant>>,
4904 }
4905
4906 impl Transport for RoundDeadlineProbe {
4907 fn round_deadline(&self) -> Option<std::time::Duration> {
4908 self.budget_queries.set(self.budget_queries.get() + 1);
4909 self.budget
4910 }
4911 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4912 self.scoped.push(deadline);
4913 }
4914 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4915 Err(TransportError::new("transport.failed", "injected"))
4916 }
4917 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4918 self.sync(request)
4919 }
4920 fn download_segment(
4921 &mut self,
4922 _: &SegmentRequest,
4923 _: &mut dyn FnMut(u64),
4924 ) -> Result<Vec<u8>, TransportError> {
4925 Err(TransportError::new("transport.failed", "injected"))
4926 }
4927 fn blob_upload_grant(
4928 &mut self,
4929 _: &str,
4930 _: u64,
4931 _: Option<&str>,
4932 ) -> Result<crate::BlobUploadGrant, TransportError> {
4933 Ok(crate::BlobUploadGrant::Present)
4934 }
4935 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4936 Ok(())
4937 }
4938 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4939 Ok(())
4940 }
4941 fn realtime_close(&mut self) -> Result<(), TransportError> {
4942 Ok(())
4943 }
4944 }
4945
4946 #[test]
4947 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
4948 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4949 {"name":"id","type":"string","nullable":false},
4950 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4951 let mut client =
4952 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
4953 client.set_meta(LOG_EPOCH_KEY, "epoch");
4954 client
4955 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4956 .unwrap();
4957 client
4958 .conn
4959 .execute_batch("DELETE FROM _syncular_blobs")
4960 .unwrap();
4961 let prepared = client.prepare_sync_round(false).unwrap();
4962 assert!(
4963 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
4964 "the staged body must fail locally"
4965 );
4966 let mut transport = RoundDeadlineProbe {
4967 budget: Some(std::time::Duration::from_secs(30)),
4968 ..Default::default()
4969 };
4970 let completed = prepared.exchange(&mut transport);
4971 assert!(matches!(
4972 completed.exchange,
4973 crate::round::ExchangeResult::Upload { result: Err(_), .. }
4974 ));
4975 assert_eq!(
4976 transport.budget_queries.get(),
4977 0,
4978 "a round with no network work never asks for a budget"
4979 );
4980 assert_eq!(transport.scoped, vec![None]);
4981 }
4982
4983 #[test]
4984 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
4985 struct HugeBudget;
4986 impl Transport for HugeBudget {
4987 fn round_deadline(&self) -> Option<std::time::Duration> {
4988 Some(std::time::Duration::MAX)
4989 }
4990 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4991 panic!("no network for an invalid budget")
4992 }
4993 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4994 self.sync(request)
4995 }
4996 fn download_segment(
4997 &mut self,
4998 _: &SegmentRequest,
4999 _: &mut dyn FnMut(u64),
5000 ) -> Result<Vec<u8>, TransportError> {
5001 panic!("no network for an invalid budget")
5002 }
5003 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5004 Ok(())
5005 }
5006 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5007 Ok(())
5008 }
5009 fn realtime_close(&mut self) -> Result<(), TransportError> {
5010 Ok(())
5011 }
5012 }
5013
5014 let mut client = client();
5015 client.set_meta(LOG_EPOCH_KEY, "epoch");
5016 let prepared = client.prepare_sync_round(false).unwrap();
5017 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5018 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5019 crate::AppliedSyncRound::Continue(_) => {
5020 panic!("an unrepresentable budget must not continue")
5021 }
5022 };
5023 match outcome {
5024 SyncOutcome::Failed {
5025 error_code,
5026 message,
5027 ..
5028 } => {
5029 assert_eq!(error_code, "sync.invalid_request");
5030 assert_eq!(message, "transport round deadline is out of range");
5031 }
5032 _ => panic!("an unrepresentable budget must fail synchronously"),
5033 }
5034 }
5035
5036 #[test]
5037 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5038 for failure in ["visible", "revision", "commit"] {
5039 let mut client = client();
5040 let id = client
5041 .mutate(vec![Mutation::Upsert {
5042 table: "tasks".into(),
5043 values: Map::from_iter([
5044 ("id".into(), Value::from("t1")),
5045 ("project_id".into(), Value::from("pending")),
5046 ]),
5047 base_version: None,
5048 }])
5049 .unwrap();
5050 client.drain_change_batches();
5051 let revision = client.local_revision();
5052 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5053 let trigger = match failure {
5054 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5055 "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"),
5056 _ => "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(),
5057 };
5058 client.conn.execute_batch(&trigger).unwrap();
5059 let change = ssp2::model::Change {
5060 table_index: 0,
5061 row_id: "t1".into(),
5062 op: Op::Upsert,
5063 row_version: Some(2),
5064 scopes: vec![],
5065 row: Some(
5066 encode_row_json(
5067 client.schema.table("tasks").unwrap(),
5068 "t1",
5069 &Map::from_iter([
5070 ("id".into(), Value::from("t1")),
5071 ("project_id".into(), Value::from("remote")),
5072 ]),
5073 &client.encryption,
5074 )
5075 .unwrap(),
5076 ),
5077 };
5078 assert!(client
5079 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5080 .is_err());
5081 assert!(client.conn.is_autocommit());
5082 assert!(!client.overlay_dirty.get());
5083 assert_eq!(client.local_revision(), revision);
5084 assert!(client.drain_change_batches().is_empty());
5085 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5086 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5087 assert!(client
5088 .query("SELECT * FROM _syncular_base_tasks", &[])
5089 .unwrap()
5090 .is_empty());
5091 client
5092 .conn
5093 .execute_batch("DROP TRIGGER fail_pending")
5094 .unwrap();
5095 client
5096 .apply_commit_frame(&["tasks".into()], &[change], None)
5097 .unwrap();
5098 assert_eq!(client.local_revision(), revision + 1);
5099 assert_eq!(client.pending_commit_ids(), vec![id]);
5100 let mut expected = before[0].clone();
5103 expected.insert("_syncular_version".to_owned(), Value::from(2));
5104 assert_eq!(
5105 client.query("SELECT * FROM tasks", &[]).unwrap(),
5106 vec![expected]
5107 );
5108 assert_eq!(
5109 client
5110 .conn
5111 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5112 .get::<_, String>(0))
5113 .unwrap(),
5114 "remote"
5115 );
5116 }
5117 }
5118
5119 #[test]
5120 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5121 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5122 "columns": [
5123 {"name":"id", "type":"string", "nullable":false},
5124 {"name":"project_id", "type":"string", "nullable":false},
5125 {"name":"title", "type":"string", "nullable":false}],
5126 "scopes": [{"pattern":"project:{project_id}"}],
5127 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5128 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5129 let mut incremental =
5130 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5131 let mut reference =
5132 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5133 for instance in [&mut incremental, &mut reference] {
5134 for index in 0..16 {
5135 instance
5136 .write_base_row(
5137 "tasks",
5138 &vec![
5139 Some(ColumnValue::String(format!("task{index}"))),
5140 Some(ColumnValue::String("p0".to_owned())),
5141 Some(ColumnValue::String(format!("base{index}"))),
5142 ],
5143 1,
5144 )
5145 .unwrap();
5146 }
5147 instance
5148 .rebuild_overlay_if_dirty()
5149 .expect("rebuild overlay");
5150 instance.overlay_rebuild_count.set(0);
5151 }
5152 for index in 0..100 {
5153 let mutations = (0..1 + index % 4)
5154 .map(|offset| {
5155 let id = format!("task{}", (index + offset) % 24);
5156 if (index + offset) % 7 == 0 {
5157 Mutation::Delete {
5158 table: "tasks".to_owned(),
5159 row_id: id,
5160 base_version: None,
5161 }
5162 } else {
5163 Mutation::Upsert {
5164 table: "tasks".to_owned(),
5165 values: Map::from_iter([
5166 ("id".to_owned(), Value::from(id)),
5167 (
5168 "project_id".to_owned(),
5169 Value::from(format!("p{}", index % 2)),
5170 ),
5171 (
5172 "title".to_owned(),
5173 Value::from(format!("needle{}", (index + offset) % 24)),
5174 ),
5175 ]),
5176 base_version: None,
5177 }
5178 }
5179 })
5180 .collect::<Vec<_>>();
5181 reference.overlay_dirty.set(true);
5182 incremental.mutate(mutations.clone()).unwrap();
5183 reference.mutate(mutations).unwrap();
5184 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5185 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5186 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5187 }
5188 assert_eq!(incremental.local_revision(), reference.local_revision());
5189 assert_eq!(
5190 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5191 serde_json::to_value(reference.drain_change_batches()).unwrap()
5192 );
5193 }
5194 assert_eq!(incremental.outbox.len(), 100);
5195 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5196 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5197 }
5198
5199 #[test]
5200 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5201 for fail_at in ["outbox", "revision"] {
5202 let mut client = client();
5203 let values = Map::from_iter([
5204 ("id".to_owned(), Value::from("t1")),
5205 ("project_id".to_owned(), Value::from("p1")),
5206 ]);
5207 let mutation = Mutation::Upsert {
5208 table: "tasks".to_owned(),
5209 values,
5210 base_version: None,
5211 };
5212 let trigger = if fail_at == "outbox" {
5213 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5214 } else {
5215 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5216 };
5217 client.conn.execute_batch(&trigger).unwrap();
5218 let revision = client.local_revision();
5219 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5220 assert_eq!(client.local_revision(), revision);
5221 assert!(client.outbox.is_empty());
5222 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5223 assert_eq!(
5224 client
5225 .conn
5226 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5227 .get::<_, i64>(0))
5228 .unwrap(),
5229 0
5230 );
5231 assert!(client.drain_change_batches().is_empty());
5232 assert!(!client.overlay_dirty.get());
5233 client
5234 .conn
5235 .execute_batch("DROP TRIGGER fail_append")
5236 .unwrap();
5237 client.mutate(vec![mutation]).unwrap();
5238 assert_eq!(client.outbox.len(), 1);
5239 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5240 }
5241 }
5242
5243 #[test]
5244 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5245 let mut client = client();
5246 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5247 let values = Map::from_iter([
5248 ("id".to_owned(), Value::from("t1")),
5249 ("project_id".to_owned(), Value::from("p1")),
5250 ]);
5251 let payload = encode_row_json(
5252 client.schema.table("tasks").unwrap(),
5253 "t1",
5254 &values,
5255 &client.encryption,
5256 )
5257 .unwrap();
5258 let first = ssp2::model::Change {
5259 table_index: 0,
5260 row_id: "t1".to_owned(),
5261 op: Op::Upsert,
5262 row_version: Some(1),
5263 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5264 row: Some(payload),
5265 };
5266 let mut invalid = first.clone();
5267 invalid.table_index = 1;
5268 let revision = client.local_revision();
5269 client.begin_observation("failed_mirror").unwrap();
5270 assert!(client
5271 .apply_commit_changes(&tables, &[first, invalid])
5272 .is_err());
5273 client.rollback_observation("failed_mirror");
5274 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5275 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5276 assert_eq!(
5277 client
5278 .conn
5279 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5280 .get::<_, i64>(
5281 0
5282 ))
5283 .unwrap(),
5284 0
5285 );
5286 assert_eq!(client.local_revision(), revision);
5287 assert!(client.drain_change_batches().is_empty());
5288 }
5289
5290 #[test]
5291 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5292 for failure in ["journal", "revision", "commit", "later-frame"] {
5293 let mut client = client();
5294 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5295 for id in ["first", "second"] {
5296 client
5297 .mutate(vec![Mutation::Upsert {
5298 table: "tasks".to_owned(),
5299 values: Map::from_iter([
5300 ("id".to_owned(), Value::from(id)),
5301 ("project_id".to_owned(), Value::from("p1")),
5302 ]),
5303 base_version: None,
5304 }])
5305 .unwrap();
5306 }
5307 let (_, meta) = client.build_request(false).unwrap();
5308 client
5310 .mutate(vec![Mutation::Upsert {
5311 table: "tasks".to_owned(),
5312 values: Map::from_iter([
5313 ("id".to_owned(), Value::from("later")),
5314 ("project_id".to_owned(), Value::from("p1")),
5315 ]),
5316 base_version: None,
5317 }])
5318 .unwrap();
5319 let ids = client.pending_commit_ids();
5320 let rows = client
5321 .query("SELECT * FROM tasks ORDER BY id", &[])
5322 .unwrap();
5323 let revision = client.local_revision();
5324 client.drain_change_batches();
5325 if failure != "later-frame" {
5326 client.conn.execute_batch(match failure {
5327 "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",
5328 "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",
5329 _ => "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",
5330 }).unwrap();
5331 }
5332 let mut response = Message {
5333 wire_version: WIRE_VERSION,
5334 msg_kind: MsgKind::Response,
5335 frames: vec![Frame::RespHeader {
5336 required_schema_version: None,
5337 latest_schema_version: None,
5338 log_epoch: Some("epoch-1".to_owned()),
5339 reset_required: Some(false),
5340 }],
5341 };
5342 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5343 response.frames.push(Frame::PushResult {
5344 client_commit_id: id.to_owned(),
5345 status: PushStatus::Applied,
5346 commit_seq: Some(1),
5347 results: vec![OpResult::Applied { op_index: 0 }],
5348 });
5349 }
5350 if failure == "later-frame" {
5351 response.frames.push(Frame::Error {
5352 code: "sync.invalid_request".to_owned(),
5353 message: "later error".to_owned(),
5354 category: "protocol".to_owned(),
5355 retryable: false,
5356 recommended_action: "retry".to_owned(),
5357 details: None,
5358 });
5359 }
5360 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5361 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5362 let expected = match failure {
5365 "later-frame" => "sync.invalid_request",
5366 "commit" => "storage.failed",
5367 _ => "client.outcome_persistence_failed",
5368 };
5369 assert!(
5370 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5371 "{failure}: {outcome:?}"
5372 );
5373 if failure == "later-frame" {
5374 assert_eq!(client.pending_commit_ids(), ids[2..]);
5375 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5376 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5377 let batches = client.drain_change_batches();
5378 assert_eq!(batches.len(), 1);
5379 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5380 assert_eq!(client.local_revision(), revision + 1);
5381 response.frames.pop();
5382 } else {
5383 assert_eq!(client.pending_commit_ids(), ids);
5384 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5385 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5386 assert_eq!(client.local_revision(), revision);
5387 assert!(client.drain_change_batches().is_empty());
5388 assert_eq!(
5389 client
5390 .query("SELECT * FROM tasks ORDER BY id", &[])
5391 .unwrap(),
5392 rows
5393 );
5394 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5395 }
5396 for frame in &mut response.frames {
5397 if let Frame::PushResult { status, .. } = frame {
5398 *status = PushStatus::Cached;
5399 }
5400 }
5401 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5402 else {
5403 panic!("retry failed");
5404 };
5405 assert_eq!(
5406 report.applied,
5407 if failure == "later-frame" {
5408 vec![]
5409 } else {
5410 ids[..2].to_vec()
5411 }
5412 );
5413 assert_eq!(client.pending_commit_ids(), ids[2..]);
5414 let batches = client.drain_change_batches();
5415 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5416 if let Some(batch) = batches.first() {
5417 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5418 }
5419 assert_eq!(client.local_revision(), revision + 1);
5420 }
5421 }
5422
5423 #[test]
5424 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5425 for failure in ["revision", "journal", "commit"] {
5426 for status in ["applied", "cached", "rejected", "conflict"] {
5427 let mut client = client();
5428 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5429 for id in ["first", "later"] {
5430 client
5431 .mutate(vec![Mutation::Upsert {
5432 table: "tasks".to_owned(),
5433 values: Map::from_iter([
5434 ("id".to_owned(), Value::from(id)),
5435 ("project_id".to_owned(), Value::from("p1")),
5436 ]),
5437 base_version: None,
5438 }])
5439 .unwrap();
5440 }
5441 let (_, meta) = client.build_request(false).unwrap();
5442 let ids = client.pending_commit_ids();
5443 let rows = client
5444 .query("SELECT * FROM tasks ORDER BY id", &[])
5445 .unwrap();
5446 let revision = client.local_revision();
5447 client.drain_change_batches();
5448 let (push_status, results) = match status {
5449 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5450 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5451 "conflict" => (
5452 PushStatus::Rejected,
5453 vec![OpResult::Conflict {
5454 op_index: 0,
5455 code: "sync.version_conflict".to_owned(),
5456 message: "conflict".to_owned(),
5457 server_version: 1,
5458 server_row: encode_row_json(
5459 client.schema.table("tasks").unwrap(),
5460 "first",
5461 &Map::from_iter([
5462 ("id".to_owned(), Value::from("first")),
5463 ("project_id".to_owned(), Value::from("p1")),
5464 ]),
5465 &client.encryption,
5466 )
5467 .unwrap(),
5468 conflict_columns: Vec::new(),
5469 }],
5470 ),
5471 _ => (
5472 PushStatus::Rejected,
5473 vec![OpResult::Error {
5474 op_index: 0,
5475 code: "sync.validation_failed".to_owned(),
5476 message: "rejected".to_owned(),
5477 retryable: false,
5478 }],
5479 ),
5480 };
5481 let response = Message {
5482 wire_version: WIRE_VERSION,
5483 msg_kind: MsgKind::Response,
5484 frames: vec![
5485 Frame::RespHeader {
5486 required_schema_version: None,
5487 latest_schema_version: None,
5488 log_epoch: Some("epoch-1".to_owned()),
5489 reset_required: Some(false),
5490 },
5491 Frame::PushResult {
5492 client_commit_id: ids[0].clone(),
5493 status: push_status,
5494 commit_seq: Some(1),
5495 results,
5496 },
5497 ],
5498 };
5499 client.conn.execute_batch(match failure {
5500 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5501 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5502 _ => "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",
5503 }).unwrap();
5504 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5505 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5506 assert_eq!(
5507 client.pending_commit_ids(),
5508 ids,
5509 "{failure}/{status}: pending identity"
5510 );
5511 assert_eq!(
5512 client.local_revision(),
5513 revision,
5514 "{failure}/{status}: revision"
5515 );
5516 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5517 assert!(client.conflicts().is_empty());
5518 assert!(client.rejections().is_empty());
5519 assert!(client.drain_change_batches().is_empty());
5520 assert_eq!(
5521 client
5522 .query("SELECT * FROM tasks ORDER BY id", &[])
5523 .unwrap(),
5524 rows
5525 );
5526 let expected = if failure == "commit" {
5529 "storage.failed"
5530 } else {
5531 "client.outcome_persistence_failed"
5532 };
5533 assert!(
5534 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5535 "{failure}/{status}: failed round"
5536 );
5537 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5538 assert!(matches!(
5539 client.process_response(&mut transport, response, &meta),
5540 SyncOutcome::Ok(_)
5541 ));
5542 assert_eq!(client.pending_commit_ids(), ids[1..]);
5543 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5544 assert_eq!(client.drain_change_batches().len(), 1);
5545 }
5546 }
5547 }
5548
5549 #[test]
5550 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5551 let mut client = client();
5552 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5553 for index in 0..4 {
5554 client
5555 .mutate(vec![Mutation::Upsert {
5556 table: "tasks".to_owned(),
5557 values: Map::from_iter([
5558 ("id".to_owned(), Value::from(format!("task-{index}"))),
5559 ("project_id".to_owned(), Value::from("project-1")),
5560 ]),
5561 base_version: None,
5562 }])
5563 .expect("queue commit");
5564 }
5565
5566 let (_, request_meta) = client.build_request(false).unwrap();
5567 let ids = &request_meta.pushed_ids;
5568 assert_eq!(ids.len(), 4);
5569 let response = Message {
5570 wire_version: WIRE_VERSION,
5571 msg_kind: MsgKind::Response,
5572 frames: vec![
5573 Frame::RespHeader {
5574 required_schema_version: None,
5575 latest_schema_version: None,
5576 log_epoch: Some("epoch-1".to_owned()),
5577 reset_required: Some(false),
5578 },
5579 Frame::PushResult {
5580 client_commit_id: ids[0].clone(),
5581 status: PushStatus::Applied,
5582 commit_seq: Some(1),
5583 results: vec![OpResult::Applied { op_index: 0 }],
5584 },
5585 Frame::PushResult {
5586 client_commit_id: ids[1].clone(),
5587 status: PushStatus::Cached,
5588 commit_seq: Some(2),
5589 results: vec![OpResult::Applied { op_index: 0 }],
5590 },
5591 Frame::PushResult {
5592 client_commit_id: ids[2].clone(),
5593 status: PushStatus::Rejected,
5594 commit_seq: None,
5595 results: vec![OpResult::Error {
5596 op_index: 0,
5597 code: "sync.validation_failed".to_owned(),
5598 message: "rejected".to_owned(),
5599 retryable: false,
5600 }],
5601 },
5602 Frame::PushResult {
5603 client_commit_id: ids[3].clone(),
5604 status: PushStatus::Rejected,
5605 commit_seq: None,
5606 results: vec![OpResult::Error {
5607 op_index: 0,
5608 code: "sync.idempotency_cache_miss".to_owned(),
5609 message: "retry".to_owned(),
5610 retryable: true,
5611 }],
5612 },
5613 ],
5614 };
5615 let mut transport =
5616 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5617
5618 client.overlay_rebuild_count.set(0);
5619 client.outcome_prune_count.set(0);
5620 let outcome = client.process_response(&mut transport, response, &request_meta);
5621 let SyncOutcome::Ok(report) = outcome else {
5622 panic!("mixed push-result response failed");
5623 };
5624
5625 assert_eq!(report.applied, ids[..2]);
5626 assert_eq!(report.rejected, ids[2..3]);
5627 assert_eq!(report.retryable, ids[3..4]);
5628 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5629 assert_eq!(
5630 client
5631 .query("SELECT id FROM tasks ORDER BY id", &[])
5632 .expect("query visible overlay"),
5633 ["task-0", "task-1", "task-3"]
5634 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5635 .to_vec()
5636 );
5637 assert_eq!(client.overlay_rebuild_count.get(), 2);
5639 assert_eq!(client.outcome_prune_count.get(), 1);
5640 }
5641
5642 fn replay_schema(fts: bool) -> Value {
5643 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5644 {"name":"id","type":"string","nullable":false},
5645 {"name":"project_id","type":"string","nullable":false},
5646 {"name":"title","type":"string","nullable":false},
5647 {"name":"done","type":"boolean","nullable":false}
5648 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5649 }
5650
5651 #[test]
5652 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5653 for schema_reset in [false, true] {
5654 let mut schema = replay_schema(false);
5655 let mut client =
5656 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5657 let id = client
5658 .mutate(vec![Mutation::Upsert {
5659 table: "tasks".into(),
5660 values: Map::from_iter([
5661 ("id".into(), json!("queued")),
5662 ("project_id".into(), json!("p1")),
5663 ("title".into(), json!("old")),
5664 ("done".into(), json!(false)),
5665 ]),
5666 base_version: None,
5667 }])
5668 .unwrap();
5669 client.set_meta(LOG_EPOCH_KEY, "old");
5670 let revision = client.local_revision();
5671 let needed = client.sync_needed;
5672 client.drain_change_batches();
5673 client.drain_sync_intents();
5674 client.conn.execute_batch("CREATE TRIGGER refuse_revision BEFORE INSERT ON _syncular_meta WHEN NEW.key='localRevision' BEGIN SELECT RAISE(ABORT,'reset completion failed'); END;").unwrap();
5675 if schema_reset {
5676 schema["version"] = json!(2);
5677 schema["tables"][0]["columns"]
5678 .as_array_mut()
5679 .unwrap()
5680 .retain(|column| column["name"] != "title");
5681 client.schema = parse_schema_json(&schema).unwrap();
5682 assert!(client.run_schema_reset().is_err());
5683 } else {
5684 assert!(client.run_log_epoch_reset("new").is_err());
5685 }
5686 assert!(!client.upgrading);
5687 assert_eq!(client.sync_needed, needed);
5688 assert_eq!(client.local_revision(), revision);
5689 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5690 assert!(client.rejections.is_empty());
5691 assert!(client.commit_outcome(&id).unwrap().is_none());
5692 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5693 assert_eq!(
5694 client
5695 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5696 .unwrap()[0]["title"],
5697 "old"
5698 );
5699 assert_eq!(
5700 client
5701 .conn
5702 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5703 .get::<_, i64>(0))
5704 .unwrap(),
5705 1
5706 );
5707 assert!(client.drain_change_batches().is_empty());
5708 assert!(client.drain_sync_intents().is_empty());
5709 assert!(client.conn.is_autocommit());
5710 }
5711 }
5712
5713 #[test]
5714 fn cached_blob_cleanup_propagates_delete_failures() {
5715 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5716 {"name":"id","type":"string","nullable":false},
5717 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5718 let mut client =
5719 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5720 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5721 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5722 client
5723 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5724 .unwrap();
5725 client.subs[0].effective = Some(vec![]);
5726 client.persist_sub(&client.subs[0]).unwrap();
5727 let revision = client.local_revision();
5728 let (_, meta) = client.build_request(false).unwrap();
5729 let mut transport = CountingRealtimeTransport::default();
5730 let mut report = SyncReport::default();
5731 assert!(client
5732 .process_section(
5733 &mut transport,
5734 "attachments",
5735 SubStatus::Revoked,
5736 "sync.scope_revoked",
5737 vec![],
5738 vec![],
5739 Some((0, None)),
5740 &meta,
5741 &mut report,
5742 &HashSet::new()
5743 )
5744 .is_err());
5745 assert_eq!(client.subs[0].state, SubState::Active);
5746 assert_eq!(client.local_revision(), revision);
5747 assert!(report.revoked.is_empty());
5748 assert!(client.conn.is_autocommit());
5749 client
5750 .conn
5751 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5752 .unwrap();
5753 assert!(client
5754 .process_section(
5755 &mut transport,
5756 "attachments",
5757 SubStatus::Revoked,
5758 "sync.scope_revoked",
5759 vec![],
5760 vec![],
5761 Some((0, None)),
5762 &meta,
5763 &mut report,
5764 &HashSet::new()
5765 )
5766 .is_ok());
5767 assert_eq!(client.subs[0].state, SubState::Revoked);
5768 assert_eq!(
5769 client
5770 .conn
5771 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5772 .get::<_, i64>(0))
5773 .unwrap(),
5774 0
5775 );
5776 }
5777
5778 #[test]
5779 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5780 let path =
5781 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5782 let schema = replay_schema(false);
5783 let mut client = SyncClient::open_path(
5784 "replay".into(),
5785 &schema,
5786 ClientLimits::default(),
5787 path.to_str().unwrap(),
5788 )
5789 .unwrap();
5790 let ids = ["early", "bad"].map(|id| {
5791 client
5792 .mutate(vec![Mutation::Upsert {
5793 table: "tasks".into(),
5794 values: Map::from_iter([
5795 ("id".into(), json!(id)),
5796 ("project_id".into(), json!("p1")),
5797 ("title".into(), json!(id)),
5798 ("done".into(), json!(false)),
5799 ]),
5800 base_version: None,
5801 }])
5802 .unwrap()
5803 });
5804 client.conn.execute_batch("DELETE FROM tasks; CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.id='bad' BEGIN SELECT RAISE(ABORT,'replay failed'); END;").unwrap();
5805 let revision = client.local_revision();
5806 drop(client);
5807 let error = SyncClient::open_path(
5808 "replay".into(),
5809 &schema,
5810 ClientLimits::default(),
5811 path.to_str().unwrap(),
5812 )
5813 .err()
5814 .expect("reopen must fail");
5815 assert!(error.contains("replay failed"));
5816 let conn = Connection::open(&path).unwrap();
5817 assert_eq!(
5818 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5819 .unwrap(),
5820 0
5821 );
5822 assert_eq!(
5823 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5824 .get::<_, i64>(0))
5825 .unwrap(),
5826 2
5827 );
5828 assert_eq!(
5829 meta_get(&conn, LOCAL_REVISION_KEY),
5830 Some(revision.to_string())
5831 );
5832 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5833 drop(conn);
5834 let reopened = SyncClient::open_path(
5835 "replay".into(),
5836 &schema,
5837 ClientLimits::default(),
5838 path.to_str().unwrap(),
5839 )
5840 .unwrap();
5841 assert_eq!(reopened.pending_commit_ids(), ids);
5842 assert_eq!(
5843 reopened
5844 .query("SELECT id FROM tasks ORDER BY id", &[])
5845 .unwrap()
5846 .len(),
5847 2
5848 );
5849 drop(reopened);
5850 std::fs::remove_file(path).unwrap();
5851 }
5852
5853 #[test]
5854 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5855 let schema = replay_schema(false);
5856 let mut client =
5857 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5858 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5859 client
5860 .write_base_row(
5861 "tasks",
5862 &vec![
5863 Some(ColumnValue::String("queued".into())),
5864 Some(ColumnValue::String("p1".into())),
5865 Some(ColumnValue::String("server".into())),
5866 Some(ColumnValue::Boolean(false)),
5867 ],
5868 1,
5869 )
5870 .unwrap();
5871 client.rebuild_overlay().unwrap();
5872 let id = client
5873 .patch(
5874 "tasks",
5875 "queued",
5876 Map::from_iter([("title".into(), json!("local"))]),
5877 Some(1),
5878 )
5879 .unwrap();
5880 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5881 let base = client
5882 .query("SELECT * FROM _syncular_base_tasks", &[])
5883 .unwrap();
5884 let revision = client.local_revision();
5885 client.drain_change_batches();
5886 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5887 let remote = vec![
5888 Some(ColumnValue::String("queued".into())),
5889 Some(ColumnValue::String("p1".into())),
5890 Some(ColumnValue::String("remote".into())),
5891 Some(ColumnValue::Boolean(false)),
5892 ];
5893 let bytes = crate::values::encode_row_json(
5894 client.schema.table("tasks").unwrap(),
5895 "queued",
5896 &Map::from_iter(
5897 client
5898 .schema
5899 .table("tasks")
5900 .unwrap()
5901 .columns
5902 .iter()
5903 .zip(&remote)
5904 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
5905 ),
5906 &client.encryption,
5907 )
5908 .unwrap();
5909 let change = ssp2::model::Change {
5910 table_index: 0,
5911 op: Op::Upsert,
5912 row_id: "queued".into(),
5913 row_version: Some(2),
5914 scopes: vec![("project_id".into(), "p1".into())],
5915 row: Some(bytes),
5916 };
5917 assert!(client
5918 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
5919 .is_err());
5920 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5921 assert_eq!(
5922 client
5923 .query("SELECT * FROM _syncular_base_tasks", &[])
5924 .unwrap(),
5925 base
5926 );
5927 assert_eq!(client.local_revision(), revision);
5928 assert!(client.drain_change_batches().is_empty());
5929 let response = Message {
5930 wire_version: 2,
5931 msg_kind: MsgKind::Response,
5932 frames: vec![
5933 Frame::RespHeader {
5934 required_schema_version: None,
5935 latest_schema_version: None,
5936 log_epoch: Some("epoch-1".into()),
5937 reset_required: Some(false),
5938 },
5939 Frame::PushResult {
5940 client_commit_id: id.clone(),
5941 status: PushStatus::Applied,
5942 commit_seq: Some(2),
5943 results: vec![OpResult::Applied { op_index: 0 }],
5944 },
5945 ],
5946 };
5947 let (_, meta) = client.build_request(false).unwrap();
5948 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5949 assert!(
5950 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
5951 );
5952 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5953 assert!(client.commit_outcome(&id).unwrap().is_none());
5954 assert_eq!(
5955 client
5956 .conn
5957 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
5958 .get::<_, i64>(
5959 0
5960 ))
5961 .unwrap(),
5962 0
5963 );
5964 assert_eq!(
5965 client
5966 .conn
5967 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
5968 row.get::<_, i64>(0)
5969 })
5970 .unwrap(),
5971 0
5972 );
5973 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
5974 assert_eq!(client.local_revision(), revision);
5975 assert!(client.drain_change_batches().is_empty());
5976 client
5977 .conn
5978 .execute_batch("DROP TRIGGER fail_replay")
5979 .unwrap();
5980 assert!(matches!(
5981 client.process_response(&mut transport, response, &meta),
5982 SyncOutcome::Ok(_)
5983 ));
5984 client
5985 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
5986 .unwrap();
5987 assert!(client.pending_commit_ids().is_empty());
5988 assert_eq!(
5989 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
5990 json!("remote")
5991 );
5992 }
5993
5994 #[test]
5995 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
5996 let client = SyncClient::new(
5997 "replay".into(),
5998 &replay_schema(false),
5999 ClientLimits::default(),
6000 )
6001 .unwrap();
6002 let table = client.schema.table("tasks").unwrap();
6003 let patch = OutboxOp {
6004 table: "tasks".into(),
6005 row_id: "absent".into(),
6006 upsert: true,
6007 base_version: Some(1),
6008 values: Some(Map::from_iter([
6009 ("id".into(), json!("absent")),
6010 ("title".into(), json!("patch")),
6011 ])),
6012 };
6013 assert!(client.visible_row(table, "absent").unwrap().is_none());
6014 client
6015 .apply_outbox_ops([&patch])
6016 .expect("sparse replay over truly absent row");
6017 client
6018 .conn
6019 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6020 .unwrap();
6021 assert!(client
6022 .visible_row(table, "bad")
6023 .unwrap_err()
6024 .starts_with("sync.local_corrupt:"));
6025 let mut invalid = patch.clone();
6026 invalid.values = Some(Map::from_iter([
6027 ("id".into(), json!("bad-value")),
6028 ("project_id".into(), json!("p1")),
6029 ("title".into(), json!("title")),
6030 ("done".into(), json!({"bad":true})),
6031 ]));
6032 assert!(client
6033 .apply_outbox_ops([&invalid])
6034 .unwrap_err()
6035 .starts_with("sync.local_corrupt:"));
6036 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6037 assert!(
6038 client.apply_outbox_ops([&patch]).is_err(),
6039 "SQL failure must not count as absence"
6040 );
6041 }
6042
6043 #[test]
6044 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6045 let mut schema = replay_schema(false);
6046 schema["tables"][0]["indexes"] =
6047 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6048 let mut client =
6049 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6050 let id = client
6051 .mutate(vec![Mutation::Upsert {
6052 table: "tasks".into(),
6053 values: Map::from_iter([
6054 ("id".into(), json!("queued")),
6055 ("project_id".into(), json!("p1")),
6056 ("title".into(), json!("occupied")),
6057 ("done".into(), json!(false)),
6058 ]),
6059 base_version: None,
6060 }])
6061 .unwrap();
6062 client
6063 .write_base_row(
6064 "tasks",
6065 &vec![
6066 Some(ColumnValue::String("server".into())),
6067 Some(ColumnValue::String("p1".into())),
6068 Some(ColumnValue::String("occupied".into())),
6069 Some(ColumnValue::Boolean(false)),
6070 ],
6071 1,
6072 )
6073 .unwrap();
6074 client.rebuild_overlay_if_dirty().unwrap();
6075 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6076 assert!(client
6077 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6078 .unwrap()
6079 .is_none());
6080 assert!(client.rejections().is_empty());
6081 client.delete_base_row("tasks", "server").unwrap();
6082 client.rebuild_overlay_if_dirty().unwrap();
6083 assert!(client
6084 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6085 .unwrap()
6086 .is_some());
6087 client
6088 .write_base_row(
6089 "tasks",
6090 &vec![
6091 Some(ColumnValue::String("base".into())),
6092 Some(ColumnValue::String("p1".into())),
6093 Some(ColumnValue::String("base-title".into())),
6094 Some(ColumnValue::Boolean(false)),
6095 ],
6096 1,
6097 )
6098 .unwrap();
6099 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6100 client
6101 .retain_failed_commit(
6102 "retained",
6103 &[OutboxOp {
6104 table: "tasks".into(),
6105 row_id: "base".into(),
6106 upsert: true,
6107 base_version: Some(1),
6108 values: Some(Map::from_iter([
6109 ("id".into(), json!("base")),
6110 ("title".into(), json!("patch")),
6111 ])),
6112 }],
6113 )
6114 .expect("an existing base must not read the unavailable visible table");
6115 }
6116
6117 #[test]
6118 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6119 for failure in ["fts", "release"] {
6120 let mut client = SyncClient::new(
6121 "replay".into(),
6122 &replay_schema(true),
6123 ClientLimits::default(),
6124 )
6125 .unwrap();
6126 client
6127 .mutate(vec![Mutation::Upsert {
6128 table: "tasks".into(),
6129 values: Map::from_iter([
6130 ("id".into(), json!("queued")),
6131 ("project_id".into(), json!("p1")),
6132 ("title".into(), json!("needle")),
6133 ("done".into(), json!(false)),
6134 ]),
6135 base_version: None,
6136 }])
6137 .unwrap();
6138 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6139 let fts_before = client
6140 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6141 .unwrap();
6142 let triggers_before: Vec<String> = client
6143 .conn
6144 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6145 .unwrap()
6146 .query_map([], |row| row.get(0))
6147 .unwrap()
6148 .collect::<Result<_, _>>()
6149 .unwrap();
6150 if failure == "fts" {
6151 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6152 } else {
6153 client.conn.execute_batch("PRAGMA foreign_keys=ON; CREATE TABLE parent(id TEXT PRIMARY KEY); CREATE TABLE deferred(id TEXT REFERENCES parent(id) DEFERRABLE INITIALLY DEFERRED); CREATE TRIGGER fail_release AFTER INSERT ON tasks BEGIN INSERT INTO deferred VALUES ('missing'); END;").unwrap();
6154 }
6155 client.overlay_dirty.set(true);
6156 assert!(client.rebuild_overlay().is_err());
6157 assert!(client.overlay_dirty.get());
6158 assert!(client.conn.is_autocommit());
6159 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6160 assert_eq!(
6161 client
6162 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6163 .unwrap(),
6164 fts_before
6165 );
6166 let original_triggers: Vec<String> = client.conn.prepare("SELECT sql FROM sqlite_master WHERE type='trigger' AND name NOT LIKE 'fail_%' ORDER BY name").unwrap().query_map([], |row| row.get(0)).unwrap().collect::<Result<_,_>>().unwrap();
6167 assert_eq!(original_triggers, triggers_before);
6168 assert_eq!(client.pending_commit_ids().len(), 1);
6169 client
6170 .conn
6171 .execute_batch(if failure == "fts" {
6172 "DROP TRIGGER fail_fts"
6173 } else {
6174 "DROP TRIGGER fail_release"
6175 })
6176 .unwrap();
6177 client
6178 .rebuild_overlay()
6179 .expect("retry after removing the fault");
6180 }
6181 }
6182
6183 #[test]
6184 fn secondary_unique_collision_preserves_existing_synced_row() {
6185 let client = SyncClient::new(
6186 "unique-upsert-test".to_owned(),
6187 &json!({
6188 "version": 1,
6189 "tables": [{
6190 "name": "tasks",
6191 "primaryKey": "id",
6192 "columns": [
6193 { "name": "id", "type": "string", "nullable": false },
6194 { "name": "project_id", "type": "string", "nullable": false },
6195 { "name": "title", "type": "string", "nullable": false }
6196 ],
6197 "scopes": [{ "pattern": "project:{project_id}" }],
6198 "indexes": [{
6199 "name": "tasks_by_project_title",
6200 "columns": ["project_id", "title"],
6201 "unique": true
6202 }]
6203 }]
6204 }),
6205 ClientLimits::default(),
6206 )
6207 .expect("test client");
6208 let table = client.schema.table("tasks").expect("tasks table");
6209 let sql = client.insert_row_sql(&base_table("tasks"), table);
6210
6211 client
6212 .conn
6213 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6214 .expect("insert first row");
6215 client
6216 .conn
6217 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6218 .expect("update same primary key");
6219 client
6220 .conn
6221 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6222 .expect("insert second row");
6223 assert!(client
6224 .conn
6225 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6226 .is_err());
6227
6228 let rows = client
6229 .conn
6230 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6231 .expect("prepare rows")
6232 .query_map([], |row| {
6233 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6234 })
6235 .expect("query rows")
6236 .collect::<Result<Vec<_>, _>>()
6237 .expect("collect rows");
6238 assert_eq!(
6239 rows,
6240 vec![
6241 ("t1".to_owned(), "updated".to_owned()),
6242 ("t2".to_owned(), "original".to_owned())
6243 ]
6244 );
6245 }
6246
6247 #[test]
6248 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6249 let path = std::env::temp_dir().join(format!(
6250 "syncular-startup-intent-{}.db",
6251 uuid::Uuid::new_v4()
6252 ));
6253 let schema = json!({
6254 "version": 1,
6255 "tables": [{
6256 "name": "tasks",
6257 "primaryKey": "id",
6258 "columns": [
6259 { "name": "id", "type": "string", "nullable": false },
6260 { "name": "project_id", "type": "string", "nullable": false }
6261 ],
6262 "scopes": [{ "pattern": "project:{project_id}" }]
6263 }]
6264 });
6265
6266 {
6267 let mut first = SyncClient::open_path_with_identity(
6268 None,
6269 &schema,
6270 ClientLimits::default(),
6271 path.to_str().expect("UTF-8 temp path"),
6272 )
6273 .expect("first open");
6274 first
6275 .set_window(
6276 &WindowBase {
6277 table: "tasks".to_owned(),
6278 variable: "project_id".to_owned(),
6279 fixed_scopes: Vec::new(),
6280 params: None,
6281 },
6282 &["persisted".to_owned()],
6283 )
6284 .expect("persist window");
6285 }
6286
6287 let mut reopened = SyncClient::open_path_with_identity(
6288 None,
6289 &schema,
6290 ClientLimits::default(),
6291 path.to_str().expect("UTF-8 temp path"),
6292 )
6293 .expect("reopen");
6294 assert!(reopened.sync_needed());
6295 assert!(matches!(
6296 reopened.drain_sync_intents().as_slice(),
6297 [SyncIntent::Interactive]
6298 ));
6299 drop(reopened);
6300 std::fs::remove_file(path).expect("remove temp database");
6301 }
6302
6303 #[test]
6304 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6305 let path = std::env::temp_dir().join(format!(
6306 "syncular-subscription-identity-{}.db",
6307 uuid::Uuid::new_v4()
6308 ));
6309 let schema = json!({
6310 "version": 1,
6311 "tables": [
6312 {
6313 "name": "tasks",
6314 "primaryKey": "id",
6315 "columns": [
6316 { "name": "id", "type": "string", "nullable": false },
6317 { "name": "project_id", "type": "string", "nullable": false }
6318 ],
6319 "scopes": [{ "pattern": "project:{project_id}" }]
6320 },
6321 {
6322 "name": "docs",
6323 "primaryKey": "id",
6324 "columns": [
6325 { "name": "id", "type": "string", "nullable": false },
6326 { "name": "org_id", "type": "string", "nullable": false },
6327 { "name": "project_id", "type": "string", "nullable": false }
6328 ],
6329 "scopes": [
6330 { "pattern": "org:{org_id}" },
6331 { "pattern": "project:{projectId}", "column": "project_id" }
6332 ]
6333 }
6334 ]
6335 });
6336
6337 {
6338 let mut first = SyncClient::open_path_with_identity(
6339 None,
6340 &schema,
6341 ClientLimits::default(),
6342 path.to_str().expect("UTF-8 temp path"),
6343 )
6344 .expect("first open");
6345 first
6346 .subscribe(
6347 "stable-subscription".to_owned(),
6348 "tasks".to_owned(),
6349 vec![(
6350 "project_id".to_owned(),
6351 vec!["p2".to_owned(), "p1".to_owned()],
6352 )],
6353 Some(r#"{"view":"v1"}"#.to_owned()),
6354 )
6355 .expect("persist subscription");
6356 let persisted = {
6357 let subscription = first
6358 .subs
6359 .iter_mut()
6360 .find(|subscription| subscription.id == "stable-subscription")
6361 .expect("subscription");
6362 subscription.cursor = 41;
6363 subscription.bootstrap_state = Some("resume-token".to_owned());
6364 subscription.effective = Some(vec![(
6365 "project_id".to_owned(),
6366 vec!["p1".to_owned(), "p2".to_owned()],
6367 )]);
6368 subscription.synced_once = true;
6369 subscription.clone()
6370 };
6371 first.persist_sub(&persisted).unwrap();
6372 }
6373
6374 let mut reopened = SyncClient::open_path_with_identity(
6375 None,
6376 &schema,
6377 ClientLimits::default(),
6378 path.to_str().expect("UTF-8 temp path"),
6379 )
6380 .expect("reopen");
6381 let progress = reopened
6382 .subscription_state("stable-subscription")
6383 .expect("persisted state");
6384 assert_eq!(progress.cursor, 41);
6385 assert!(progress.has_resume_token);
6386
6387 reopened
6388 .subscribe(
6389 "stable-subscription".to_owned(),
6390 "tasks".to_owned(),
6391 vec![(
6392 "project_id".to_owned(),
6393 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6394 )],
6395 Some(r#"{"view":"v1"}"#.to_owned()),
6396 )
6397 .expect("canonical intent is idempotent");
6398 assert_eq!(
6399 reopened
6400 .subscription_state("stable-subscription")
6401 .expect("unchanged state")
6402 .cursor,
6403 progress.cursor
6404 );
6405
6406 for (table, scopes, params) in [
6407 (
6408 "tasks",
6409 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6410 Some(r#"{"view":"v1"}"#.to_owned()),
6411 ),
6412 (
6413 "tasks",
6414 vec![(
6415 "project_id".to_owned(),
6416 vec!["p2".to_owned(), "p1".to_owned()],
6417 )],
6418 Some(r#"{"view":"v2"}"#.to_owned()),
6419 ),
6420 (
6421 "docs",
6422 vec![
6423 ("org_id".to_owned(), vec!["o1".to_owned()]),
6424 ("projectId".to_owned(), vec!["p1".to_owned()]),
6425 ],
6426 Some(r#"{"view":"v1"}"#.to_owned()),
6427 ),
6428 ] {
6429 let error = reopened
6430 .subscribe(
6431 "stable-subscription".to_owned(),
6432 table.to_owned(),
6433 scopes,
6434 params,
6435 )
6436 .expect_err("identity rebind must fail");
6437 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6438 assert_eq!(
6439 reopened
6440 .subscription_state("stable-subscription")
6441 .expect("unchanged state")
6442 .cursor,
6443 progress.cursor
6444 );
6445 }
6446
6447 drop(reopened);
6448 std::fs::remove_file(path).expect("remove temp database");
6449 }
6450
6451 #[test]
6452 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6453 let path = std::env::temp_dir().join(format!(
6454 "syncular-satisfied-schema-floor-{}.db",
6455 uuid::Uuid::new_v4()
6456 ));
6457 let schema = json!({
6458 "version": 23,
6459 "tables": [{
6460 "name": "tasks",
6461 "primaryKey": "id",
6462 "columns": [
6463 { "name": "id", "type": "string", "nullable": false },
6464 { "name": "project_id", "type": "string", "nullable": false }
6465 ],
6466 "scopes": [{ "pattern": "project:{project_id}" }]
6467 }]
6468 });
6469
6470 {
6471 let mut first = SyncClient::open_path_with_identity(
6472 None,
6473 &schema,
6474 ClientLimits::default(),
6475 path.to_str().expect("UTF-8 temp path"),
6476 )
6477 .expect("first open");
6478 first
6479 .subscribe(
6480 "tasks".to_owned(),
6481 "tasks".to_owned(),
6482 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6483 None,
6484 )
6485 .expect("persist subscription");
6486 first.set_schema_floor(Some(SchemaFloor {
6487 required_schema_version: Some(22),
6488 latest_schema_version: Some(22),
6489 }));
6490 }
6491
6492 let mut reopened = SyncClient::open_path_with_identity(
6493 None,
6494 &schema,
6495 ClientLimits::default(),
6496 path.to_str().expect("UTF-8 temp path"),
6497 )
6498 .expect("reopen");
6499 assert!(reopened.schema_floor().is_none());
6500 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6501 assert!(reopened.sync_needed());
6502 assert!(matches!(
6503 reopened.drain_sync_intents().as_slice(),
6504 [SyncIntent::Interactive]
6505 ));
6506 drop(reopened);
6507 std::fs::remove_file(path).expect("remove temp database");
6508 }
6509
6510 #[test]
6511 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6512 let path = std::env::temp_dir().join(format!(
6513 "syncular-unsatisfied-schema-floor-{}.db",
6514 uuid::Uuid::new_v4()
6515 ));
6516 let schema = json!({
6517 "version": 1,
6518 "tables": [{
6519 "name": "tasks",
6520 "primaryKey": "id",
6521 "columns": [
6522 { "name": "id", "type": "string", "nullable": false },
6523 { "name": "project_id", "type": "string", "nullable": false }
6524 ],
6525 "scopes": [{ "pattern": "project:{project_id}" }]
6526 }]
6527 });
6528
6529 {
6530 let mut first = SyncClient::open_path_with_identity(
6531 None,
6532 &schema,
6533 ClientLimits::default(),
6534 path.to_str().expect("UTF-8 temp path"),
6535 )
6536 .expect("first open");
6537 first
6538 .subscribe(
6539 "tasks".to_owned(),
6540 "tasks".to_owned(),
6541 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6542 None,
6543 )
6544 .expect("persist subscription");
6545 first.set_schema_floor(Some(SchemaFloor {
6546 required_schema_version: Some(2),
6547 latest_schema_version: Some(2),
6548 }));
6549 }
6550
6551 let reopened = SyncClient::open_path_with_identity(
6552 None,
6553 &schema,
6554 ClientLimits::default(),
6555 path.to_str().expect("UTF-8 temp path"),
6556 )
6557 .expect("reopen");
6558 assert_eq!(
6559 reopened.schema_floor(),
6560 Some(&SchemaFloor {
6561 required_schema_version: Some(2),
6562 latest_schema_version: Some(2),
6563 })
6564 );
6565 assert!(!reopened.sync_needed());
6566 drop(reopened);
6567 std::fs::remove_file(path).expect("remove temp database");
6568 }
6569
6570 #[test]
6571 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6572 let path = std::env::temp_dir().join(format!(
6573 "syncular-indexed-column-bump-{}.db",
6574 uuid::Uuid::new_v4()
6575 ));
6576 let old_schema = json!({
6577 "version": 1,
6578 "tables": [
6579 {
6580 "name": "tasks",
6581 "primaryKey": "id",
6582 "columns": [
6583 { "name": "id", "type": "string", "nullable": false },
6584 { "name": "project_id", "type": "string", "nullable": false }
6585 ],
6586 "scopes": [{ "pattern": "project:{project_id}" }]
6587 },
6588 {
6589 "name": "legacy",
6590 "primaryKey": "id",
6591 "columns": [
6592 { "name": "id", "type": "string", "nullable": false },
6593 { "name": "project_id", "type": "string", "nullable": false }
6594 ],
6595 "scopes": [{ "pattern": "project:{project_id}" }]
6596 }
6597 ]
6598 });
6599 {
6600 let mut first = SyncClient::open_path_with_identity(
6601 None,
6602 &old_schema,
6603 ClientLimits::default(),
6604 path.to_str().expect("UTF-8 temp path"),
6605 )
6606 .expect("open old schema");
6607 first
6608 .subscribe(
6609 "legacy-sub".to_owned(),
6610 "legacy".to_owned(),
6611 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6612 None,
6613 )
6614 .expect("persist legacy subscription");
6615 }
6616
6617 let new_schema = json!({
6618 "version": 2,
6619 "tables": [{
6620 "name": "tasks",
6621 "primaryKey": "id",
6622 "columns": [
6623 { "name": "id", "type": "string", "nullable": false },
6624 { "name": "project_id", "type": "string", "nullable": false },
6625 { "name": "facility_membership_id", "type": "string", "nullable": true }
6626 ],
6627 "scopes": [{ "pattern": "project:{project_id}" }],
6628 "indexes": [{
6629 "name": "tasks_by_membership",
6630 "columns": ["project_id", "facility_membership_id"],
6631 "unique": false
6632 }]
6633 }]
6634 });
6635 let upgraded = SyncClient::open_path_with_identity(
6636 None,
6637 &new_schema,
6638 ClientLimits::default(),
6639 path.to_str().expect("UTF-8 temp path"),
6640 )
6641 .expect("open upgraded schema");
6642 let columns = upgraded
6643 .conn
6644 .prepare("PRAGMA table_info(tasks)")
6645 .expect("prepare columns")
6646 .query_map([], |row| row.get::<_, String>(1))
6647 .expect("query columns")
6648 .collect::<Result<Vec<_>, _>>()
6649 .expect("collect columns");
6650 assert!(columns.contains(&"facility_membership_id".to_owned()));
6651 let index_count: i64 = upgraded
6652 .conn
6653 .query_row(
6654 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6655 [],
6656 |row| row.get(0),
6657 )
6658 .expect("query index");
6659 assert_eq!(index_count, 1);
6660 assert!(upgraded.subscription_state("legacy-sub").is_none());
6661 drop(upgraded);
6662 std::fs::remove_file(path).expect("remove temp database");
6663 }
6664
6665 #[test]
6666 fn migrates_pre_envelope_outcome_journal_additively() {
6667 let path = std::env::temp_dir().join(format!(
6668 "syncular-outcome-migration-{}.db",
6669 uuid::Uuid::new_v4()
6670 ));
6671 let conn = Connection::open(&path).expect("open legacy database");
6672 conn.execute_batch(
6673 "CREATE TABLE _syncular_commit_outcomes (
6674 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6675 client_commit_id TEXT NOT NULL UNIQUE,
6676 status TEXT NOT NULL,
6677 recorded_at_ms INTEGER NOT NULL,
6678 results_json TEXT NOT NULL,
6679 resolution TEXT NOT NULL DEFAULT 'active',
6680 resolved_at_ms INTEGER,
6681 replacement_client_commit_id TEXT);",
6682 )
6683 .expect("create legacy outcome journal");
6684 drop(conn);
6685 let schema = json!({
6686 "version": 1,
6687 "tables": [{
6688 "name": "tasks",
6689 "primaryKey": "id",
6690 "columns": [
6691 { "name": "id", "type": "string", "nullable": false },
6692 { "name": "project_id", "type": "string", "nullable": false }
6693 ],
6694 "scopes": [{ "pattern": "project:{project_id}" }]
6695 }]
6696 });
6697 let client = SyncClient::open_path(
6698 "migration-native".to_owned(),
6699 &schema,
6700 ClientLimits::default(),
6701 path.to_str().expect("UTF-8 temp path"),
6702 )
6703 .expect("migrate database");
6704 let has_operations = client
6705 .conn
6706 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6707 .expect("prepare table info")
6708 .query_map([], |row| row.get::<_, String>(1))
6709 .expect("query table info")
6710 .filter_map(Result::ok)
6711 .any(|column| column == "operations_json");
6712 assert!(has_operations);
6713 drop(client);
6714 std::fs::remove_file(path).expect("remove temp database");
6715 }
6716
6717 #[test]
6718 fn retained_rejection_survives_file_reopen_and_security_purge() {
6719 let path =
6720 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6721 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6722 { "name": "id", "type": "string", "nullable": false },
6723 { "name": "project_id", "type": "string", "nullable": false },
6724 { "name": "title", "type": "string", "nullable": false }
6725 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6726 {
6727 let mut first = SyncClient::open_path(
6728 "retained-native".to_owned(),
6729 &schema,
6730 ClientLimits::default(),
6731 path.to_str().unwrap(),
6732 )
6733 .unwrap();
6734 first
6735 .write_base_row(
6736 "tasks",
6737 &vec![
6738 Some(ColumnValue::String("t1".into())),
6739 Some(ColumnValue::String("p1".into())),
6740 Some(ColumnValue::String("server".into())),
6741 ],
6742 1,
6743 )
6744 .unwrap();
6745 first.rebuild_overlay().expect("rebuild overlay");
6746 let operation = OutboxOp {
6747 table: "tasks".into(),
6748 row_id: "t1".into(),
6749 upsert: true,
6750 base_version: Some(1),
6751 values: Some(Map::from_iter([
6752 ("id".into(), json!("t1")),
6753 ("title".into(), json!("mine")),
6754 ])),
6755 };
6756 first
6757 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6758 .unwrap();
6759 first
6760 .persist_commit_outcome(
6761 "retained",
6762 CommitOutcomeStatus::Rejected,
6763 &[CommitOperationOutcome::Error {
6764 rejection: RejectionRecord {
6765 client_commit_id: "retained".into(),
6766 op_index: 0,
6767 code: "validation.denied".into(),
6768 message: "denied".into(),
6769 retryable: false,
6770 details: None,
6771 operation: None,
6772 },
6773 }],
6774 Some(&[operation]),
6775 )
6776 .unwrap();
6777 first.rebuild_overlay().expect("rebuild overlay");
6778 }
6779 {
6780 let mut reopened = SyncClient::open_path(
6781 "retained-native".to_owned(),
6782 &schema,
6783 ClientLimits::default(),
6784 path.to_str().unwrap(),
6785 )
6786 .unwrap();
6787 let row = reopened
6788 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6789 .expect("read visible row")
6790 .unwrap();
6791 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6792 assert_eq!(
6793 reopened
6794 .commit_outcome("retained")
6795 .unwrap()
6796 .unwrap()
6797 .retained_rows
6798 .unwrap()[0]
6799 .server_row
6800 .as_ref()
6801 .unwrap()["title"],
6802 json!("server")
6803 );
6804 reopened
6805 .conn
6806 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6807 .unwrap();
6808 reopened.rebuild_overlay().expect("rebuild overlay");
6809 assert!(reopened
6810 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6811 .expect("read visible row")
6812 .is_none());
6813 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6814 assert!(retained.retained_rows.is_some());
6815 assert_eq!(
6816 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6817 json!("mine")
6818 );
6819 reopened
6820 .purge_local_data(&LocalDataPurgeInput {
6821 purge_id: "protected-purge".into(),
6822 targets: vec![LocalDataPurgeTarget {
6823 table: "tasks".into(),
6824 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6825 }],
6826 })
6827 .unwrap();
6828 assert!(reopened
6829 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6830 .expect("read visible row")
6831 .is_none());
6832 assert!(reopened
6833 .commit_outcome("retained")
6834 .unwrap()
6835 .unwrap()
6836 .retained_rows
6837 .is_none());
6838 }
6839 std::fs::remove_file(path).unwrap();
6840 }
6841
6842 #[test]
6843 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6844 let path = std::env::temp_dir().join(format!(
6845 "syncular-durable-outcome-{}.db",
6846 uuid::Uuid::new_v4()
6847 ));
6848 let schema = json!({
6849 "version": 1,
6850 "tables": [{
6851 "name": "tasks",
6852 "primaryKey": "id",
6853 "columns": [
6854 { "name": "id", "type": "string", "nullable": false },
6855 { "name": "project_id", "type": "string", "nullable": false }
6856 ],
6857 "scopes": [{ "pattern": "project:{project_id}" }]
6858 }]
6859 });
6860 let conflict = ConflictRecord {
6861 client_commit_id: "losing-commit".to_owned(),
6862 op_index: 0,
6863 table: "tasks".to_owned(),
6864 row_id: "t1".to_owned(),
6865 code: "sync.version_conflict".to_owned(),
6866 message: "stale base version".to_owned(),
6867 server_version: 2,
6868 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6869 conflict_columns: vec!["project_id".to_owned()],
6870 operation: Some(CommitOperation {
6871 table: "tasks".to_owned(),
6872 row_id: "t1".to_owned(),
6873 op: "upsert".to_owned(),
6874 base_version: Some(1),
6875 values: Some(Map::from_iter([
6876 ("id".into(), json!("t1")),
6877 ("project_id".into(), json!("p1")),
6878 ])),
6879 }),
6880 };
6881 let failed_operations = vec![
6882 OutboxOp {
6883 upsert: true,
6884 table: "tasks".to_owned(),
6885 row_id: "t1".to_owned(),
6886 base_version: Some(1),
6887 values: Some(Map::from_iter([
6888 ("id".into(), json!("t1")),
6889 ("project_id".into(), json!("p1")),
6890 ])),
6891 },
6892 OutboxOp {
6893 upsert: true,
6894 table: "tasks".to_owned(),
6895 row_id: "status-event-1".to_owned(),
6896 base_version: Some(0),
6897 values: Some(Map::from_iter([
6898 ("id".into(), json!("status-event-1")),
6899 ("project_id".into(), json!("p1")),
6900 ])),
6901 },
6902 ];
6903
6904 {
6905 let mut first = SyncClient::open_path(
6906 "durable-native".to_owned(),
6907 &schema,
6908 ClientLimits::default(),
6909 path.to_str().expect("UTF-8 temp path"),
6910 )
6911 .expect("first open");
6912 first
6913 .begin_observation("test_outcome")
6914 .expect("begin outcome");
6915 first
6916 .persist_commit_outcome(
6917 "losing-commit",
6918 CommitOutcomeStatus::Conflict,
6919 &[CommitOperationOutcome::Conflict {
6920 conflict: conflict.clone(),
6921 }],
6922 Some(&failed_operations),
6923 )
6924 .expect("persist outcome");
6925 first.conflicts.push(conflict);
6926 first
6927 .finish_observation(
6928 "test_outcome",
6929 ChangeAccumulator {
6930 conflicts: true,
6931 outcomes: true,
6932 ..ChangeAccumulator::default()
6933 },
6934 )
6935 .expect("commit outcome");
6936 }
6937
6938 {
6939 let mut reopened = SyncClient::open_path(
6940 "durable-native".to_owned(),
6941 &schema,
6942 ClientLimits::default(),
6943 path.to_str().expect("UTF-8 temp path"),
6944 )
6945 .expect("reopen");
6946 assert_eq!(reopened.conflicts().len(), 1);
6947 assert_eq!(
6948 reopened.conflicts()[0].conflict_columns,
6949 vec!["project_id".to_owned()],
6950 "conflictColumns survives the journal round trip"
6951 );
6952 let outcome = reopened
6953 .commit_outcome("losing-commit")
6954 .expect("read outcome")
6955 .expect("outcome");
6956 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
6957 let operations = outcome.operations.expect("aggregate envelope");
6958 assert_eq!(operations.len(), 2);
6959 assert_eq!(operations[1].row_id, "status-event-1");
6960 let resolved = reopened
6961 .resolve_commit_outcome(ResolveCommitOutcomeInput {
6962 client_commit_id: "losing-commit".to_owned(),
6963 resolution: CommitOutcomeResolution::ResolvedKeepServer,
6964 replacement_client_commit_id: None,
6965 })
6966 .expect("resolve");
6967 assert_eq!(
6968 resolved.resolution,
6969 CommitOutcomeResolution::ResolvedKeepServer
6970 );
6971 assert!(reopened.conflicts().is_empty());
6972 }
6973
6974 let reopened = SyncClient::open_path(
6975 "durable-native".to_owned(),
6976 &schema,
6977 ClientLimits::default(),
6978 path.to_str().expect("UTF-8 temp path"),
6979 )
6980 .expect("second reopen");
6981 assert!(reopened.conflicts().is_empty());
6982 assert_eq!(
6983 reopened
6984 .commit_outcome("losing-commit")
6985 .expect("read outcome")
6986 .expect("outcome")
6987 .resolution,
6988 CommitOutcomeResolution::ResolvedKeepServer
6989 );
6990 drop(reopened);
6991 std::fs::remove_file(path).expect("remove temp database");
6992 }
6993
6994 #[test]
6995 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
6996 let path =
6997 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
6998 let schema = json!({
6999 "version": 1,
7000 "tables": [{
7001 "name": "tasks",
7002 "primaryKey": "id",
7003 "columns": [
7004 { "name": "id", "type": "string", "nullable": false },
7005 { "name": "project_id", "type": "string", "nullable": false }
7006 ],
7007 "scopes": [{ "pattern": "project:{project_id}" }]
7008 }]
7009 });
7010 let mut client = SyncClient::open_path_with_identity(
7011 Some("sidecar-client".to_owned()),
7012 &schema,
7013 ClientLimits::default(),
7014 path.to_str().expect("UTF-8 temp path"),
7015 )
7016 .expect("open owner");
7017 let base = WindowBase {
7018 table: "tasks".to_owned(),
7019 variable: "project_id".to_owned(),
7020 fixed_scopes: Vec::new(),
7021 params: None,
7022 };
7023 client
7024 .set_window(&base, &["one".to_owned()])
7025 .expect("set window");
7026 client
7027 .mutate(vec![Mutation::Upsert {
7028 table: "tasks".to_owned(),
7029 values: Map::from_iter([
7030 ("id".to_owned(), Value::from("t1")),
7031 ("project_id".to_owned(), Value::from("one")),
7032 ]),
7033 base_version: None,
7034 }])
7035 .expect("local mutate");
7036
7037 let coverage = [WindowCoverage {
7038 base,
7039 units: vec!["one".to_owned(), "missing".to_owned()],
7040 }];
7041 let owner = client
7042 .query_snapshot(
7043 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7044 &[],
7045 &coverage,
7046 None,
7047 )
7048 .expect("owner snapshot");
7049 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7050 let sidecar = reader
7051 .query_snapshot(
7052 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7053 &[],
7054 &coverage,
7055 )
7056 .expect("sidecar snapshot");
7057
7058 assert_eq!(sidecar.revision, owner.revision);
7059 assert_eq!(sidecar.rows, owner.rows);
7060 assert_eq!(
7061 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7062 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7063 );
7064 assert_eq!(sidecar.revision, "2");
7065 assert_eq!(sidecar.rows[0]["id"], "t1");
7066 assert_eq!(sidecar.rows[0]["server_version"], -1);
7067 assert!(!sidecar.coverage.complete);
7068 assert_eq!(sidecar.coverage.pending.len(), 1);
7069 assert_eq!(sidecar.coverage.missing.len(), 1);
7070
7071 drop(reader);
7072 drop(client);
7073 std::fs::remove_file(path).expect("remove temp database");
7074 }
7075
7076 #[test]
7077 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7078 let path =
7079 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7080 let schema = json!({
7081 "version": 1,
7082 "tables": [{
7083 "name": "tasks",
7084 "primaryKey": "id",
7085 "columns": [
7086 { "name": "id", "type": "string", "nullable": false },
7087 { "name": "project_id", "type": "string", "nullable": false }
7088 ],
7089 "scopes": [{ "pattern": "project:{project_id}" }]
7090 }]
7091 });
7092 let mut client = SyncClient::open_path_with_identity(
7093 Some("sidecar-catchup".to_owned()),
7094 &schema,
7095 ClientLimits::default(),
7096 path.to_str().expect("UTF-8 temp path"),
7097 )
7098 .expect("open owner");
7099 client
7100 .subscribe(
7101 "s1".into(),
7102 "tasks".into(),
7103 vec![("project_id".into(), vec!["one".into()])],
7104 None,
7105 )
7106 .expect("subscribe");
7107 let commit_id = client
7108 .mutate(vec![Mutation::Upsert {
7109 table: "tasks".to_owned(),
7110 values: Map::from_iter([
7111 ("id".to_owned(), Value::from("t1")),
7112 ("project_id".to_owned(), Value::from("one")),
7113 ]),
7114 base_version: None,
7115 }])
7116 .expect("mutate");
7117 let revision = client.local_revision().to_string();
7118
7119 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7120 assert_eq!(reader.local_revision().expect("revision"), revision);
7121
7122 let read = reader
7123 .snapshot_read(&SnapshotReadRequest {
7124 statements: vec![
7125 SnapshotStatement {
7126 sql: "SELECT 1 AS n",
7127 params: &[],
7128 },
7129 SnapshotStatement {
7130 sql: "SELECT id FROM tasks ORDER BY id",
7131 params: &[],
7132 },
7133 ],
7134 coverage: &[],
7135 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7136 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7137 })
7138 .expect("snapshot read");
7139 assert_eq!(read.revision, revision);
7140 assert_eq!(read.queries.len(), 2);
7141 assert_eq!(read.queries[0][0]["n"], 1);
7142 assert_eq!(read.queries[1][0]["id"], "t1");
7143 match &read.subscriptions[0] {
7144 SubscriptionCatchup::Known {
7145 status,
7146 cursor,
7147 bootstrap_complete,
7148 known_pending_pages,
7149 ..
7150 } => {
7151 assert_eq!(status, "active");
7152 assert_eq!(*cursor, -1);
7153 assert!(!bootstrap_complete);
7154 assert!(known_pending_pages);
7155 }
7156 other => panic!("expected a known subscription: {other:?}"),
7157 }
7158 assert!(matches!(
7159 &read.subscriptions[1],
7160 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7161 ));
7162 assert!(matches!(
7163 &read.deliveries[0],
7164 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7165 ));
7166 assert!(matches!(
7167 &read.deliveries[1],
7168 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7169 ));
7170
7171 assert!(reader
7173 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7174 .is_err());
7175 assert_eq!(
7176 reader.local_revision().expect("revision after rollback"),
7177 revision
7178 );
7179 assert!(reader
7181 .query_snapshot("DELETE FROM tasks", &[], &[])
7182 .is_err());
7183
7184 client
7186 .conn
7187 .execute(
7188 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7189 [],
7190 )
7191 .expect("corrupt revision");
7192 let failure = reader.local_revision().expect_err("corrupt revision fails");
7193 assert_eq!(failure.code, Some("sync.local_corrupt"));
7194
7195 drop(reader);
7196 drop(client);
7197 std::fs::remove_file(path).expect("remove temp database");
7198 }
7199
7200 #[test]
7201 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7202 let path =
7203 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7204 let schema = json!({
7205 "version": 1,
7206 "tables": [{
7207 "name": "tasks",
7208 "primaryKey": "id",
7209 "columns": [
7210 { "name": "id", "type": "string", "nullable": false },
7211 { "name": "project_id", "type": "string", "nullable": false }
7212 ],
7213 "scopes": [{ "pattern": "project:{project_id}" }]
7214 }]
7215 });
7216 let mut client = SyncClient::open_path_with_identity(
7217 Some("sidecar-delivery".to_owned()),
7218 &schema,
7219 ClientLimits::default(),
7220 path.to_str().expect("UTF-8 temp path"),
7221 )
7222 .expect("open owner");
7223 client.begin_observation("test_delivery").expect("observe");
7224 let operations = [OutboxOp {
7225 upsert: true,
7226 table: "tasks".to_owned(),
7227 row_id: "t1".to_owned(),
7228 base_version: Some(1),
7229 values: Some(Map::from_iter([
7230 ("id".into(), json!("t1")),
7231 ("project_id".into(), json!("p1")),
7232 ])),
7233 }];
7234 client
7235 .persist_commit_outcome(
7236 "conflict-commit",
7237 CommitOutcomeStatus::Conflict,
7238 &[CommitOperationOutcome::Conflict {
7239 conflict: ConflictRecord {
7240 client_commit_id: "conflict-commit".to_owned(),
7241 op_index: 0,
7242 table: "tasks".to_owned(),
7243 row_id: "t1".to_owned(),
7244 code: "sync.version_conflict".to_owned(),
7245 message: "stale base version".to_owned(),
7246 server_version: 2,
7247 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7248 conflict_columns: vec!["project_id".to_owned()],
7249 operation: None,
7250 },
7251 }],
7252 Some(&operations),
7253 )
7254 .expect("persist conflict");
7255 client
7256 .persist_commit_outcome(
7257 "rejected-commit",
7258 CommitOutcomeStatus::Rejected,
7259 &[CommitOperationOutcome::Error {
7260 rejection: RejectionRecord {
7261 client_commit_id: "rejected-commit".to_owned(),
7262 op_index: 0,
7263 code: "sync.unknown_table".to_owned(),
7264 message: "unknown table".to_owned(),
7265 retryable: false,
7266 details: None,
7267 operation: None,
7268 },
7269 }],
7270 Some(&operations),
7271 )
7272 .expect("persist rejection");
7273 client
7274 .finish_observation(
7275 "test_delivery",
7276 ChangeAccumulator {
7277 conflicts: true,
7278 outcomes: true,
7279 ..ChangeAccumulator::default()
7280 },
7281 )
7282 .expect("commit outcomes");
7283
7284 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7285 match reader.commit_delivery("conflict-commit").expect("conflict") {
7286 CommitDelivery::Known { outcome, .. } => {
7287 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7288 assert!(matches!(
7289 outcome.results.as_slice(),
7290 [CommitOperationOutcome::Conflict { conflict }]
7291 if conflict.code == "sync.version_conflict"
7292 && conflict.server_version == 2
7293 ));
7294 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7295 assert!(
7296 outcome.retained_rows.is_none(),
7297 "the sidecar reports the persisted view, not owner-derived retained rows"
7298 );
7299 }
7300 other => panic!("expected a known conflict: {other:?}"),
7301 }
7302 match reader
7303 .commit_delivery("rejected-commit")
7304 .expect("rejection")
7305 {
7306 CommitDelivery::Known { outcome, .. } => {
7307 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7308 assert!(matches!(
7309 outcome.results.as_slice(),
7310 [CommitOperationOutcome::Error { rejection }]
7311 if rejection.code == "sync.unknown_table"
7312 ));
7313 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7314 assert!(outcome.retained_rows.is_none());
7315 }
7316 other => panic!("expected a known rejection: {other:?}"),
7317 }
7318 drop(reader);
7319 drop(client);
7320 std::fs::remove_file(path).expect("remove temp database");
7321 }
7322
7323 #[test]
7324 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7325 use std::sync::mpsc::channel;
7326 let path = std::env::temp_dir().join(format!(
7327 "syncular-read-concurrent-{}.db",
7328 uuid::Uuid::new_v4()
7329 ));
7330 let schema = json!({
7331 "version": 1,
7332 "tables": [{
7333 "name": "tasks",
7334 "primaryKey": "id",
7335 "columns": [
7336 { "name": "id", "type": "string", "nullable": false },
7337 { "name": "project_id", "type": "string", "nullable": false }
7338 ],
7339 "scopes": [{ "pattern": "project:{project_id}" }]
7340 }]
7341 });
7342 let mut client = SyncClient::open_path_with_identity(
7343 Some("sidecar-concurrent".to_owned()),
7344 &schema,
7345 ClientLimits::default(),
7346 path.to_str().expect("UTF-8 temp path"),
7347 )
7348 .expect("open owner");
7349 client
7350 .mutate(vec![Mutation::Upsert {
7351 table: "tasks".to_owned(),
7352 values: Map::from_iter([
7353 ("id".to_owned(), Value::from("t1")),
7354 ("project_id".to_owned(), Value::from("one")),
7355 ]),
7356 base_version: None,
7357 }])
7358 .expect("seed row");
7359 let revision_before = client.local_revision().to_string();
7360
7361 let (ready_tx, ready_rx) = channel::<()>();
7364 let (done_tx, done_rx) = channel::<()>();
7365 let writer_path = path.to_string_lossy().into_owned();
7366 let writer_schema = schema.clone();
7367 let writer = std::thread::spawn(move || {
7368 let mut writer = SyncClient::open_path_with_identity(
7369 Some("sidecar-concurrent".to_owned()),
7370 &writer_schema,
7371 ClientLimits::default(),
7372 &writer_path,
7373 )
7374 .expect("open writer");
7375 ready_rx.recv().expect("reader signalled");
7376 writer
7377 .mutate(vec![Mutation::Upsert {
7378 table: "tasks".to_owned(),
7379 values: Map::from_iter([
7380 ("id".to_owned(), Value::from("t2")),
7381 ("project_id".to_owned(), Value::from("one")),
7382 ]),
7383 base_version: None,
7384 }])
7385 .expect("writer commit");
7386 let _ = done_tx.send(());
7387 });
7388 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7389 SNAPSHOT_READ_HOOK.with(|slot| {
7390 *slot.borrow_mut() = Some(Box::new(move || {
7391 ready_tx.send(()).expect("signal writer");
7392 done_rx.recv().expect("writer finished");
7393 }));
7394 });
7395 let read = reader
7396 .snapshot_read(&SnapshotReadRequest {
7397 statements: vec![
7398 SnapshotStatement {
7399 sql: "SELECT count(*) AS n FROM tasks",
7400 params: &[],
7401 },
7402 SnapshotStatement {
7403 sql: "SELECT count(*) AS n FROM tasks",
7404 params: &[],
7405 },
7406 ],
7407 coverage: &[],
7408 subscriptions: Vec::new(),
7409 commit_ids: Vec::new(),
7410 })
7411 .expect("snapshot read");
7412 assert_eq!(
7413 read.queries[0][0]["n"], 1,
7414 "first statement sees the pre-commit row"
7415 );
7416 assert_eq!(
7417 read.queries[1][0]["n"], 1,
7418 "a later statement keeps the one read snapshot"
7419 );
7420 assert_eq!(
7421 read.revision, revision_before,
7422 "the read reports the snapshot revision, not the writer's"
7423 );
7424 writer.join().expect("join writer");
7425
7426 assert_ne!(
7427 reader.local_revision().expect("revision after commit"),
7428 revision_before,
7429 "the next read observes the committed revision"
7430 );
7431 let after = reader
7432 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7433 .expect("count after commit");
7434 assert_eq!(
7435 after.rows[0]["n"], 2,
7436 "the committed row is visible afterwards"
7437 );
7438
7439 drop(reader);
7440 drop(client);
7441 std::fs::remove_file(path).expect("remove temp database");
7442 }
7443
7444 #[test]
7445 fn snapshot_read_rejects_every_non_canonical_revision() {
7446 let path = std::env::temp_dir().join(format!(
7447 "syncular-read-revision-{}.db",
7448 uuid::Uuid::new_v4()
7449 ));
7450 let schema = json!({
7451 "version": 1,
7452 "tables": [{
7453 "name": "tasks",
7454 "primaryKey": "id",
7455 "columns": [
7456 { "name": "id", "type": "string", "nullable": false },
7457 { "name": "project_id", "type": "string", "nullable": false }
7458 ],
7459 "scopes": [{ "pattern": "project:{project_id}" }]
7460 }]
7461 });
7462 let client = SyncClient::open_path_with_identity(
7463 Some("sidecar-revision".to_owned()),
7464 &schema,
7465 ClientLimits::default(),
7466 path.to_str().expect("UTF-8 temp path"),
7467 )
7468 .expect("open owner");
7469 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7470 for bad in [
7471 "+1",
7472 "01",
7473 "-1",
7474 " 1",
7475 "1 ",
7476 "18446744073709551616",
7477 "1.0",
7478 "abc",
7479 "",
7480 ] {
7481 client
7482 .conn
7483 .execute(
7484 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7485 rusqlite::params![bad],
7486 )
7487 .expect("corrupt revision");
7488 let failure = reader.local_revision().expect_err("corrupt marker fails");
7489 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7490 }
7491 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7492 client
7493 .conn
7494 .execute_batch(&format!(
7495 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7496 ))
7497 .expect("corrupt stored revision type");
7498 assert_eq!(
7499 reader.local_revision().expect_err("invalid SQL field").code,
7500 Some("sync.local_corrupt")
7501 );
7502 }
7503 client
7505 .conn
7506 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7507 .expect("delete marker");
7508 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7509
7510 drop(reader);
7511 drop(client);
7512 std::fs::remove_file(path).expect("remove temp database");
7513 }
7514
7515 #[test]
7516 fn snapshot_read_rejects_corrupt_subscription_state() {
7517 let path = std::env::temp_dir().join(format!(
7518 "syncular-read-substate-{}.db",
7519 uuid::Uuid::new_v4()
7520 ));
7521 let schema = json!({
7522 "version": 1,
7523 "tables": [{
7524 "name": "tasks",
7525 "primaryKey": "id",
7526 "columns": [
7527 { "name": "id", "type": "string", "nullable": false },
7528 { "name": "project_id", "type": "string", "nullable": false }
7529 ],
7530 "scopes": [{ "pattern": "project:{project_id}" }]
7531 }]
7532 });
7533 let mut client = SyncClient::open_path_with_identity(
7534 Some("sidecar-substate".to_owned()),
7535 &schema,
7536 ClientLimits::default(),
7537 path.to_str().expect("UTF-8 temp path"),
7538 )
7539 .expect("open owner");
7540 client
7541 .subscribe(
7542 "s1".into(),
7543 "tasks".into(),
7544 vec![("project_id".into(), vec!["one".into()])],
7545 None,
7546 )
7547 .expect("subscribe");
7548 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7549 for bad in [
7550 "not json".to_owned(),
7551 "{}".to_owned(),
7552 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7553 json!({ "status": "active", "cursor": "x" }).to_string(),
7554 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7555 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7556 ] {
7557 client
7558 .conn
7559 .execute(
7560 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7561 rusqlite::params![bad],
7562 )
7563 .expect("corrupt subscription state");
7564 let failure = reader
7565 .subscription_catchup("s1")
7566 .expect_err("corrupt state fails");
7567 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7568 }
7569
7570 for assignment in [
7571 "tbl=X'7461736b73'",
7572 "state_json=X'7b7d'",
7573 "state_json=CAST(X'FF' AS TEXT)",
7574 ] {
7575 client.conn.execute_batch(&format!(
7576 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7577 )).expect("corrupt stored subscription type");
7578 assert_eq!(
7579 reader
7580 .subscription_catchup("s1")
7581 .expect_err("invalid SQL field")
7582 .code,
7583 Some("sync.local_corrupt")
7584 );
7585 }
7586
7587 drop(reader);
7588 drop(client);
7589 std::fs::remove_file(path).expect("remove temp database");
7590 }
7591
7592 #[test]
7593 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7594 let path = std::env::temp_dir().join(format!(
7595 "syncular-read-rollback-{}.db",
7596 uuid::Uuid::new_v4()
7597 ));
7598 let schema = json!({
7599 "version": 1,
7600 "tables": [{
7601 "name": "tasks",
7602 "primaryKey": "id",
7603 "columns": [
7604 { "name": "id", "type": "string", "nullable": false },
7605 { "name": "project_id", "type": "string", "nullable": false }
7606 ],
7607 "scopes": [{ "pattern": "project:{project_id}" }]
7608 }]
7609 });
7610 let mut client = SyncClient::open_path_with_identity(
7611 Some("sidecar-rollback".to_owned()),
7612 &schema,
7613 ClientLimits::default(),
7614 path.to_str().expect("UTF-8 temp path"),
7615 )
7616 .expect("open owner");
7617 let commit_id = client
7618 .mutate(vec![Mutation::Upsert {
7619 table: "tasks".to_owned(),
7620 values: Map::from_iter([
7621 ("id".to_owned(), Value::from("t1")),
7622 ("project_id".to_owned(), Value::from("one")),
7623 ]),
7624 base_version: None,
7625 }])
7626 .expect("seed row");
7627 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7628 let revision = reader.local_revision().expect("revision");
7629
7630 let failure = reader
7633 .snapshot_read(&SnapshotReadRequest {
7634 statements: vec![
7635 SnapshotStatement {
7636 sql: "SELECT id FROM tasks",
7637 params: &[],
7638 },
7639 SnapshotStatement {
7640 sql: "SELECT * FROM does_not_exist",
7641 params: &[],
7642 },
7643 ],
7644 coverage: &[],
7645 subscriptions: Vec::new(),
7646 commit_ids: vec![commit_id.clone()],
7647 })
7648 .expect_err("second statement fails");
7649 assert_eq!(failure.code, None);
7650 assert_eq!(
7651 reader.local_revision().expect("revision after rollback"),
7652 revision,
7653 "a rolled-back read consumes no revision"
7654 );
7655 assert!(
7656 !matches!(
7657 reader
7658 .commit_delivery(&commit_id)
7659 .expect("delivery after rollback"),
7660 CommitDelivery::Unknown { .. }
7661 ),
7662 "the same reader serves the next read after releasing the connection"
7663 );
7664
7665 let guard = reader
7667 .snapshot_read(&SnapshotReadRequest {
7668 statements: vec![SnapshotStatement {
7669 sql: "DELETE FROM tasks",
7670 params: &[],
7671 }],
7672 coverage: &[],
7673 subscriptions: Vec::new(),
7674 commit_ids: Vec::new(),
7675 })
7676 .expect_err("write guard");
7677 assert!(guard.message.contains("read-only"), "{}", guard.message);
7678
7679 drop(reader);
7680 drop(client);
7681 std::fs::remove_file(path).expect("remove temp database");
7682 }
7683
7684 #[test]
7685 fn owned_query_failures_classify_order_bound_and_clear() {
7686 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7687 rusqlite::ffi::Error::new(266),
7688 Some("private SQLite prose".to_owned()),
7689 ));
7690 assert_eq!(io.code, Some("client.storage_io"));
7691 assert_eq!(io.sqlite_code, Some(266));
7692 assert_eq!(io.message, "local SQLite storage I/O failed");
7693 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7694 rusqlite::ffi::Error::new(26),
7695 None,
7696 ));
7697 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7698 assert!(!io.retryable());
7699 assert!(!corrupt.retryable());
7700
7701 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7704 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7705 rusqlite::ffi::Error::new(raw),
7706 Some("database is locked".to_owned()),
7707 ));
7708 assert_eq!(busy.code, Some("client.storage_busy"));
7709 assert_eq!(busy.sqlite_code, Some(extended));
7710 assert_eq!(busy.message, "local SQLite storage is busy");
7711 assert!(busy.retryable());
7712 }
7713 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7714 rusqlite::ffi::Error::new(1),
7715 Some("generic SQLite prose".to_owned()),
7716 ));
7717 assert_eq!(unrelated.code, None);
7718 assert!(!unrelated.retryable());
7719
7720 let mut client = client();
7721 let generic = QueryReadFailure::from("private query prose".to_owned());
7722 client.record_query_read(
7723 QueryOwner {
7724 id: "first",
7725 tables: &["tasks", "tasks"],
7726 },
7727 Some(&generic),
7728 );
7729 client.record_query_read(
7730 QueryOwner {
7731 id: "second",
7732 tables: &["tasks"],
7733 },
7734 Some(&generic),
7735 );
7736 client.record_query_read(
7737 QueryOwner {
7738 id: "first",
7739 tables: &["docs"],
7740 },
7741 Some(&io),
7742 );
7743 let failures = &client
7744 .diagnostics_snapshot(&Default::default())
7745 .expect("diagnostics")
7746 .query_failures;
7747 assert_eq!(
7748 failures
7749 .iter()
7750 .map(|entry| entry.id.as_str())
7751 .collect::<Vec<_>>(),
7752 vec!["second", "first"]
7753 );
7754 assert_eq!(failures[1].tables, vec!["docs"]);
7755 assert_eq!(failures[1].code, "client.storage_io");
7756
7757 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7758 client.record_query_read(
7759 QueryOwner {
7760 id: &format!("query-{index}"),
7761 tables: &["tasks"],
7762 },
7763 Some(&generic),
7764 );
7765 }
7766 let failures = &client
7767 .diagnostics_snapshot(&Default::default())
7768 .expect("bounded diagnostics")
7769 .query_failures;
7770 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7771 assert_eq!(failures.last().expect("last failure").id, "query-256");
7772
7773 client
7774 .query_snapshot(
7775 "SELECT id FROM tasks",
7776 &[],
7777 &[],
7778 Some(QueryOwner {
7779 id: "query-256",
7780 tables: &["tasks"],
7781 }),
7782 )
7783 .expect("successful owned read");
7784 assert!(client
7785 .diagnostics_snapshot(&Default::default())
7786 .expect("cleared diagnostics")
7787 .query_failures
7788 .iter()
7789 .all(|entry| entry.id != "query-256"));
7790 }
7791
7792 #[test]
7793 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7794 let mut client = client();
7795 let image = Connection::open_in_memory().unwrap();
7796 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7797 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7798 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7799 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7800 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7801 let table = client.schema.table("tasks").unwrap().clone();
7802 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7803 assert!(client
7804 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7805 .is_err());
7806 assert!(client.conn.is_autocommit());
7807 assert_eq!(
7808 client
7809 .query("SELECT count(*) AS n FROM tasks", &[])
7810 .unwrap()[0]["n"],
7811 1024
7812 );
7813 assert_eq!(client.drain_change_batches().len(), 1);
7814 image
7815 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7816 .unwrap();
7817 assert!(matches!(
7818 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7819 Ok(2050)
7820 ));
7821 assert_eq!(
7822 client
7823 .query("SELECT count(*) AS n FROM tasks", &[])
7824 .unwrap()[0]["n"],
7825 2050
7826 );
7827 assert_eq!(client.drain_change_batches().len(), 3);
7828 }
7829
7830 #[test]
7831 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7832 let mut client = client();
7833 let base = WindowBase {
7834 table: "tasks".into(),
7835 variable: "project_id".into(),
7836 fixed_scopes: Vec::new(),
7837 params: None,
7838 };
7839 client
7840 .set_window(&base, &["p1".into(), "p2".into()])
7841 .unwrap();
7842 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7843 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7844 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7845 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7846 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7847 client.drain_change_batches();
7848 assert!(client.set_window(&base, &["p2".into()]).is_err());
7849 assert!(client.conn.is_autocommit());
7850 assert_eq!(
7851 client
7852 .query("SELECT count(*) AS n FROM tasks", &[])
7853 .unwrap()[0]["n"],
7854 1027
7855 );
7856 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7857 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7858 assert_eq!(client.drain_change_batches().len(), 1);
7859 client
7860 .conn
7861 .execute_batch("DROP TRIGGER interrupt_evict")
7862 .unwrap();
7863 client.drain_pending_evictions().unwrap();
7864 assert!(client.load_pending_evictions().unwrap().is_empty());
7865 assert_eq!(
7866 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7867 "held"
7868 );
7869 assert_eq!(client.drain_change_batches().len(), 2);
7870 }
7871
7872 #[test]
7873 fn eviction_marker_failures_roll_back_window_changes() {
7874 let mut client = client();
7875 let base = WindowBase {
7876 table: "tasks".into(),
7877 variable: "project_id".into(),
7878 fixed_scopes: Vec::new(),
7879 params: None,
7880 };
7881 client.set_window(&base, &["p1".into()]).unwrap();
7882 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7883 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7884 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7885 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7886 let revision = client.local_revision();
7887 assert!(client.set_window(&base, &[]).is_err());
7888 assert!(client.conn.is_autocommit());
7889 assert_eq!(client.local_revision(), revision);
7890 assert_eq!(
7891 client
7892 .query("SELECT count(*) AS n FROM tasks", &[])
7893 .unwrap()[0]["n"],
7894 2050
7895 );
7896 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7897 client.conn.execute_batch("DROP TRIGGER fail_marker;
7898 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7899 assert!(client.set_window(&base, &[]).is_err());
7900 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
7901 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7902 assert!(client.drain_pending_evictions().is_err());
7903 assert!(client.conn.is_autocommit());
7904 assert_eq!(
7905 client
7906 .query("SELECT count(*) AS n FROM tasks", &[])
7907 .unwrap()[0]["n"],
7908 2
7909 );
7910 assert!(client.set_window(&base, &["p1".into()]).is_err());
7911 assert!(client.conn.is_autocommit());
7912 assert!(client.window_state(&base).units.is_empty());
7913 assert!(client.subs.is_empty());
7914 assert_eq!(
7915 client
7916 .query(
7917 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
7918 &[]
7919 )
7920 .unwrap()[0]["n"],
7921 1
7922 );
7923 client
7924 .conn
7925 .execute_batch("DROP TRIGGER fail_marker")
7926 .unwrap();
7927 client.set_window(&base, &["p1".into()]).unwrap();
7928 assert_eq!(client.window_state(&base).units, vec!["p1"]);
7929 }
7930
7931 #[test]
7932 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
7933 let mut client = client();
7934 client
7935 .conn
7936 .execute(
7937 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
7938 [],
7939 )
7940 .unwrap();
7941 assert_eq!(
7942 client.drain_pending_evictions().unwrap_err(),
7943 "sync.local_corrupt: invalid pending eviction scopes"
7944 );
7945 assert!(client.conn.is_autocommit());
7946 assert!(client.enqueue_startup_sync_if_needed().is_err());
7947 }
7948
7949 #[test]
7950 fn image_chunks_reject_null_primary_keys() {
7951 let mut client = client();
7952 let image = Connection::open_in_memory().unwrap();
7953 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
7954 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
7955 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
7956 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7957 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
7958 let table = client.schema.table("tasks").unwrap().clone();
7959 assert!(client
7960 .apply_sqlite_image(
7961 &image,
7962 &table,
7963 true,
7964 &[("project_id".into(), vec!["p1".into()])],
7965 1025,
7966 7,
7967 "digest"
7968 )
7969 .is_err());
7970 assert!(client.conn.is_autocommit());
7971 assert_eq!(
7972 client
7973 .query("SELECT count(*) AS n FROM tasks", &[])
7974 .unwrap()[0]["n"],
7975 0
7976 );
7977 }
7978
7979 #[test]
7980 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
7981 for images in [false, true] {
7982 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
7983 "name":name,"primaryKey":"id","columns":[
7984 {"name":"id","type":"string","nullable":false},
7985 {"name":"project_id","type":"string","nullable":false},
7986 {"name":"title","type":"string","nullable":false}],
7987 "scopes":[{"pattern":"project:{project_id}"}],
7988 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
7989 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
7990 })))});
7991 let mut actual =
7992 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
7993 let mut reference =
7994 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
7995 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7996 for client in [&mut actual, &mut reference] {
7997 client
7998 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
7999 .unwrap();
8000 client.subs[0].effective = Some(scopes.clone());
8001 for (table, id, title) in [
8002 ("tasks", "a", "original"),
8003 ("tasks", "b", "occupied"),
8004 ("tasks", "c", "deleted"),
8005 ("notes", "n", "untouched"),
8006 ] {
8007 client
8008 .write_base_row(
8009 table,
8010 &vec![
8011 Some(ColumnValue::String(id.into())),
8012 Some(ColumnValue::String("p1".into())),
8013 Some(ColumnValue::String(title.into())),
8014 ],
8015 1,
8016 )
8017 .unwrap();
8018 }
8019 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8020 client
8021 .mutate(vec![
8022 Mutation::Upsert {
8023 table: "tasks".into(),
8024 values: Map::from_iter([
8025 ("id".into(), Value::from("a")),
8026 ("project_id".into(), Value::from("p1")),
8027 ("title".into(), Value::from("available")),
8028 ]),
8029 base_version: None,
8030 },
8031 Mutation::Delete {
8032 table: "tasks".into(),
8033 row_id: "c".into(),
8034 base_version: None,
8035 },
8036 Mutation::Upsert {
8037 table: "tasks".into(),
8038 values: Map::from_iter([
8039 ("id".into(), Value::from("local")),
8040 ("project_id".into(), Value::from("p1")),
8041 ("title".into(), Value::from("local value")),
8042 ]),
8043 base_version: None,
8044 },
8045 Mutation::Upsert {
8046 table: "notes".into(),
8047 values: Map::from_iter([
8048 ("id".into(), Value::from("n")),
8049 ("project_id".into(), Value::from("p1")),
8050 ("title".into(), Value::from("pending note")),
8051 ]),
8052 base_version: None,
8053 },
8054 ])
8055 .unwrap();
8056 }
8057 let pending = actual.pending_commit_ids();
8058 actual.overlay_rebuild_count.set(0);
8059 for (id, title) in [
8060 ("b", "available"),
8061 ("b", "occupied"),
8062 ("a", "server original"),
8063 ("d", "local value"),
8064 ("d", "released"),
8065 ("c", "redelivered"),
8066 ] {
8067 let table = actual.schema.table("tasks").unwrap().clone();
8068 let values = vec![
8069 Some(ColumnValue::String(id.into())),
8070 Some(ColumnValue::String("p1".into())),
8071 Some(ColumnValue::String(title.into())),
8072 ];
8073 if images {
8074 let image = Connection::open_in_memory().unwrap();
8075 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8076 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8077 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8078 image
8079 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8080 .unwrap();
8081 assert!(matches!(
8082 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8083 Ok(1)
8084 ));
8085 } else {
8086 let segment = RowsSegment {
8087 table: "tasks".into(),
8088 schema_version: 1,
8089 columns: table.wire_columns.clone(),
8090 blocks: vec![vec![ssp2::segment::SegmentRow {
8091 server_version: 2,
8092 values: values.clone(),
8093 }]],
8094 };
8095 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8096 }
8097 reference.write_base_row("tasks", &values, 2).unwrap();
8098 reference.rebuild_overlay().expect("rebuild overlay");
8099 for table in ["tasks", "notes"] {
8100 for sql in [format!("SELECT * FROM {table} ORDER BY id"),format!("SELECT _syncular_source_id,title FROM {table}_fts ORDER BY _syncular_source_id"),format!("SELECT source_id FROM _syncular_fts_{table}_fts ORDER BY source_id")] {
8101 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8102 }
8103 }
8104 assert_eq!(actual.pending_commit_ids(), pending);
8105 }
8106 assert_eq!(
8107 actual.overlay_rebuild_count.get(),
8108 0,
8109 "each chunk must leave unrelated tables alone"
8110 );
8111 let before = actual
8112 .query("SELECT * FROM tasks ORDER BY id", &[])
8113 .unwrap();
8114 let fts_before = actual
8115 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8116 .unwrap();
8117 let revision = actual.local_revision();
8118 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8119 let table = actual.schema.table("tasks").unwrap().clone();
8120 let segment = RowsSegment {
8121 table: "tasks".into(),
8122 schema_version: 1,
8123 columns: table.wire_columns.clone(),
8124 blocks: vec![vec![ssp2::segment::SegmentRow {
8125 server_version: 3,
8126 values: vec![
8127 Some(ColumnValue::String("b".into())),
8128 Some(ColumnValue::String("p1".into())),
8129 Some(ColumnValue::String("fresh".into())),
8130 ],
8131 }]],
8132 };
8133 assert!(actual.apply_segment(0, &segment, false).is_err());
8134 assert!(actual.conn.is_autocommit());
8135 assert_eq!(
8136 actual
8137 .query("SELECT * FROM tasks ORDER BY id", &[])
8138 .unwrap(),
8139 before
8140 );
8141 assert_eq!(
8142 actual
8143 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8144 .unwrap(),
8145 fts_before
8146 );
8147 assert_eq!(actual.local_revision(), revision);
8148 assert_eq!(actual.pending_commit_ids(), pending);
8149 }
8150 }
8151
8152 #[test]
8153 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8154 for (kind, a, b) in [
8155 ("string", json!("a"), json!("b")),
8156 ("integer", json!(1), json!(2)),
8157 ("boolean", json!(true), json!(false)),
8158 ("json", json!("1"), json!("2")),
8159 ] {
8160 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[{"name":"id","type":kind,"nullable":false},{"name":"title","type":"string","nullable":false}],"scopes":[],"indexes":[{"name":"unique_title","columns":["title"],"unique":true}]}]});
8161 for images in [false, true] {
8162 let mut client =
8163 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8164 let table = client.schema.table("tasks").unwrap().clone();
8165 client
8166 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8167 .unwrap();
8168 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8169 client
8170 .write_base_row(
8171 "tasks",
8172 &vec![
8173 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8174 Some(ColumnValue::String(title.into())),
8175 ],
8176 1,
8177 )
8178 .unwrap();
8179 }
8180 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8181 client
8182 .mutate(vec![Mutation::Upsert {
8183 table: "tasks".into(),
8184 values: Map::from_iter([
8185 ("id".into(), a.clone()),
8186 ("title".into(), json!("available")),
8187 ]),
8188 base_version: None,
8189 }])
8190 .unwrap();
8191 client.overlay_rebuild_count.set(0);
8192 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8193 let values = vec![
8194 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8195 Some(ColumnValue::String(title.into())),
8196 ];
8197 if images {
8198 let image = Connection::open_in_memory().unwrap();
8199 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8200 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8201 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8202 image
8203 .execute(
8204 "INSERT INTO tasks VALUES (?1,?2,2)",
8205 rusqlite::params![RowParam::Cell(&values[0]), title],
8206 )
8207 .unwrap();
8208 assert!(
8209 matches!(
8210 client.apply_sqlite_image(
8211 &image,
8212 &table,
8213 false,
8214 &[],
8215 1,
8216 7,
8217 "digest"
8218 ),
8219 Ok(1)
8220 ),
8221 "{kind}, images={images}"
8222 );
8223 } else {
8224 let segment = RowsSegment {
8225 table: "tasks".into(),
8226 schema_version: 1,
8227 columns: table.wire_columns.clone(),
8228 blocks: vec![vec![ssp2::segment::SegmentRow {
8229 server_version: 2,
8230 values,
8231 }]],
8232 };
8233 assert!(
8234 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8235 "{kind}"
8236 );
8237 }
8238 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8239 let actual: String = client
8240 .conn
8241 .query_row(
8242 "SELECT title FROM tasks WHERE id=?1",
8243 [RowParam::Cell(&key)],
8244 |row| row.get(0),
8245 )
8246 .unwrap();
8247 assert_eq!(actual, expected, "{kind}, images={images}");
8248 }
8249 assert_eq!(client.overlay_rebuild_count.get(), 0);
8250 }
8251 }
8252 }
8253
8254 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8255 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8256 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8257 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8258 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8259 })))});
8260 let mut client =
8261 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8262 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<8192) INSERT INTO _syncular_base_notes SELECT printf('%08d',x),'p2','note-'||x,1 FROM n").unwrap();
8263 client.rebuild_overlay().expect("rebuild overlay");
8264 client
8265 .mutate(vec![Mutation::Upsert {
8266 table: "tasks".into(),
8267 values: Map::from_iter([
8268 ("id".into(), json!("offline")),
8269 ("project_id".into(), json!("p1")),
8270 ("title".into(), json!("pending edit")),
8271 ]),
8272 base_version: None,
8273 }])
8274 .unwrap();
8275 let image = Connection::open_in_memory().unwrap();
8276 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8277 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8278 image.execute("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<?1) INSERT INTO tasks SELECT printf('%08d',x),'p1','item-'||x,1 FROM n",[rows]).unwrap();
8279 image
8280 .execute(
8281 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8282 [rows],
8283 )
8284 .unwrap();
8285 client.overlay_rebuild_count.set(0);
8286 let table = client.schema.table("tasks").unwrap().clone();
8287 (client, image, table)
8288 }
8289
8290 #[test]
8291 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8292 let (mut client, image, table) = unique_import_fixture(1);
8293 image
8294 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8295 .unwrap();
8296 client
8297 .conn
8298 .execute_batch("PRAGMA max_page_count = 1")
8299 .unwrap();
8300 let effective = [("project_id".into(), vec!["p1".into()])];
8301 assert!(client
8302 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8303 .is_err());
8304 let failure = client.storage_failure.borrow().clone().unwrap();
8305 assert_eq!(failure.code, Some("client.storage_full"));
8306 assert_eq!(failure.sqlite_code, Some(13));
8307 assert!(!failure.message.contains("rollback"));
8308 assert!(client.conn.is_autocommit());
8309 client
8310 .conn
8311 .execute_batch("PRAGMA max_page_count = 1073741823")
8312 .unwrap();
8313 assert!(matches!(
8314 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8315 Ok(1)
8316 ));
8317 assert!(client.conn.is_autocommit());
8318 }
8319
8320 #[test]
8321 fn authoring_storage_failures_are_structured_and_scoped() {
8322 let path = std::env::temp_dir().join(format!(
8323 "syncular-authoring-failure-{}.db",
8324 uuid::Uuid::new_v4()
8325 ));
8326 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8327 {"name":"id","type":"string","nullable":false},
8328 {"name":"project_id","type":"string","nullable":false},
8329 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8330 let mut client = SyncClient::open_path(
8331 "authoring".into(),
8332 &schema,
8333 ClientLimits::default(),
8334 path.to_str().unwrap(),
8335 )
8336 .expect("open client");
8337 client.create_synced_tables().expect("create synced tables");
8338 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8340
8341 let upsert = |id: &str| {
8342 vec![Mutation::Upsert {
8343 table: "tasks".into(),
8344 values: Map::from_iter([
8345 ("id".into(), json!(id)),
8346 ("project_id".into(), json!("p1")),
8347 ("title".into(), json!("first")),
8348 ]),
8349 base_version: None,
8350 }]
8351 };
8352
8353 let locker = Connection::open(&path).expect("second connection");
8354 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8355 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8356 assert_eq!(busy.code, "client.storage_busy");
8357 assert!(busy.retryable);
8358 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8359 assert!(client.pending_commit_ids().is_empty());
8360 assert_eq!(client.local_revision(), 0);
8361
8362 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8364 assert_eq!(invalid.code, "sync.invalid_request");
8365 assert_eq!(invalid.message, "the authoring request is invalid");
8366 assert!(
8367 invalid.details.as_ref().unwrap()["legacyCause"]
8368 .as_str()
8369 .unwrap()
8370 .contains("at least one operation"),
8371 "{invalid:?}"
8372 );
8373
8374 locker.execute_batch("ROLLBACK").unwrap();
8375 let id = client.mutate(upsert("t1")).expect("retry after busy");
8376 assert_eq!(client.pending_commit_ids(), vec![id]);
8377 assert_eq!(client.local_revision(), 1);
8378
8379 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8381 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8382 assert_eq!(busy_again.code, "client.storage_busy");
8383 assert!(busy_again.retryable);
8384 locker.execute_batch("ROLLBACK").unwrap();
8385
8386 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8388 rusqlite::ffi::Error::new(13),
8389 Some("database or disk is full".to_owned()),
8390 )));
8391 assert_eq!(full.code, "client.storage_full");
8392 assert!(!full.retryable);
8393 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8394
8395 drop(locker);
8396 drop(client);
8397 let _ = std::fs::remove_file(&path);
8398 }
8399
8400 #[test]
8401 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8402 let path = std::env::temp_dir().join(format!(
8403 "syncular-caller-values-{}.db",
8404 uuid::Uuid::new_v4()
8405 ));
8406 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8407 {"name":"id","type":"string","nullable":false},
8408 {"name":"project_id","type":"string","nullable":false},
8409 {"name":"title","type":"string","nullable":false},
8410 {"name":"priority","type":"integer","nullable":false},
8411 {"name":"payload","type":"bytes","nullable":false},
8412 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8413 let mut client = SyncClient::open_path(
8414 "caller-values".into(),
8415 &schema,
8416 ClientLimits::default(),
8417 path.to_str().unwrap(),
8418 )
8419 .expect("open client");
8420 client.create_synced_tables().expect("create synced tables");
8421
8422 let values = |id: &str| {
8423 Map::from_iter([
8424 ("id".to_owned(), json!(id)),
8425 ("project_id".to_owned(), json!("p1")),
8426 ("title".to_owned(), json!("first")),
8427 ("priority".to_owned(), json!(1)),
8428 ("payload".to_owned(), json!({"$bytes": "0a"})),
8429 ])
8430 };
8431 let upsert = |values: Map<String, Value>| {
8432 vec![Mutation::Upsert {
8433 table: "tasks".into(),
8434 values,
8435 base_version: None,
8436 }]
8437 };
8438 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8439 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8440
8441 for (label, override_value, cause) in [
8446 ("unknown column", ("nope", json!(1)), "unknown column"),
8447 (
8448 "internal sync column",
8449 ("_sync_version", json!(1)),
8450 "internal sync column",
8451 ),
8452 ("wrong value type", ("title", json!(7)), "expected a string"),
8453 (
8454 "malformed bytes envelope",
8455 ("payload", json!({"$bytes": "zz"})),
8456 "expected exactly",
8457 ),
8458 (
8459 "odd-length bytes envelope",
8460 ("payload", json!({"$bytes": "abc"})),
8461 "expected exactly",
8462 ),
8463 (
8464 "plus-sign bytes envelope",
8465 ("payload", json!({"$bytes": "+a"})),
8466 "expected exactly",
8467 ),
8468 (
8469 "bytes envelope with a second key",
8470 ("payload", json!({"$bytes": "0a", "extra": 1})),
8471 "expected exactly",
8472 ),
8473 (
8474 "no bytes envelope",
8475 ("payload", json!({"nested": true})),
8476 "expected exactly",
8477 ),
8478 ] {
8479 let mut full = values("t2");
8480 full.insert(override_value.0.to_owned(), override_value.1);
8481 let error = client.mutate(upsert(full)).expect_err(label);
8482 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8483 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8484 assert!(!error.retryable, "{label}");
8485 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8486 .as_str()
8487 .unwrap();
8488 assert!(
8489 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8490 "{label}: {legacy}"
8491 );
8492 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8493 assert_eq!(client.local_revision(), 1, "{label}");
8494 assert_eq!(
8495 client.query("SELECT * FROM tasks", &[]).unwrap(),
8496 rows,
8497 "{label}"
8498 );
8499 }
8500
8501 let mut missing = values("t3");
8504 missing.remove("payload");
8505 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8506 assert_eq!(missing_error.code, "sync.invalid_request");
8507 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8508 .as_str()
8509 .unwrap()
8510 .contains("is not nullable"));
8511
8512 let mut bad_row_id = values("t4");
8515 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8516 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8517 assert_eq!(row_id_error.code, "sync.invalid_request");
8518
8519 for (label, partial, cause) in [
8521 (
8522 "patch wrong value type",
8523 ("priority", json!("high")),
8524 "expected an integer",
8525 ),
8526 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8527 ] {
8528 let error = client
8529 .patch(
8530 "tasks",
8531 "t1",
8532 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8533 None,
8534 )
8535 .expect_err(label);
8536 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8537 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8538 assert!(
8539 error.details.as_ref().unwrap()["legacyCause"]
8540 .as_str()
8541 .unwrap()
8542 .contains(cause),
8543 "{label}"
8544 );
8545 }
8546 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8547 assert_eq!(client.local_revision(), 1);
8548 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8549
8550 for (label, payload) in [
8553 ("explicit null", json!(null)),
8554 ("empty envelope", json!({"$bytes": ""})),
8555 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8556 ] {
8557 let mut accepted = values("t-ok");
8558 accepted.insert("extra".to_owned(), payload);
8559 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8560 panic!("{label} must be accepted: {error:?}");
8561 });
8562 assert!(!encoded.is_empty(), "{label}");
8563 }
8564
8565 let next = client
8567 .mutate(upsert(values("t5")))
8568 .expect("valid after failures");
8569 assert_ne!(next, seed);
8570
8571 drop(client);
8572 let _ = std::fs::remove_file(&path);
8573 }
8574
8575 #[test]
8576 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8577 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8578 {"name":"id","type":"string","nullable":false},
8579 {"name":"project_id","type":"string","nullable":false},
8580 {"name":"payload","type":"bytes","nullable":true}],
8581 "scopes":[{"pattern":"project:{project_id}"}]}]});
8582 let open = |path: &std::path::Path| {
8583 SyncClient::open_path(
8584 "legacy-values".into(),
8585 &schema,
8586 ClientLimits::default(),
8587 path.to_str().unwrap(),
8588 )
8589 .expect("open client")
8590 };
8591 let row = |id: &str| {
8592 Map::from_iter([
8593 ("id".to_owned(), json!(id)),
8594 ("project_id".to_owned(), json!("p1")),
8595 ("payload".to_owned(), json!({"$bytes": "0a"})),
8596 ])
8597 };
8598 let upsert = |values| {
8599 vec![Mutation::Upsert {
8600 table: "tasks".into(),
8601 values,
8602 base_version: None,
8603 }]
8604 };
8605 for (label, stored_cell, representable) in [
8606 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8607 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8608 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8609 ("no envelope", json!({"nested": true}), false),
8610 ("wrong declared type", json!("high"), true),
8614 ("stored null for a non-nullable column", json!(null), true),
8615 ] {
8616 let path = std::env::temp_dir().join(format!(
8617 "syncular-legacy-values-{}-{}.db",
8618 label.replace(' ', "-"),
8619 uuid::Uuid::new_v4()
8620 ));
8621 let mut client = open(&path);
8622 client.create_synced_tables().expect("create synced tables");
8623 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8624 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8625 drop(client);
8626
8627 let connection = Connection::open(&path).expect("raw connection");
8629 let (column, cell) = if stored_cell.is_null() {
8630 ("project_id", stored_cell.clone())
8631 } else {
8632 ("payload", stored_cell.clone())
8633 };
8634 let mut planted_values = json!({
8635 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8636 });
8637 planted_values[column] = cell;
8638 let planted = json!([{
8639 "op": "upsert",
8640 "table": "tasks",
8641 "rowId": "t1",
8642 "baseVersion": null,
8643 "values": planted_values
8644 }]);
8645 connection
8646 .execute(
8647 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8648 rusqlite::params![planted.to_string(), stale],
8649 )
8650 .expect("plant legacy intent");
8651 drop(connection);
8652
8653 let reopened = open(&path);
8656 assert_eq!(
8657 reopened.pending_commit_ids(),
8658 vec![successor.clone()],
8659 "{label}"
8660 );
8661 let rejection = reopened
8662 .rejections()
8663 .iter()
8664 .find(|rejection| rejection.client_commit_id == stale)
8665 .unwrap_or_else(|| panic!("{label}: rejection"));
8666 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8667 assert_eq!(
8668 rejection.message, "the persisted commit carries values the current codec refuses",
8669 "{label}"
8670 );
8671 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8672 assert_eq!(
8673 rejection
8674 .details
8675 .as_ref()
8676 .and_then(|details| details.reason.as_deref()),
8677 Some(INVALID_STORED_VALUES_REASON),
8678 "{label}"
8679 );
8680 let journal = reopened
8682 .commit_outcome(&stale)
8683 .expect("read outcome")
8684 .expect("journal entry");
8685 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8686 drop(reopened);
8687 let mut reopened = open(&path);
8688 let reread = reopened
8689 .commit_outcome(&stale)
8690 .expect("read outcome")
8691 .expect("journal entry");
8692 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8693
8694 assert_eq!(
8697 reopened
8698 .query("SELECT id FROM tasks ORDER BY id", &[])
8699 .unwrap(),
8700 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8701 "{label}"
8702 );
8703 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8704 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8705 assert!(!reopened
8706 .drop_unencodable_outbox(false, None)
8707 .expect("idempotent prepass"));
8708 let mut transport = CountingRealtimeTransport::default();
8709 let outcome = reopened.sync(&mut transport);
8710 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8711 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8712 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8713 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8714 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8715 drop(reopened);
8716 let _ = std::fs::remove_file(&path);
8717 }
8718 }
8719
8720 #[test]
8721 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8722 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8723 {"name":"id","type":"string","nullable":false},
8724 {"name":"project_id","type":"string","nullable":false},
8725 {"name":"payload","type":"bytes","nullable":true}],
8726 "scopes":[{"pattern":"project:{project_id}"}]}]});
8727 for (label, column, planted) in [
8728 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8729 (
8730 "stored null for a non-nullable column",
8731 "project_id",
8732 json!(null),
8733 ),
8734 ("wrong declared type", "payload", json!(7)),
8735 ] {
8736 let path = std::env::temp_dir().join(format!(
8737 "syncular-shared-row-{}-{}.db",
8738 label.replace(' ', "-"),
8739 uuid::Uuid::new_v4()
8740 ));
8741 let open = |path: &std::path::Path| {
8742 SyncClient::open_path(
8743 "shared-row".into(),
8744 &schema,
8745 ClientLimits::default(),
8746 path.to_str().unwrap(),
8747 )
8748 .expect("open client")
8749 };
8750 let row = |id: &str| {
8751 Map::from_iter([
8752 ("id".to_owned(), json!(id)),
8753 ("project_id".to_owned(), json!("p1")),
8754 ("payload".to_owned(), json!({"$bytes": "0a"})),
8755 ])
8756 };
8757 let upsert = |values| {
8758 vec![Mutation::Upsert {
8759 table: "tasks".into(),
8760 values,
8761 base_version: None,
8762 }]
8763 };
8764 let mut client = open(&path);
8765 client.create_synced_tables().expect("create synced tables");
8766 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8767 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8768 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8769 drop(client);
8770
8771 let connection = Connection::open(&path).expect("raw connection");
8772 for commit_id in [&older, &newer] {
8773 let mut values = row("t1");
8774 values.insert(column.to_owned(), planted.clone());
8775 let planted_ops = json!([{
8776 "op": "upsert",
8777 "table": "tasks",
8778 "rowId": "t1",
8779 "baseVersion": null,
8780 "values": values
8781 }]);
8782 connection
8783 .execute(
8784 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8785 rusqlite::params![planted_ops.to_string(), commit_id],
8786 )
8787 .expect("plant legacy intent");
8788 }
8789 drop(connection);
8790
8791 let mut reopened = open(&path);
8793 assert_eq!(
8794 reopened.pending_commit_ids(),
8795 vec![successor.clone()],
8796 "{label}"
8797 );
8798 assert_eq!(
8799 reopened
8800 .rejections()
8801 .iter()
8802 .map(|rejection| rejection.client_commit_id.clone())
8803 .collect::<Vec<_>>(),
8804 vec![older.clone(), newer.clone()],
8805 "{label}"
8806 );
8807 assert!(
8808 reopened
8809 .rejections()
8810 .iter()
8811 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8812 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8813 == Some(INVALID_STORED_VALUES_REASON)),
8814 "{label}"
8815 );
8816 for commit_id in [&older, &newer] {
8817 assert!(
8818 matches!(
8819 reopened
8820 .commit_outcome(commit_id)
8821 .expect("outcome")
8822 .map(|o| o.status),
8823 Some(CommitOutcomeStatus::Rejected)
8824 ),
8825 "{label}"
8826 );
8827 }
8828 assert_eq!(
8829 reopened
8830 .query("SELECT id FROM tasks ORDER BY id", &[])
8831 .unwrap(),
8832 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8833 "{label}"
8834 );
8835 let revision = reopened.local_revision();
8836 let batches = reopened.drain_change_batches();
8837 assert!(
8838 batches.iter().any(|batch| batch.rejections_changed
8839 && batch.outcomes_changed
8840 && batch.tables.iter().any(|change| change.table == "tasks")
8841 && batch.revision == revision.to_string()),
8842 "{label}: {batches:?}"
8843 );
8844 drop(reopened);
8845
8846 let mut stable = open(&path);
8849 assert_eq!(stable.local_revision(), revision, "{label}");
8850 assert!(stable.drain_change_batches().is_empty(), "{label}");
8851 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8852 drop(stable);
8853 let _ = std::fs::remove_file(&path);
8854 }
8855 }
8856
8857 #[test]
8858 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8859 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8860 {"name":"id","type":"string","nullable":false},
8861 {"name":"project_id","type":"string","nullable":false},
8862 {"name":"payload","type":"bytes","nullable":true}],
8863 "scopes":[{"pattern":"project:{project_id}"}]}]});
8864 for (label, second_op) in [
8865 (
8866 "delete carrying values",
8867 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8868 ),
8869 (
8870 "baseVersion beyond the safe bound",
8871 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8872 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8873 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8874 ),
8875 ] {
8876 let path = std::env::temp_dir().join(format!(
8877 "syncular-journal-shape-{}-{}.db",
8878 label.replace(' ', "-"),
8879 uuid::Uuid::new_v4()
8880 ));
8881 let open = |path: &std::path::Path| {
8882 SyncClient::open_path(
8883 "journal-shape".into(),
8884 &schema,
8885 ClientLimits::default(),
8886 path.to_str().unwrap(),
8887 )
8888 .expect("open client")
8889 };
8890 let values = |id: &str| {
8891 Map::from_iter([
8892 ("id".to_owned(), json!(id)),
8893 ("project_id".to_owned(), json!("p1")),
8894 ("payload".to_owned(), json!({"$bytes": "0a"})),
8895 ])
8896 };
8897 let upsert = |values| {
8898 vec![Mutation::Upsert {
8899 table: "tasks".into(),
8900 values,
8901 base_version: None,
8902 }]
8903 };
8904 let mut client = open(&path);
8905 client.create_synced_tables().expect("create synced tables");
8906 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
8907 drop(client);
8908
8909 let connection = Connection::open(&path).expect("raw connection");
8910 let planted = json!([
8914 {
8915 "op": "upsert",
8916 "table": "tasks",
8917 "rowId": "t1",
8918 "baseVersion": null,
8919 "values": {"id": "t1", "project_id": null,
8920 "payload": {"$bytes": "0a"}}
8921 },
8922 second_op
8923 ]);
8924 connection
8925 .execute(
8926 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8927 rusqlite::params![planted.to_string(), stale],
8928 )
8929 .expect("plant legacy intent");
8930 drop(connection);
8931
8932 let reopened = open(&path);
8933 let journal = reopened
8934 .commit_outcome(&stale)
8935 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
8936 .unwrap_or_else(|| panic!("{label}: journal entry"));
8937 assert!(
8938 matches!(journal.status, CommitOutcomeStatus::Rejected),
8939 "{label}"
8940 );
8941 assert!(journal.operations.is_none(), "{label}");
8942 assert!(
8943 reopened
8944 .commit_outcomes(CommitOutcomeQuery::default())
8945 .is_ok(),
8946 "{label}: outcome list query"
8947 );
8948 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
8949 drop(reopened);
8950 let _ = std::fs::remove_file(&path);
8951 }
8952 }
8953
8954 #[test]
8955 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
8956 let schema = |version: i32, extra: bool| {
8957 let mut columns = vec![
8958 json!({"name":"id","type":"string","nullable":false}),
8959 json!({"name":"project_id","type":"string","nullable":false}),
8960 json!({"name":"payload","type":"bytes","nullable":true}),
8961 ];
8962 if extra {
8963 columns.push(json!({"name":"later","type":"string","nullable":true}));
8964 }
8965 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
8966 "scopes":[{"pattern":"project:{project_id}"}]}]})
8967 };
8968 for (label, bump) in [
8969 ("same-version recreation", false),
8970 ("version-bump reset", true),
8971 ("version-only bump reset", true),
8972 ] {
8973 let path = std::env::temp_dir().join(format!(
8974 "syncular-legacy-reset-{}-{}.db",
8975 label.replace(' ', "-"),
8976 uuid::Uuid::new_v4()
8977 ));
8978 let v1 = schema(1, false);
8979 let open = |schema: &Value, path: &std::path::Path| {
8980 SyncClient::open_path(
8981 "legacy-reset".into(),
8982 schema,
8983 ClientLimits::default(),
8984 path.to_str().unwrap(),
8985 )
8986 .expect("open client")
8987 };
8988 let row = |id: &str| {
8989 Map::from_iter([
8990 ("id".to_owned(), json!(id)),
8991 ("project_id".to_owned(), json!("p1")),
8992 ("payload".to_owned(), json!({"$bytes": "0a"})),
8993 ])
8994 };
8995 let upsert = |values| {
8996 vec![Mutation::Upsert {
8997 table: "tasks".into(),
8998 values,
8999 base_version: None,
9000 }]
9001 };
9002 let mut client = open(&v1, &path);
9003 client.create_synced_tables().expect("create synced tables");
9004 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9005 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9006 drop(client);
9007
9008 let connection = Connection::open(&path).expect("raw connection");
9009 let planted = json!([{
9010 "op": "upsert",
9011 "table": "tasks",
9012 "rowId": "t1",
9013 "baseVersion": null,
9014 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9015 }]);
9016 connection
9017 .execute(
9018 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9019 rusqlite::params![planted.to_string(), stale],
9020 )
9021 .expect("plant legacy intent");
9022 drop(connection);
9023
9024 let target = if bump {
9025 schema(2, label == "version-bump reset")
9026 } else {
9027 v1.clone()
9028 };
9029 let reopened = open(&target, &path);
9030 let rejection = reopened
9031 .rejections()
9032 .iter()
9033 .find(|rejection| rejection.client_commit_id == stale)
9034 .unwrap_or_else(|| panic!("{label}: rejection"));
9035 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9036 assert_eq!(
9037 rejection
9038 .details
9039 .as_ref()
9040 .and_then(|details| details.reason.as_deref()),
9041 Some(INVALID_STORED_VALUES_REASON),
9042 "{label}"
9043 );
9044 assert!(rejection.operation.is_some(), "{label}");
9045 assert_eq!(
9046 reopened.pending_commit_ids(),
9047 vec![successor.clone()],
9048 "{label}"
9049 );
9050 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9055 if label == "version-bump reset" {
9056 assert_eq!(
9057 reopened
9058 .query("SELECT id FROM tasks ORDER BY id", &[])
9059 .unwrap(),
9060 Vec::<Map<String, Value>>::new(),
9061 "{label}"
9062 );
9063 let mut next = reopened;
9067 let revision = next.local_revision();
9068 let batches = next.drain_change_batches();
9069 assert!(
9070 batches.iter().any(|batch| batch.rejections_changed
9071 && batch.outcomes_changed
9072 && batch.tables.iter().any(|change| change.table == "tasks")
9073 && batch.revision == revision.to_string()),
9074 "{label}: {batches:?}"
9075 );
9076 next.mutate(upsert(Map::from_iter([
9077 ("id".to_owned(), json!("t3")),
9078 ("project_id".to_owned(), json!("p1")),
9079 ("payload".to_owned(), json!({"$bytes": "0b"})),
9080 ("later".to_owned(), json!("v2")),
9081 ])))
9082 .expect("v2 authoring after the reset");
9083 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9084 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9085 drop(next);
9086 } else {
9087 assert_eq!(
9088 reopened
9089 .query("SELECT id FROM tasks ORDER BY id", &[])
9090 .unwrap(),
9091 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9092 "{label}"
9093 );
9094 drop(reopened);
9095 }
9096
9097 if !bump {
9100 let mut recreated = open(&v1, &path);
9101 let fresh = recreated
9102 .mutate(upsert(row("t3")))
9103 .expect("fresh commit while open");
9104 recreated.drain_change_batches();
9105 let planted_fresh = json!([{
9106 "op": "upsert",
9107 "table": "tasks",
9108 "rowId": "t3",
9109 "baseVersion": null,
9110 "values": {"id": "t3", "project_id": "p1",
9111 "payload": {"$bytes": "zz"}}
9112 }]);
9113 let connection = Connection::open(&path).expect("raw connection");
9114 let affected = connection
9115 .execute(
9116 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9117 rusqlite::params![planted_fresh.to_string(), fresh],
9118 )
9119 .expect("plant fresh legacy intent");
9120 assert_eq!(
9121 affected, 1,
9122 "{label}: the in-place plant must hit the queued commit"
9123 );
9124 drop(connection);
9125 let revision_before = recreated.local_revision();
9126 recreated
9127 .recreate_with_schema(&v1)
9128 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9129 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9130 let fresh_rejection = recreated
9131 .rejections()
9132 .iter()
9133 .find(|rejection| rejection.client_commit_id == fresh)
9134 .expect("the in-place recovery rejection");
9135 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9136 assert_eq!(
9137 fresh_rejection
9138 .details
9139 .as_ref()
9140 .and_then(|details| details.reason.as_deref()),
9141 Some(INVALID_STORED_VALUES_REASON),
9142 "{label}"
9143 );
9144 assert!(
9145 matches!(
9146 recreated
9147 .commit_outcome(&fresh)
9148 .expect("outcome read")
9149 .map(|outcome| outcome.status),
9150 Some(CommitOutcomeStatus::Rejected)
9151 ),
9152 "{label}"
9153 );
9154 assert!(recreated.local_revision() > revision_before, "{label}");
9155 let revision = recreated.local_revision();
9156 let batches = recreated.drain_change_batches();
9157 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9158 assert!(
9159 batches.iter().any(|batch| batch.rejections_changed
9160 && batch.outcomes_changed
9161 && batch.tables.iter().any(|change| change.table == "tasks")
9162 && batch.revision == revision.to_string()),
9163 "{label}: {batches:?}"
9164 );
9165 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9166 assert_eq!(
9167 recreated
9168 .query("SELECT id FROM tasks ORDER BY id", &[])
9169 .unwrap(),
9170 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9171 "{label}: the recovered commit leaves no projection"
9172 );
9173 drop(recreated);
9174 }
9175 let _ = std::fs::remove_file(&path);
9176 }
9177 }
9178
9179 #[test]
9180 fn legacy_values_recover_over_a_synced_authoritative_row() {
9181 let path = std::env::temp_dir().join(format!(
9182 "syncular-authoritative-row-{}.db",
9183 uuid::Uuid::new_v4()
9184 ));
9185 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9186 {"name":"id","type":"string","nullable":false},
9187 {"name":"project_id","type":"string","nullable":false},
9188 {"name":"title","type":"string","nullable":false},
9189 {"name":"payload","type":"bytes","nullable":true}],
9190 "scopes":[{"pattern":"project:{project_id}"}]}]});
9191 let open = |path: &std::path::Path| {
9192 SyncClient::open_path(
9193 "authoritative-row".into(),
9194 &schema,
9195 ClientLimits::default(),
9196 path.to_str().unwrap(),
9197 )
9198 .expect("open client")
9199 };
9200 let row = |title: &str| {
9201 Map::from_iter([
9202 ("id".to_owned(), json!("t1")),
9203 ("project_id".to_owned(), json!("p1")),
9204 ("title".to_owned(), json!(title)),
9205 ("payload".to_owned(), json!({"$bytes": "0a"})),
9206 ])
9207 };
9208 let upsert = |values| {
9209 vec![Mutation::Upsert {
9210 table: "tasks".into(),
9211 values,
9212 base_version: None,
9213 }]
9214 };
9215 let mut client = open(&path);
9216 client.create_synced_tables().expect("create synced tables");
9217 client
9220 .conn
9221 .execute_batch(
9222 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9223 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9224 )
9225 .expect("seed authoritative row");
9226 let older = client.mutate(upsert(row("older"))).expect("older");
9227 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9228 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9229 drop(client);
9230
9231 let connection = Connection::open(&path).expect("raw connection");
9232 for commit_id in [&older, &newer] {
9233 let planted = json!([{
9234 "op": "upsert",
9235 "table": "tasks",
9236 "rowId": "t1",
9237 "baseVersion": null,
9238 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9239 "payload": {"$bytes": "zz"}}
9240 }]);
9241 connection
9242 .execute(
9243 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9244 rusqlite::params![planted.to_string(), commit_id],
9245 )
9246 .expect("plant legacy intent");
9247 }
9248 drop(connection);
9249
9250 let reopened = open(&path);
9251 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9252 assert_eq!(
9253 reopened
9254 .rejections()
9255 .iter()
9256 .map(|rejection| rejection.client_commit_id.clone())
9257 .collect::<Vec<_>>(),
9258 vec![older.clone(), newer.clone()]
9259 );
9260 assert_eq!(
9263 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9264 Ok(vec![Map::from_iter([
9265 ("id".to_owned(), json!("t1")),
9266 ("title".to_owned(), json!("successor")),
9267 ("_syncular_version".to_owned(), json!(1)),
9268 ])])
9269 );
9270
9271 drop(reopened);
9274 let connection = Connection::open(&path).expect("raw connection");
9275 connection
9276 .execute(
9277 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9278 rusqlite::params![
9279 json!([{
9280 "op": "upsert",
9281 "table": "tasks",
9282 "rowId": "t1",
9283 "baseVersion": null,
9284 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9285 "payload": {"$bytes": "zz"}}
9286 }])
9287 .to_string(),
9288 successor
9289 ],
9290 )
9291 .expect("plant successor intent");
9292 drop(connection);
9293 let late = open(&path);
9294 assert!(late.pending_commit_ids().is_empty());
9295 assert_eq!(
9296 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9297 Ok(vec![Map::from_iter([
9298 ("id".to_owned(), json!("t1")),
9299 ("title".to_owned(), json!("authoritative")),
9300 ("_syncular_version".to_owned(), json!(1)),
9301 ])])
9302 );
9303 drop(late);
9304 let _ = std::fs::remove_file(&path);
9305 }
9306
9307 #[test]
9308 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9309 let path = std::env::temp_dir().join(format!(
9310 "syncular-value-recovery-commit-{}.db",
9311 uuid::Uuid::new_v4()
9312 ));
9313 let path_str = path.to_str().expect("path");
9314 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9315 {"name":"id","type":"string","nullable":false},
9316 {"name":"project_id","type":"string","nullable":false},
9317 {"name":"payload","type":"bytes","nullable":true}],
9318 "scopes":[{"pattern":"project:{project_id}"}]}]});
9319 let conn = Connection::open(path_str).expect("replica connection");
9322 let mut client = SyncClient::with_connection(
9323 "value-recovery-commit".into(),
9324 &schema,
9325 ClientLimits::default(),
9326 conn,
9327 )
9328 .expect("replica");
9329 assert_eq!(
9330 client
9331 .conn
9332 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9333 .expect("journal mode"),
9334 "delete"
9335 );
9336 client.create_synced_tables().expect("create synced tables");
9337 let stale = client
9338 .mutate(vec![Mutation::Upsert {
9339 table: "tasks".into(),
9340 values: Map::from_iter([
9341 ("id".to_owned(), json!("t1")),
9342 ("project_id".to_owned(), json!("p1")),
9343 ("payload".to_owned(), json!({"$bytes": "0a"})),
9344 ]),
9345 base_version: None,
9346 }])
9347 .expect("queued write");
9348 client.drain_change_batches();
9349 let affected = client
9350 .conn
9351 .execute(
9352 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9353 rusqlite::params![
9354 json!([{
9355 "op": "upsert",
9356 "table": "tasks",
9357 "rowId": "t1",
9358 "baseVersion": null,
9359 "values": {"id": "t1", "project_id": "p1",
9360 "payload": {"$bytes": "zz"}}
9361 }])
9362 .to_string(),
9363 stale
9364 ],
9365 )
9366 .expect("plant legacy intent");
9367 assert_eq!(affected, 1, "the planted row must exist");
9368 let revision = client.local_revision();
9369 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9370 let prior_outbox = client.pending_commit_ids();
9371 let prior_rejections = client.rejections().len();
9372 let prior_changes = client.change_queue.len();
9373 let prior_intents = client.sync_intent_queue.len();
9374 let prior_failed = client.failed_commits.len();
9375 let reader = Connection::open(path_str).expect("reader");
9378 reader
9379 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9380 .expect("reader snapshot");
9381 client
9382 .conn
9383 .busy_timeout(std::time::Duration::ZERO)
9384 .expect("busy timeout");
9385 let error = client
9386 .recreate_with_schema(&schema)
9387 .expect_err("a blocked COMMIT must refuse the recovery");
9388 assert!(error.starts_with("client.storage_busy:"), "{error}");
9389 assert!(client.conn.is_autocommit(), "the transaction is released");
9390 assert_eq!(client.local_revision(), revision);
9391 assert_eq!(client.pending_commit_ids(), prior_outbox);
9392 assert_eq!(client.rejections().len(), prior_rejections);
9393 assert_eq!(client.change_queue.len(), prior_changes);
9394 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9395 assert_eq!(client.failed_commits.len(), prior_failed);
9396 assert_eq!(
9397 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9398 rows
9399 );
9400 assert!(
9401 client
9402 .commit_outcome(&stale)
9403 .expect("outcome read")
9404 .is_none(),
9405 "the rolled-back recovery kept no outcome"
9406 );
9407 reader.execute_batch("ROLLBACK").expect("release reader");
9409 client
9410 .recreate_with_schema(&schema)
9411 .expect("retry after the blocked commit");
9412 assert!(client.pending_commit_ids().is_empty());
9413 assert_eq!(client.rejections().len(), prior_rejections + 1);
9414 assert!(client.local_revision() > revision);
9415 let batches = client.drain_change_batches();
9416 assert!(
9417 batches.iter().any(|batch| batch.rejections_changed
9418 && batch.outcomes_changed
9419 && batch.tables.iter().any(|change| change.table == "tasks")),
9420 "{batches:?}"
9421 );
9422 drop(client);
9423 let _ = std::fs::remove_file(&path);
9424 }
9425
9426 #[cfg(feature = "e2ee")]
9427 #[test]
9428 fn a_failed_recovery_restores_retained_failed_commits() {
9429 let path = std::env::temp_dir().join(format!(
9430 "syncular-value-recovery-retention-{}.db",
9431 uuid::Uuid::new_v4()
9432 ));
9433 let path_str = path.to_str().expect("path");
9434 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9435 {"name":"id","type":"string","nullable":false},
9436 {"name":"project_id","type":"string","nullable":false},
9437 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9438 "scopes":[{"pattern":"project:{project_id}"}]}]});
9439 let conn = Connection::open(path_str).expect("replica connection");
9440 let mut client = SyncClient::with_connection(
9441 "value-recovery-retention".into(),
9442 &schema,
9443 ClientLimits::default(),
9444 conn,
9445 )
9446 .expect("replica");
9447 client.create_synced_tables().expect("create synced tables");
9448 client.set_retain_failed_commits(true).expect("retention");
9449 client.set_encryption(crate::values::EncryptionConfig::default());
9451 client
9452 .mutate(vec![Mutation::Upsert {
9453 table: "secrets".into(),
9454 values: Map::from_iter([
9455 ("id".to_owned(), json!("s1")),
9456 ("project_id".to_owned(), json!("p1")),
9457 ("note".to_owned(), json!("secret")),
9458 ]),
9459 base_version: None,
9460 }])
9461 .expect("queued write");
9462 client.drain_change_batches();
9463 let revision = client.local_revision();
9464 let prior_outbox = client.pending_commit_ids();
9465 let prior_rejections = client.rejections().len();
9466 let prior_failed = client.failed_commits.len();
9467 let reader = Connection::open(path_str).expect("reader");
9468 reader
9469 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9470 .expect("reader snapshot");
9471 client
9472 .conn
9473 .busy_timeout(std::time::Duration::ZERO)
9474 .expect("busy timeout");
9475 let error = client
9476 .drop_unencodable_outbox(true, None)
9477 .expect_err("a blocked COMMIT must refuse the recovery");
9478 assert!(error.starts_with("client.storage_busy:"), "{error}");
9479 assert_eq!(client.failed_commits.len(), prior_failed);
9480 assert_eq!(client.pending_commit_ids(), prior_outbox);
9481 assert_eq!(client.rejections().len(), prior_rejections);
9482 assert_eq!(client.local_revision(), revision);
9483 reader.execute_batch("ROLLBACK").expect("release reader");
9485 client
9486 .drop_unencodable_outbox(true, None)
9487 .expect("retry after the blocked commit");
9488 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9489 assert!(client.pending_commit_ids().is_empty());
9490 assert_eq!(client.rejections().len(), prior_rejections + 1);
9491 drop(client);
9492 let _ = std::fs::remove_file(&path);
9493 }
9494
9495 #[test]
9496 fn client_error_maps_legacy_codes_and_static_messages() {
9497 let code_only = ClientError::from("sync.unknown_table".to_owned());
9498 assert_eq!(code_only.code, "sync.unknown_table");
9499 assert_eq!(code_only.message, "the commit targets an unknown table");
9500 assert!(code_only.details.is_none());
9501
9502 let dynamic = ClientError::from(
9503 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9504 );
9505 assert_eq!(dynamic.code, "sync.invalid_request");
9506 assert_eq!(dynamic.message, "the authoring request is invalid");
9507 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9508 .as_str()
9509 .unwrap()
9510 .contains("patch cannot write scope column"));
9511
9512 let unclassified =
9513 ClientError::from("a commit must contain at least one operation".to_owned());
9514 assert_eq!(unclassified.code, "client.failed");
9515 assert_eq!(unclassified.message, "the local authoring operation failed");
9516 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9517 .as_str()
9518 .unwrap()
9519 .contains("at least one operation"));
9520
9521 let metadata = ClientError::from(QueryReadFailure {
9523 code: None,
9524 sqlite_code: Some(1),
9525 sqlite_message: Some("generic SQLite prose".to_owned()),
9526 rollback_failure: Some(Box::new(QueryReadFailure::from(
9527 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9528 ))),
9529 message: "unclassified read".to_owned(),
9530 });
9531 assert_eq!(metadata.code, "client.failed");
9532 assert_eq!(metadata.message, "the local authoring operation failed");
9533 let details = metadata.details.as_ref().unwrap();
9534 assert_eq!(details["sqliteCode"], 1);
9535 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9536 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9537 assert_eq!(details["legacyCause"], "unclassified read");
9538 }
9539
9540 #[test]
9541 fn schema_marker_read_keeps_storage_classification() {
9542 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9543 rusqlite::ffi::Error::new(5),
9544 Some("database is locked".to_owned()),
9545 ));
9546 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9547 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9548 rusqlite::ffi::Error::new(1),
9549 Some("unreadable".to_owned()),
9550 ));
9551 assert_eq!(
9552 invalid,
9553 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9554 );
9555
9556 let path =
9559 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9560 let setup = Connection::open(&path).expect("setup connection");
9561 setup
9562 .execute_batch(
9563 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9564 )
9565 .unwrap();
9566 let locker = Connection::open(&path).expect("locker connection");
9567 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9568 let reader = Connection::open(&path).expect("reader connection");
9569 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9570 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9571 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9572 locker.execute_batch("ROLLBACK").unwrap();
9573 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9574 let rows = setup
9575 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9576 row.get::<_, i64>(0)
9577 })
9578 .unwrap();
9579 assert_eq!(rows, 1, "the guard performed no writes");
9580 drop(reader);
9581 drop(locker);
9582 drop(setup);
9583 let _ = std::fs::remove_file(&path);
9584 }
9585
9586 #[cfg(feature = "crdt-yjs")]
9587 #[test]
9588 fn crdt_authoring_pre_read_preserves_storage_classification() {
9589 let path =
9590 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9591 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9592 {"name":"id","type":"string","nullable":false},
9593 {"name":"project_id","type":"string","nullable":false},
9594 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9595 "scopes":[{"pattern":"project:{project_id}"}]}]});
9596 let mut client = SyncClient::open_path(
9597 "crdt-busy".into(),
9598 &schema,
9599 ClientLimits::default(),
9600 path.to_str().unwrap(),
9601 )
9602 .expect("open client");
9603 client.create_synced_tables().expect("create synced tables");
9604 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9605
9606 let locker = Connection::open(&path).expect("locker connection");
9607 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9608 let busy = client
9609 .crdt_apply_update("notes", "n1", "doc", &[])
9610 .expect_err("busy crdt pre-read");
9611 assert_eq!(busy.code, "client.storage_busy");
9612 assert!(busy.retryable);
9613 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9614 assert!(client.pending_commit_ids().is_empty());
9615 assert_eq!(client.local_revision(), 0);
9616
9617 locker.execute_batch("ROLLBACK").unwrap();
9618 drop(locker);
9619 drop(client);
9620 let _ = std::fs::remove_file(&path);
9621 }
9622
9623 #[test]
9624 fn large_unique_import_copies_no_unrelated_tables() {
9625 let (mut client, image, table) = unique_import_fixture(10240);
9626 let pending = client.pending_commit_ids();
9627 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9628 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9629 assert!(matches!(
9630 client.apply_sqlite_image(
9631 &image,
9632 &table,
9633 true,
9634 &[("project_id".into(), vec!["p1".into()])],
9635 10240,
9636 7,
9637 "digest"
9638 ),
9639 Ok(10240)
9640 ));
9641 assert_eq!(client.overlay_rebuild_count.get(), 0);
9642 assert_eq!(client.pending_commit_ids(), pending);
9643 assert_eq!(
9644 client
9645 .query("SELECT count(*) AS n FROM tasks", &[])
9646 .unwrap()[0]["n"],
9647 10241
9648 );
9649 assert_eq!(
9650 client
9651 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9652 .unwrap()[0]["n"],
9653 10241
9654 );
9655 assert_eq!(
9656 client
9657 .query("SELECT count(*) AS n FROM notes_fts", &[])
9658 .unwrap()[0]["n"],
9659 8192
9660 );
9661 }
9662
9663 #[test]
9664 #[ignore = "manual scaling benchmark; see bench/README.md"]
9665 fn benchmark_unique_image_import() {
9666 let mut samples = Vec::new();
9667 for rows in [10240, 51200, 102400] {
9668 for trial in 0..4 {
9669 let (mut client, image, table) = unique_import_fixture(rows);
9670 let start = std::time::Instant::now();
9671 assert!(
9672 matches!(client.apply_sqlite_image(&image,&table,true,&[("project_id".into(),vec!["p1".into()])],rows as i64,7,"digest"),Ok(n) if n as usize == rows)
9673 );
9674 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9675 assert_eq!(
9676 client
9677 .query("SELECT count(*) AS n FROM tasks", &[])
9678 .unwrap()[0]["n"],
9679 rows + 1
9680 );
9681 assert_eq!(
9682 client
9683 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9684 .unwrap()[0]["n"],
9685 rows + 1
9686 );
9687 assert_eq!(
9688 client
9689 .query("SELECT count(*) AS n FROM notes_fts", &[])
9690 .unwrap()[0]["n"],
9691 8192
9692 );
9693 assert_eq!(client.pending_commit_ids().len(), 1);
9694 if trial > 0 {
9695 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9696 }
9697 }
9698 }
9699 let artifact = json!({"boundary":"Rust core; in-memory SQLite; one pending edit; secondary unique indexes and FTS; 8192 unrelated rows; one warmup per size","optimized":!cfg!(debug_assertions),"sqliteVersion":rusqlite::version(),"os":std::env::consts::OS,"arch":std::env::consts::ARCH,"sourceHash":bytes_to_hex(&Sha256::digest(include_str!("client.rs").as_bytes())),"samples":samples});
9700 let output = serde_json::to_string_pretty(&artifact).unwrap();
9701 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9702 use std::io::Write;
9703 let mut file = std::fs::OpenOptions::new()
9704 .write(true)
9705 .create_new(true)
9706 .open(path)
9707 .unwrap();
9708 file.write_all(output.as_bytes()).unwrap();
9709 } else {
9710 println!("{output}");
9711 }
9712 }
9713
9714 #[test]
9715 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9716 let schema = json!({
9717 "version": 1,
9718 "tables": [{
9719 "name": "catalogue_codes",
9720 "primaryKey": "id",
9721 "columns": [
9722 { "name": "id", "type": "string", "nullable": false },
9723 { "name": "release_id", "type": "string", "nullable": false },
9724 { "name": "code", "type": "string", "nullable": false },
9725 { "name": "title", "type": "string", "nullable": false }
9726 ],
9727 "scopes": [{ "pattern": "release:{release_id}" }],
9728 "ftsIndexes": [{
9729 "name": "catalogue_codes_fts",
9730 "columns": ["code", "title"],
9731 "tokenize": "unicode61 remove_diacritics 2"
9732 }]
9733 }]
9734 });
9735 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9736 .expect("FTS5 client");
9737 let insert_trigger: String = client
9738 .conn
9739 .query_row(
9740 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9741 [],
9742 |row| row.get(0),
9743 )
9744 .expect("insert trigger");
9745 let replace_guard: String = client
9746 .conn
9747 .query_row(
9748 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9749 [],
9750 |row| row.get(0),
9751 )
9752 .expect("replace guard");
9753 assert!(!insert_trigger.contains("DELETE FROM"));
9754 assert!(replace_guard.contains("BEFORE INSERT"));
9755 assert!(replace_guard.contains("WHEN EXISTS"));
9756 let search = |client: &SyncClient, query: &str| {
9757 client
9758 .query(
9759 "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",
9760 &[Value::from(query)],
9761 )
9762 .expect("FTS query")
9763 };
9764
9765 client
9766 .mutate(vec![Mutation::Upsert {
9767 table: "catalogue_codes".to_owned(),
9768 values: Map::from_iter([
9769 ("id".to_owned(), Value::from("c1")),
9770 ("release_id".to_owned(), Value::from("r1")),
9771 ("code".to_owned(), Value::from("A01")),
9772 ("title".to_owned(), Value::from("Cholera")),
9773 ]),
9774 base_version: None,
9775 }])
9776 .expect("insert code");
9777 assert_eq!(search(&client, "cholera").len(), 1);
9778 let prior = client
9779 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9780 .unwrap();
9781 client
9782 .conn
9783 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9784 .unwrap();
9785 client.create_synced_tables().unwrap();
9786 assert_eq!(
9787 client
9788 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9789 .unwrap(),
9790 prior
9791 );
9792 let plan = client.query("EXPLAIN QUERY PLAN SELECT rowid FROM catalogue_codes_fts WHERE rowid = (SELECT id FROM _syncular_fts_catalogue_codes_fts WHERE source_id = 'c1')", &[]).unwrap();
9793 let rendered = serde_json::to_string(&plan).unwrap();
9794 assert!(rendered.contains("COVERING INDEX"));
9795 assert!(rendered.contains("INDEX 0:="));
9796 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9797 assert_eq!(search(&client, "cholera").len(), 1);
9798
9799 client
9800 .mutate(vec![Mutation::Upsert {
9801 table: "catalogue_codes".to_owned(),
9802 values: Map::from_iter([
9803 ("id".to_owned(), Value::from("c1")),
9804 ("release_id".to_owned(), Value::from("r1")),
9805 ("code".to_owned(), Value::from("A01")),
9806 ("title".to_owned(), Value::from("Enteric infection")),
9807 ]),
9808 base_version: None,
9809 }])
9810 .expect("update code");
9811 assert!(search(&client, "cholera").is_empty());
9812 assert_eq!(search(&client, "enteric").len(), 1);
9813
9814 client
9815 .mutate(vec![Mutation::Delete {
9816 table: "catalogue_codes".to_owned(),
9817 row_id: "c1".to_owned(),
9818 base_version: None,
9819 }])
9820 .expect("delete code");
9821 assert!(search(&client, "enteric").is_empty());
9822 }
9823
9824 #[test]
9825 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9826 let schema = json!({
9827 "version": 1,
9828 "tables": [{
9829 "name": "patient_notes",
9830 "primaryKey": "id",
9831 "columns": [
9832 { "name": "id", "type": "string", "nullable": false },
9833 { "name": "practice_id", "type": "string", "nullable": false },
9834 { "name": "encryption_key_id", "type": "string", "nullable": false },
9835 { "name": "title", "type": "string", "nullable": false }
9836 ],
9837 "scopes": [{ "pattern": "practice:{practice_id}" }],
9838 "ftsIndexes": [{
9839 "name": "patient_notes_fts",
9840 "columns": ["title"],
9841 "tokenize": "unicode61 remove_diacritics 2"
9842 }]
9843 }]
9844 });
9845 let mut client = SyncClient::new(
9846 "local-purge-test".to_owned(),
9847 &schema,
9848 ClientLimits::default(),
9849 )
9850 .expect("local purge client");
9851 client
9852 .conn
9853 .execute(
9854 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9855 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9856 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9857 [],
9858 )
9859 .expect("seed base rows");
9860 client.overlay_dirty.set(true);
9861 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9862
9863 let note = |id: &str, key_id: &str, title: &str| {
9864 Map::from_iter([
9865 ("id".to_owned(), Value::from(id)),
9866 ("practice_id".to_owned(), Value::from("practice-1")),
9867 ("encryption_key_id".to_owned(), Value::from(key_id)),
9868 ("title".to_owned(), Value::from(title)),
9869 ])
9870 };
9871 let doomed = client
9872 .mutate(vec![
9873 Mutation::Upsert {
9874 table: "patient_notes".to_owned(),
9875 values: note("target", "key-revoked", "Target changed"),
9876 base_version: None,
9877 },
9878 Mutation::Upsert {
9879 table: "patient_notes".to_owned(),
9880 values: note("unrelated", "key-held", "Unrelated changed"),
9881 base_version: None,
9882 },
9883 ])
9884 .expect("doomed commit");
9885 let kept = client
9886 .mutate(vec![Mutation::Upsert {
9887 table: "patient_notes".to_owned(),
9888 values: note("kept", "key-held", "Kept optimistic"),
9889 base_version: None,
9890 }])
9891 .expect("kept commit");
9892 client.drain_change_batches();
9893
9894 let input = LocalDataPurgeInput {
9895 purge_id: "purge-001".to_owned(),
9896 targets: vec![LocalDataPurgeTarget {
9897 table: "patient_notes".to_owned(),
9898 selectors: BTreeMap::from([(
9899 "encryption_key_id".to_owned(),
9900 vec!["key-revoked".to_owned()],
9901 )]),
9902 }],
9903 };
9904 assert_eq!(
9905 client.purge_local_data(&input).expect("apply purge"),
9906 LocalDataPurgeResult {
9907 already_applied: false,
9908 purged_rows: 1,
9909 dropped_commits: 1,
9910 }
9911 );
9912 let rows = client
9913 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
9914 .expect("visible rows");
9915 assert_eq!(rows.len(), 2);
9916 assert_eq!(rows[0]["id"], "kept");
9917 assert_eq!(rows[1]["id"], "unrelated");
9918 assert_eq!(rows[1]["title"], "Unrelated original");
9919 let fts = client
9920 .query(
9921 "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'",
9922 &[],
9923 )
9924 .expect("fts query");
9925 assert!(fts.is_empty());
9926 assert_eq!(client.outbox.len(), 1);
9927 assert_eq!(client.outbox[0].client_commit_id, kept);
9928 let outcome = client
9929 .commit_outcome(&doomed)
9930 .expect("read doomed outcome")
9931 .expect("doomed outcome");
9932 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
9933 match &outcome.results[0] {
9934 CommitOperationOutcome::Error { rejection } => {
9935 assert_eq!(rejection.code, "client.local_data_purged");
9936 assert_eq!(rejection.client_commit_id, doomed);
9937 }
9938 other => panic!("expected local purge rejection, got {other:?}"),
9939 }
9940 assert_eq!(client.drain_change_batches().len(), 1);
9941 assert_eq!(
9942 client.purge_local_data(&input).expect("retry purge"),
9943 LocalDataPurgeResult {
9944 already_applied: true,
9945 purged_rows: 0,
9946 dropped_commits: 0,
9947 }
9948 );
9949 let conflicting = LocalDataPurgeInput {
9950 purge_id: input.purge_id.clone(),
9951 targets: vec![LocalDataPurgeTarget {
9952 table: "patient_notes".to_owned(),
9953 selectors: BTreeMap::from([(
9954 "encryption_key_id".to_owned(),
9955 vec!["key-held".to_owned()],
9956 )]),
9957 }],
9958 };
9959 assert!(client
9960 .purge_local_data(&conflicting)
9961 .expect_err("id collision must fail")
9962 .contains("already used with a different plan"));
9963 }
9964
9965 fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
9967 let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
9968 Ok(prepared) => prepared,
9969 Err(outcome) => return *outcome,
9970 };
9971 loop {
9972 let completed = crate::round::test_support::drive_round(next, transport);
9973 match client.apply_sync_round(completed) {
9974 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
9975 crate::AppliedSyncRound::Complete {
9976 outcome, controls, ..
9977 } => {
9978 if client.transport_enabled() {
9979 for text in controls {
9980 let _ = transport.realtime_send(&text);
9981 }
9982 } else {
9983 client.disconnect_realtime(transport);
9984 }
9985 return outcome;
9986 }
9987 }
9988 }
9989 }
9990
9991 #[derive(Clone)]
9992 enum RoundReplyScript {
9993 Response(Message),
9994 PushAck,
9996 Error(TransportError),
9997 }
9998
9999 struct SplitParityTransport {
10002 script: RoundReplyScript,
10003 segments: BTreeMap<String, Vec<u8>>,
10004 requests: Vec<Message>,
10005 batches: Vec<Vec<String>>,
10006 uploads: Vec<String>,
10007 sync_calls: usize,
10008 realtime_sync_calls: usize,
10009 }
10010
10011 impl SplitParityTransport {
10012 fn new(script: RoundReplyScript) -> Self {
10013 Self {
10014 script,
10015 segments: BTreeMap::new(),
10016 requests: Vec::new(),
10017 batches: Vec::new(),
10018 uploads: Vec::new(),
10019 sync_calls: 0,
10020 realtime_sync_calls: 0,
10021 }
10022 }
10023
10024 fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10025 Self {
10026 segments,
10027 ..Self::new(script)
10028 }
10029 }
10030
10031 fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10032 let message =
10033 decode_message(request).expect("the client encodes a legal round request");
10034 self.requests.push(message.clone());
10035 match &self.script {
10036 RoundReplyScript::Response(response) => Ok(encode_message(response)),
10037 RoundReplyScript::Error(error) => Err(error.clone()),
10038 RoundReplyScript::PushAck => {
10039 let mut frames = vec![Frame::RespHeader {
10040 required_schema_version: None,
10041 latest_schema_version: None,
10042 log_epoch: Some("epoch-1".into()),
10043 reset_required: Some(false),
10044 }];
10045 let mut commit_seq = 0;
10046 for frame in &message.frames {
10047 if let Frame::PushCommit {
10048 client_commit_id,
10049 operations,
10050 } = frame
10051 {
10052 commit_seq += 1;
10053 frames.push(Frame::PushResult {
10054 client_commit_id: client_commit_id.clone(),
10055 status: PushStatus::Applied,
10056 commit_seq: Some(commit_seq),
10057 results: (0..operations.len())
10058 .map(|op_index| OpResult::Applied {
10059 op_index: op_index as i32,
10060 })
10061 .collect(),
10062 });
10063 }
10064 }
10065 frames.push(Frame::SubStart {
10066 id: "tasks".into(),
10067 status: SubStatus::Active,
10068 reason_code: String::new(),
10069 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10070 bootstrap: false,
10071 });
10072 frames.push(Frame::SubEnd {
10073 next_cursor: 0,
10074 bootstrap_state: None,
10075 });
10076 Ok(encode_message(&Message {
10077 wire_version: WIRE_VERSION,
10078 msg_kind: MsgKind::Response,
10079 frames,
10080 }))
10081 }
10082 }
10083 }
10084 }
10085
10086 impl Transport for SplitParityTransport {
10087 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10088 self.sync_calls += 1;
10089 self.respond(request)
10090 }
10091
10092 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10093 self.realtime_sync_calls += 1;
10094 self.respond(request)
10095 }
10096 fn download_segment(
10097 &mut self,
10098 _request: &SegmentRequest,
10099 _on_progress: &mut dyn FnMut(u64),
10100 ) -> Result<Vec<u8>, TransportError> {
10101 Err(TransportError::new(
10102 "sync.transport_failed",
10103 "per-descriptor fetch",
10104 ))
10105 }
10106
10107 fn download_segments(
10108 &mut self,
10109 fetches: &[crate::SegmentFetch],
10110 on_progress: &mut (dyn FnMut(usize, u64) + Send),
10111 ) -> Vec<Result<Vec<u8>, TransportError>> {
10112 self.batches.push(
10113 fetches
10114 .iter()
10115 .map(|fetch| match fetch {
10116 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10117 crate::SegmentFetch::Url(url) => url.clone(),
10118 })
10119 .collect(),
10120 );
10121 fetches
10122 .iter()
10123 .enumerate()
10124 .map(|(index, fetch)| {
10125 let crate::SegmentFetch::Direct(request) = fetch else {
10126 panic!("the descriptors are direct-endpoint requests")
10127 };
10128 let bytes =
10129 self.segments
10130 .get(&request.segment_id)
10131 .cloned()
10132 .ok_or_else(|| {
10133 TransportError::new("transport.not_found", "missing segment body")
10134 })?;
10135 on_progress(index, bytes.len() as u64);
10136 Ok(bytes)
10137 })
10138 .collect()
10139 }
10140
10141 fn blob_upload_grant(
10142 &mut self,
10143 _blob_id: &str,
10144 _byte_length: u64,
10145 _media_type: Option<&str>,
10146 ) -> Result<BlobUploadGrant, TransportError> {
10147 Ok(BlobUploadGrant::None)
10148 }
10149
10150 fn blob_upload(
10151 &mut self,
10152 blob_id: &str,
10153 _bytes: &[u8],
10154 _media_type: Option<&str>,
10155 ) -> Result<(), TransportError> {
10156 self.uploads.push(blob_id.to_owned());
10157 Ok(())
10158 }
10159
10160 fn realtime_connect(&mut self) -> Result<(), TransportError> {
10161 Ok(())
10162 }
10163
10164 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10165 Ok(())
10166 }
10167
10168 fn realtime_close(&mut self) -> Result<(), TransportError> {
10169 Ok(())
10170 }
10171 }
10172
10173 fn parity_clients() -> (SyncClient, SyncClient) {
10174 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10175 {"name":"id","type":"string","nullable":false},
10176 {"name":"project_id","type":"string","nullable":false}],
10177 "scopes":[{"pattern":"project:{project_id}"}]}]});
10178 let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10179 let mut client =
10180 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10181 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10182 client
10183 .subscribe(
10184 "tasks".into(),
10185 "tasks".into(),
10186 vec![("project_id".into(), vec!["p1".into()])],
10187 None,
10188 )
10189 .unwrap();
10190 client.subs[0].cursor = 0;
10191 client.subs[0].synced_once = true;
10192 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10193 client.persist_sub(&client.subs[0]).unwrap();
10194 client.drain_change_batches();
10195 client
10196 });
10197 (exchange, split)
10198 }
10199
10200 fn pull_response(client: &SyncClient) -> Message {
10201 let row = encode_row_json(
10202 client.schema.table("tasks").unwrap(),
10203 "kept",
10204 &Map::from_iter([
10205 ("id".into(), json!("kept")),
10206 ("project_id".into(), json!("p1")),
10207 ]),
10208 &client.encryption,
10209 )
10210 .unwrap();
10211 Message {
10212 wire_version: WIRE_VERSION,
10213 msg_kind: MsgKind::Response,
10214 frames: vec![
10215 Frame::RespHeader {
10216 required_schema_version: None,
10217 latest_schema_version: None,
10218 log_epoch: Some("epoch-1".into()),
10219 reset_required: Some(false),
10220 },
10221 Frame::SubStart {
10222 id: "tasks".into(),
10223 status: SubStatus::Active,
10224 reason_code: String::new(),
10225 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10226 bootstrap: false,
10227 },
10228 Frame::Commit {
10229 commit_seq: 1,
10230 created_at_ms: 1,
10231 actor_id: "writer".into(),
10232 tables: vec!["tasks".into()],
10233 changes: vec![ssp2::model::Change {
10234 table_index: 0,
10235 row_id: "kept".into(),
10236 op: Op::Upsert,
10237 row_version: Some(1),
10238 scopes: vec![("project_id".into(), "p1".into())],
10239 row: Some(row),
10240 }],
10241 },
10242 Frame::SubEnd {
10243 next_cursor: 1,
10244 bootstrap_state: None,
10245 },
10246 ],
10247 }
10248 }
10249
10250 #[test]
10251 fn split_round_matches_exchange_for_a_pull() {
10252 let (mut exchange, mut split) = parity_clients();
10253 let response = pull_response(&exchange);
10254 let mut exchange_transport =
10255 SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10256 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10257
10258 let exchange_outcome = exchange.sync(&mut exchange_transport);
10259 let split_outcome = sync_split(&mut split, &mut split_transport);
10260 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10261 assert_eq!(
10262 exchange
10263 .query("SELECT id, project_id FROM tasks", &[])
10264 .unwrap(),
10265 split
10266 .query("SELECT id, project_id FROM tasks", &[])
10267 .unwrap()
10268 );
10269 assert_eq!(exchange_transport.requests.len(), 1);
10270 assert_eq!(split_transport.requests.len(), 1);
10271 }
10272
10273 #[test]
10274 fn split_round_matches_exchange_for_a_push_with_an_upload() {
10275 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10276 {"name":"id","type":"string","nullable":false},
10277 {"name":"project_id","type":"string","nullable":false},
10278 {"name":"file","type":"blob_ref","nullable":true}],
10279 "scopes":[{"pattern":"project:{project_id}"}]}]});
10280 let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10281 let mut client =
10282 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10283 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10284 client
10285 .subscribe(
10286 "attachments".into(),
10287 "attachments".into(),
10288 vec![("project_id".into(), vec!["p1".into()])],
10289 None,
10290 )
10291 .unwrap();
10292 client
10293 });
10294 let bytes = b"split upload";
10295 let mut mutations = Vec::new();
10296 for client in [&mut exchange, &mut split] {
10297 client
10298 .upload_blob(bytes, Some("text/plain".into()), None)
10299 .unwrap();
10300 mutations.push(
10301 client
10302 .mutate(vec![Mutation::Upsert {
10303 table: "attachments".into(),
10304 values: Map::from_iter([
10305 ("id".into(), Value::from("a1")),
10306 ("project_id".into(), Value::from("p1")),
10307 (
10308 "file".into(),
10309 Value::from(format!(
10310 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10311 blob_id_for(bytes),
10312 bytes.len()
10313 )),
10314 ),
10315 ]),
10316 base_version: None,
10317 }])
10318 .unwrap(),
10319 );
10320 }
10321 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10322 let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10323 let exchange_outcome = exchange.sync(&mut exchange_transport);
10324 let split_outcome = sync_split(&mut split, &mut split_transport);
10325 let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10326 panic!("the exchange round must succeed: {exchange_outcome:?}")
10327 };
10328 let SyncOutcome::Ok(split_report) = split_outcome else {
10329 panic!("the split round must succeed: {split_outcome:?}")
10330 };
10331 assert_eq!(exchange_report.pushed, 1);
10332 assert_eq!(split_report.pushed, 1);
10333 assert_eq!(
10334 exchange_report.commits_applied,
10335 split_report.commits_applied
10336 );
10337 assert_eq!(
10338 exchange_transport.uploads,
10339 [blob_id_for(bytes)],
10340 "one upload before the metadata request"
10341 );
10342 assert_eq!(split_transport.uploads, exchange_transport.uploads);
10343 assert!(exchange.pending_commit_ids().is_empty());
10344 assert!(split.pending_commit_ids().is_empty());
10345 assert_eq!(
10346 exchange
10347 .query("SELECT id, project_id FROM attachments", &[])
10348 .unwrap(),
10349 split
10350 .query("SELECT id, project_id FROM attachments", &[])
10351 .unwrap()
10352 );
10353 }
10354
10355 #[test]
10356 fn split_round_matches_exchange_for_a_segment_download() {
10357 let (mut exchange, mut split) = parity_clients();
10358 let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10359 let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10360 table: "tasks".into(),
10361 schema_version: 1,
10362 columns,
10363 blocks: vec![vec![ssp2::segment::SegmentRow {
10364 server_version: 1,
10365 values: vec![
10366 Some(ColumnValue::String("page".into())),
10367 Some(ColumnValue::String("p1".into())),
10368 ],
10369 }]],
10370 });
10371 let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10372 let response = Message {
10373 wire_version: WIRE_VERSION,
10374 msg_kind: MsgKind::Response,
10375 frames: vec![
10376 Frame::RespHeader {
10377 required_schema_version: None,
10378 latest_schema_version: None,
10379 log_epoch: Some("epoch-1".into()),
10380 reset_required: Some(false),
10381 },
10382 Frame::SubStart {
10383 id: "tasks".into(),
10384 status: SubStatus::Active,
10385 reason_code: String::new(),
10386 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10387 bootstrap: true,
10388 },
10389 Frame::SegmentRef {
10390 segment_id: segment_id.clone(),
10391 media_type: MediaType::Rows,
10392 table: "tasks".into(),
10393 byte_length: page.len() as i64,
10394 row_count: 1,
10395 as_of_commit_seq: 1,
10396 scope_digest: "digest".into(),
10397 row_cursor: None,
10398 next_row_cursor: None,
10399 url: None,
10400 url_expires_at_ms: None,
10401 },
10402 Frame::SubEnd {
10403 next_cursor: 1,
10404 bootstrap_state: None,
10405 },
10406 ],
10407 };
10408 let segments = BTreeMap::from([(segment_id.clone(), page)]);
10409 let mut exchange_transport = SplitParityTransport::with_segments(
10410 RoundReplyScript::Response(response.clone()),
10411 segments.clone(),
10412 );
10413 let mut split_transport =
10414 SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10415 let exchange_outcome = exchange.sync(&mut exchange_transport);
10416 let split_outcome = sync_split(&mut split, &mut split_transport);
10417 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10418 assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10419 assert_eq!(split_transport.batches, exchange_transport.batches);
10420 assert_eq!(
10421 exchange
10422 .query("SELECT id, project_id FROM tasks", &[])
10423 .unwrap(),
10424 split
10425 .query("SELECT id, project_id FROM tasks", &[])
10426 .unwrap()
10427 );
10428 }
10429
10430 #[test]
10431 fn split_round_matches_exchange_for_a_transport_failure() {
10432 let (mut exchange, mut split) = parity_clients();
10433 let error = TransportError::new("transport.timeout", "injected");
10434 let mut exchange_transport =
10435 SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10436 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10437 let exchange_outcome = exchange.sync(&mut exchange_transport);
10438 let split_outcome = sync_split(&mut split, &mut split_transport);
10439 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10440 assert!(matches!(
10441 split_outcome,
10442 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10443 ));
10444 assert_eq!(
10445 exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10446 split.query("SELECT id FROM tasks", &[]).unwrap()
10447 );
10448 }
10449
10450 #[test]
10451 fn split_round_matches_exchange_for_a_failed_realtime_round() {
10452 let (mut exchange, mut split) = parity_clients();
10453 let error = TransportError::new("transport.timeout", "socket round failed");
10454 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10455 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10456 TransportError::new("transport.timeout", "socket round failed"),
10457 ));
10458 exchange.connect_realtime(&mut exchange_transport).unwrap();
10459 split.connect_realtime(&mut split_transport).unwrap();
10460 let exchange_outcome = exchange.sync(&mut exchange_transport);
10461 let split_outcome = sync_split(&mut split, &mut split_transport);
10462 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10463 assert_eq!(exchange_transport.realtime_sync_calls, 1);
10464 assert_eq!(exchange_transport.sync_calls, 0);
10465 assert_eq!(split_transport.realtime_sync_calls, 1);
10466 assert_eq!(split_transport.sync_calls, 0);
10467 assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10468 assert_eq!(split.realtime_state(), RealtimeState::Lost);
10469 }
10470}
10471
10472impl SubState {
10473 fn name(self) -> &'static str {
10474 match self {
10475 SubState::Active => "active",
10476 SubState::Revoked => "revoked",
10477 SubState::Failed => "failed",
10478 }
10479 }
10480
10481 fn parse(value: &str) -> Self {
10482 match value {
10483 "revoked" => Self::Revoked,
10484 "failed" => Self::Failed,
10485 _ => Self::Active,
10486 }
10487 }
10488}
10489
10490#[derive(Debug, Clone)]
10491struct Subscription {
10492 id: String,
10493 table: String,
10494 requested: Vec<(String, Vec<String>)>,
10495 params: Option<String>,
10496 cursor: i64,
10497 bootstrap_state: Option<String>,
10499 state: SubState,
10500 reason_code: Option<String>,
10501 effective: Option<Vec<(String, Vec<String>)>>,
10504 synced_once: bool,
10505}
10506
10507#[cfg(test)]
10508mod subscription_rearm_tests {
10509 use super::*;
10510 use serde_json::json;
10511
10512 fn client() -> SyncClient {
10513 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10514 {"name":"id","type":"string","nullable":false},
10515 {"name":"project_id","type":"string","nullable":false}],
10516 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10517 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10518 }
10519
10520 fn scopes() -> Vec<(String, Vec<String>)> {
10521 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10522 }
10523
10524 fn store_revoked(client: &mut SyncClient) {
10526 client
10527 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10528 .expect("subscribe");
10529 let sub = &mut client.subs[0];
10530 sub.cursor = 41;
10531 sub.bootstrap_state = Some("resume".to_owned());
10532 sub.effective = Some(scopes());
10533 sub.state = SubState::Revoked;
10534 sub.reason_code = Some("sync.scope_revoked".to_owned());
10535 client.persist_sub(&client.subs[0]).expect("persist");
10536 client.sync_needed = false;
10537 client.sync_intent_queue.clear();
10538 }
10539
10540 #[test]
10541 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10542 let mut client = client();
10543 store_revoked(&mut client);
10544
10545 client
10546 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10547 .expect("re-arm");
10548
10549 let view = client.subscription_state("sub").expect("state");
10550 assert_eq!(view.status, "active");
10551 assert_eq!(view.cursor, -1);
10552 assert!(!view.has_resume_token);
10553 assert_eq!(view.effective_scopes, None);
10554 assert_eq!(view.reason_code, None);
10555 assert!(client.sync_needed, "a re-arm asks for a pull");
10556 assert!(matches!(
10557 client.drain_sync_intents().as_slice(),
10558 [SyncIntent::Interactive]
10559 ));
10560 let stored: String = client
10561 .conn
10562 .query_row(
10563 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10564 [],
10565 |row| row.get(0),
10566 )
10567 .expect("persisted row");
10568 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10569 assert_eq!(stored["status"], json!("active"));
10570 assert_eq!(stored["cursor"], json!(-1));
10571 assert_eq!(stored["bootstrapState"], Value::Null);
10572 assert_eq!(stored["effectiveScopes"], Value::Null);
10573 assert_eq!(stored["reasonCode"], Value::Null);
10574 }
10575
10576 #[test]
10577 fn a_failed_registration_is_not_rearmed() {
10578 let mut client = client();
10579 client
10580 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10581 .expect("subscribe");
10582 client.subs[0].state = SubState::Failed;
10583 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10584 client.persist_sub(&client.subs[0]).expect("persist");
10585 client.sync_needed = false;
10586 client.sync_intent_queue.clear();
10587
10588 client
10589 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10590 .expect("subscribe");
10591
10592 let view = client.subscription_state("sub").expect("state");
10593 assert_eq!(view.status, "failed");
10594 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10595 assert!(!client.sync_needed);
10596 assert!(client.drain_sync_intents().is_empty());
10597 }
10598}
10599
10600#[derive(Debug, Clone)]
10601struct OutboxOp {
10602 upsert: bool,
10603 table: String,
10604 row_id: String,
10605 base_version: Option<i64>,
10606 values: Option<Map<String, Value>>,
10610}
10611
10612impl From<&OutboxOp> for CommitOperation {
10613 fn from(operation: &OutboxOp) -> Self {
10614 Self {
10615 table: operation.table.clone(),
10616 row_id: operation.row_id.clone(),
10617 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10618 base_version: operation.base_version,
10619 values: operation.values.clone(),
10620 }
10621 }
10622}
10623
10624#[derive(Debug, Clone)]
10625struct OutboxCommit {
10626 client_commit_id: String,
10627 ops: Vec<OutboxOp>,
10628}
10629
10630fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>)> {
10634 commit
10635 .ops
10636 .iter()
10637 .filter(|operation| operation.upsert)
10638 .map(|operation| {
10639 (
10640 operation.table.as_str(),
10641 operation
10642 .values
10643 .as_ref()
10644 .map(|values| values.keys().map(String::as_str).collect())
10645 .unwrap_or_default(),
10646 )
10647 })
10648 .collect()
10649}
10650
10651type FailedCommit = (OutboxCommit, Map<String, Value>);
10652
10653#[derive(Debug, Clone)]
10654struct CompiledLocalDataPurgeTarget {
10655 table: String,
10656 selectors: Vec<(String, Vec<String>)>,
10657}
10658
10659struct StoredCommitOutcomeRow {
10660 sequence: i64,
10661 client_commit_id: String,
10662 status: String,
10663 recorded_at_ms: i64,
10664 results_json: String,
10665 operations_json: Option<String>,
10666 resolution: String,
10667 resolved_at_ms: Option<i64>,
10668 replacement_client_commit_id: Option<String>,
10669}
10670
10671impl StoredCommitOutcomeRow {
10672 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10673 Ok(Self {
10674 sequence: row.get(0)?,
10675 client_commit_id: row.get(1)?,
10676 status: row.get(2)?,
10677 recorded_at_ms: row.get(3)?,
10678 results_json: row.get(4)?,
10679 operations_json: row.get(5)?,
10680 resolution: row.get(6)?,
10681 resolved_at_ms: row.get(7)?,
10682 replacement_client_commit_id: row.get(8)?,
10683 })
10684 }
10685}
10686
10687enum SectionError {
10690 FailClosed,
10691 Abort(String, String),
10692}
10693
10694#[derive(Debug)]
10695pub(crate) struct RequestMeta {
10696 pushed_ids: Vec<String>,
10697 fresh: Vec<(String, bool)>,
10700 pub(crate) accept: u8,
10701 deferred_commits: usize,
10705}
10706
10707#[derive(Default)]
10708struct ChangeAccumulator {
10709 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10711 windows: BTreeMap<(String, String), BTreeSet<String>>,
10712 status: bool,
10713 conflicts: bool,
10714 rejections: bool,
10715 outcomes: bool,
10716}
10717
10718impl ChangeAccumulator {
10719 fn table(&mut self, table: &str) {
10720 self.tables.insert(table.to_owned(), None);
10721 }
10722
10723 fn scope(&mut self, table: &str, key: String) {
10724 match self.tables.get_mut(table) {
10725 Some(None) => {}
10726 Some(Some(keys)) => {
10727 keys.insert(key);
10728 }
10729 None => {
10730 self.tables
10731 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10732 }
10733 }
10734 }
10735
10736 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10737 self.windows
10738 .entry((base_key.to_owned(), table.to_owned()))
10739 .or_default()
10740 .insert(unit.to_owned());
10741 }
10742
10743 fn touched(&self) -> bool {
10744 !self.tables.is_empty()
10745 || !self.windows.is_empty()
10746 || self.status
10747 || self.conflicts
10748 || self.rejections
10749 || self.outcomes
10750 }
10751}
10752
10753const PUSH_OPS_PER_REQUEST: usize = 500;
10759const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10760const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10761const MAX_LOCAL_PURGE_VALUES: usize = 128;
10762const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10763pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10764
10765struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10767
10768impl OverlayDirty {
10769 fn clean() -> Self {
10770 Self(RefCell::new(Some(BTreeSet::new())))
10771 }
10772 fn get(&self) -> bool {
10773 self.0
10774 .borrow()
10775 .as_ref()
10776 .is_none_or(|tables| !tables.is_empty())
10777 }
10778 fn set(&self, dirty: bool) {
10779 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10780 }
10781 fn table(&self, table: &str) {
10782 if let Some(tables) = self.0.borrow_mut().as_mut() {
10783 tables.insert(table.to_owned());
10784 }
10785 }
10786 fn snapshot(&self) -> Option<BTreeSet<String>> {
10787 self.0.borrow().clone()
10788 }
10789 fn restore(&self, tables: Option<BTreeSet<String>>) {
10790 *self.0.borrow_mut() = tables;
10791 }
10792}
10793
10794pub struct SyncClient {
10795 #[cfg(feature = "bench-internals")]
10796 benchmark_phases: Recorder,
10797 conn: Connection,
10798 schema: ClientSchema,
10799 client_id: String,
10800 limits: ClientLimits,
10801 subs: Vec<Subscription>,
10802 outbox: Vec<OutboxCommit>,
10803 failed_commits: Vec<FailedCommit>,
10804 retain_failed_commits: bool,
10805 conflicts: Vec<ConflictRecord>,
10806 rejections: Vec<RejectionRecord>,
10807 schema_floor: Option<SchemaFloor>,
10808 lease_state: Option<LeaseState>,
10810 stopped: bool,
10812 upgrading: bool,
10814 sync_needed: bool,
10816 realtime_policy: RealtimePolicy,
10819 realtime_state: RealtimeState,
10820 realtime_reason_code: Option<String>,
10821 realtime_retry_delay_ms: Option<u64>,
10822 presence: HashMap<String, HashMap<String, PresencePeer>>,
10824 now_ms: Option<i64>,
10827 encryption: crate::values::EncryptionConfig,
10831 security_preflight: bool,
10834 transport_enabled: bool,
10835 previous_version: Option<PreviousVersionContextConfig>,
10839 insert_sql: RefCell<HashMap<String, String>>,
10845 overlay_dirty: OverlayDirty,
10848 storage_failure: RefCell<Option<QueryReadFailure>>,
10850 #[cfg(test)]
10853 overlay_rebuild_count: Cell<usize>,
10854 #[cfg(test)]
10855 acknowledged_replay_count: Cell<usize>,
10856 #[cfg(test)]
10859 outcome_prune_count: Cell<usize>,
10860 progress: ProgressObserver,
10862 change_queue: VecDeque<ClientChangeBatch>,
10863 sync_intent_queue: VecDeque<SyncIntent>,
10864 retry_delay_ms: u64,
10866 round_retry_delay_ms: Option<u64>,
10868 round_failure: Option<String>,
10873 active_round: Option<uuid::Uuid>,
10874 last_round: Option<DiagnosticLastRound>,
10875 last_change: Option<DiagnosticLastChange>,
10876 query_failures: Vec<DiagnosticQueryFailure>,
10878 coalesced: Option<CoalescedApply>,
10880 coalesced_failure: Option<String>,
10883}
10884
10885struct CoalescedApply {
10889 rows: usize,
10890 subs: Vec<Subscription>,
10891 outbox: Vec<OutboxCommit>,
10892 failed_commits: Vec<FailedCommit>,
10893 conflicts: Vec<ConflictRecord>,
10894 rejections: Vec<RejectionRecord>,
10895 schema_floor: Option<SchemaFloor>,
10896 lease_state: Option<LeaseState>,
10897 stopped: bool,
10898 upgrading: bool,
10899 sync_needed: bool,
10900 overlay_dirty: Option<BTreeSet<String>>,
10901 change_queue: usize,
10902 sync_intent_queue: usize,
10903 last_change: Option<DiagnosticLastChange>,
10904}
10905
10906const COALESCED_APPLY_ROWS: usize = 1024;
10907
10908pub(crate) fn quote_ident(name: &str) -> String {
10909 format!("\"{}\"", name.replace('"', "\"\""))
10910}
10911pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
10915 conn.query_row(
10916 "SELECT value FROM _syncular_meta WHERE key = ?1",
10917 rusqlite::params![key],
10918 |row| row.get::<_, String>(0),
10919 )
10920 .ok()
10921}
10922
10923pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
10924 let _ = conn.execute(
10925 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
10926 rusqlite::params![key, value],
10927 );
10928}
10929
10930pub(crate) fn meta_delete(conn: &Connection, key: &str) {
10931 let _ = conn.execute(
10932 "DELETE FROM _syncular_meta WHERE key = ?1",
10933 rusqlite::params![key],
10934 );
10935}
10936
10937fn row_id_predicate(table: &TableSchema) -> String {
10939 let key = quote_ident(&table.primary_key);
10940 let text_match = format!("CAST({key} AS TEXT) = ?1");
10941 match table.columns[table.pk_index].ty {
10942 ColumnType::String | ColumnType::Json => {
10943 format!("{key} = ?1 AND {text_match}")
10944 }
10945 ColumnType::Integer | ColumnType::Boolean => {
10949 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
10950 }
10951 _ => text_match,
10954 }
10955}
10956
10957fn is_local_operation_code_like(value: &str) -> bool {
10958 let bytes = value.as_bytes();
10959 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
10960 && bytes
10961 .iter()
10962 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
10963}
10964
10965fn base_table(name: &str) -> String {
10966 quote_ident(&format!("_syncular_base_{name}"))
10967}
10968
10969type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
10974
10975fn window_base_key(base: &WindowBase) -> String {
10976 format!(
10977 "{}\0{}\0{}",
10978 base.table,
10979 base.variable,
10980 canonical_scope_json(&base.fixed_scopes)
10981 )
10982}
10983
10984fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
10986 let mut scopes = base.fixed_scopes.clone();
10987 scopes.retain(|(k, _)| k != &base.variable);
10988 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
10989 scopes
10990}
10991
10992fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
10996 let canonical = canonical_scope_json(&unit_scopes(base, unit));
10997 let digest = Sha256::digest(canonical.as_bytes());
10998 let hex = bytes_to_hex(&digest);
10999 format!("w:{}:{}", base.table, &hex[..16])
11000}
11001
11002fn visible_table(name: &str) -> String {
11003 quote_ident(name)
11004}
11005
11006const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11007
11008fn is_synced_table_name(name: &str) -> bool {
11011 if name.starts_with("sqlite_") {
11012 return false;
11013 }
11014 if name.starts_with("_syncular_base_") {
11015 return true; }
11017 !name.starts_with("_syncular_")
11019}
11020
11021fn blob_id_for(bytes: &[u8]) -> String {
11023 let digest = Sha256::digest(bytes);
11024 format!("sha256:{}", bytes_to_hex(&digest))
11025}
11026
11027enum RowParam<'a> {
11031 Cell(&'a Option<ColumnValue>),
11032 Version(i64),
11033}
11034
11035impl rusqlite::ToSql for RowParam<'_> {
11036 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11037 Ok(match self {
11038 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11039 RowParam::Cell(cell) => match cell {
11040 None => ToSqlOutput::Owned(SqlValue::Null),
11041 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11042 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11043 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11044 Some(ColumnValue::Boolean(b)) => {
11045 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11046 }
11047 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11048 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11049 }
11050 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11052 ToSqlOutput::Borrowed(ValueRef::Blob(b))
11053 }
11054 },
11055 })
11056 }
11057}
11058
11059fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11061 match param.to_sql().map_err(|error| error.to_string())? {
11062 ToSqlOutput::Owned(value) => Ok(value),
11063 ToSqlOutput::Borrowed(value) => {
11064 SqlValue::try_from(value).map_err(|error| error.to_string())
11065 }
11066 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11067 }
11068}
11069
11070fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11078 use ssp2::segment::ColumnType;
11079 let mismatch = || {
11080 Err(format!(
11081 "image column {:?} holds a value of the wrong type",
11082 column.name
11083 ))
11084 };
11085 match value {
11086 ValueRef::Null => {
11087 if !column.nullable {
11088 return Err(format!(
11089 "image column {:?} is NULL but not nullable",
11090 column.name
11091 ));
11092 }
11093 Ok(ToSqlOutput::Owned(SqlValue::Null))
11094 }
11095 ValueRef::Integer(i) => match column.ty {
11096 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11097 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11098 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11099 _ => mismatch(),
11100 },
11101 ValueRef::Real(_) => match column.ty {
11102 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11103 _ => mismatch(),
11104 },
11105 ValueRef::Text(t) => {
11106 std::str::from_utf8(t)
11107 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11108 match column.ty {
11109 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11110 Ok(ToSqlOutput::Borrowed(value))
11111 }
11112 _ => mismatch(),
11113 }
11114 }
11115 ValueRef::Blob(_) => match column.ty {
11116 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11118 _ => mismatch(),
11119 },
11120 }
11121}
11122
11123pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11124 use rusqlite::types::ValueRef;
11125 match value {
11126 ValueRef::Null => Value::Null,
11127 ValueRef::Integer(i) => match column.ty {
11128 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11129 ssp2::segment::ColumnType::Float => {
11130 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11131 }
11132 _ => Value::from(i),
11133 },
11134 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11135 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11136 ValueRef::Blob(b) => {
11137 let mut map = Map::new();
11138 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11139 Value::Object(map)
11140 }
11141 }
11142}
11143
11144fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11148 Ok(match value {
11149 Value::Null => SqlValue::Null,
11150 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11151 Value::Number(n) => {
11152 if let Some(i) = n.as_i64() {
11153 SqlValue::Integer(i)
11154 } else if let Some(f) = n.as_f64() {
11155 SqlValue::Real(f)
11156 } else {
11157 return Err(format!("query param number {n} is out of range"));
11158 }
11159 }
11160 Value::String(s) => SqlValue::Text(s.clone()),
11161 Value::Object(_) => {
11162 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11163 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11164 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11165 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11166 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11167 })?)
11168 } else {
11169 return Err(
11170 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11171 .to_owned(),
11172 );
11173 }
11174 }
11175 Value::Array(_) => return Err("query array params are not supported".to_owned()),
11176 })
11177}
11178
11179fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11184 use rusqlite::types::ValueRef;
11185 match value {
11186 ValueRef::Null => Value::Null,
11187 ValueRef::Integer(i) => {
11188 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11192 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11193 Value::from(i)
11194 } else {
11195 let mut map = Map::new();
11196 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11197 Value::Object(map)
11198 }
11199 }
11200 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11201 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11202 ValueRef::Blob(b) => {
11203 let mut map = Map::new();
11204 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11205 Value::Object(map)
11206 }
11207 }
11208}
11209
11210fn query_connection(
11212 conn: &Connection,
11213 sql: &str,
11214 params: &[QueryValue],
11215) -> Result<Vec<QueryRow>, QueryReadFailure> {
11216 crate::query_guard::assert_read_only_query(sql)?;
11217 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11218 let bound: Vec<SqlValue> = params
11219 .iter()
11220 .map(json_param_to_sql)
11221 .collect::<Result<_, _>>()?;
11222 let mut stmt = conn.prepare(&lowered_sql)?;
11223 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11224 let bound_refs: Vec<&dyn rusqlite::ToSql> =
11225 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11226 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11227 let mut out = Vec::new();
11228 while let Some(row) = sql_rows.next()? {
11229 let mut record = Map::new();
11230 for (i, name) in column_names.iter().enumerate() {
11231 let value = row.get_ref(i)?;
11232 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11233 }
11234 out.push(record);
11235 }
11236 Ok(out)
11237}
11238
11239fn persisted_window_state(
11240 conn: &Connection,
11241 base: &WindowBase,
11242) -> Result<WindowState, QueryReadFailure> {
11243 let mut stmt = conn.prepare(
11244 "SELECT windows.unit, subscriptions.state_json
11245 FROM _syncular_windows AS windows
11246 JOIN _syncular_subscriptions AS subscriptions
11247 ON subscriptions.id = windows.sub_id
11248 WHERE windows.base = ?1
11249 ORDER BY windows.unit ASC",
11250 )?;
11251 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11252 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11253 })?;
11254 let mut units = Vec::new();
11255 let mut pending = Vec::new();
11256 for row in rows {
11257 let (unit, raw) = row?;
11258 let state: Value = serde_json::from_str(&raw)
11259 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11260 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11261 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11262 || state
11263 .get("bootstrapState")
11264 .is_some_and(|value| !value.is_null());
11265 if is_pending {
11266 pending.push(unit.clone());
11267 }
11268 units.push(unit);
11269 }
11270 Ok(WindowState { units, pending })
11271}
11272
11273#[derive(Debug, Clone, Copy)]
11275pub struct SnapshotStatement<'a> {
11276 pub sql: &'a str,
11277 pub params: &'a [Value],
11278}
11279
11280#[derive(Debug, Clone, Default)]
11284pub struct SnapshotReadRequest<'a> {
11285 pub statements: Vec<SnapshotStatement<'a>>,
11286 pub coverage: &'a [WindowCoverage],
11287 pub subscriptions: Vec<String>,
11288 pub commit_ids: Vec<String>,
11289}
11290
11291#[cfg(test)]
11292thread_local! {
11293 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11297 std::cell::RefCell::new(None);
11298}
11299
11300#[cfg(test)]
11301fn run_snapshot_read_hook() {
11302 SNAPSHOT_READ_HOOK.with(|slot| {
11303 if let Some(hook) = slot.borrow_mut().take() {
11304 hook();
11305 }
11306 });
11307}
11308
11309fn snapshot_read_connection(
11314 conn: &Connection,
11315 request: &SnapshotReadRequest<'_>,
11316) -> Result<SnapshotRead, QueryReadFailure> {
11317 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11318 let corrupt = |message: &str| QueryReadFailure {
11319 code: Some("sync.local_corrupt"),
11320 sqlite_code: None,
11321 sqlite_message: None,
11322 rollback_failure: None,
11323 message: message.to_owned(),
11324 };
11325 let stored_failure = |error: rusqlite::Error, message: &str| match error {
11326 rusqlite::Error::Utf8Error(..)
11327 | rusqlite::Error::InvalidColumnType(..)
11328 | rusqlite::Error::FromSqlConversionFailure(..)
11329 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11330 _ => QueryReadFailure::from(error),
11331 };
11332 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11333 let revision: Option<String> = conn
11336 .query_row(
11337 "SELECT value FROM _syncular_meta WHERE key = ?1",
11338 rusqlite::params![LOCAL_REVISION_KEY],
11339 |row| row.get(0),
11340 )
11341 .optional()
11342 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11343 let revision = match revision {
11344 None => "0".to_owned(),
11345 Some(value)
11346 if value == "0"
11347 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11348 && value.bytes().all(|b| b.is_ascii_digit())
11349 && value.parse::<u64>().is_ok()) =>
11350 {
11351 value
11352 }
11353 Some(_) => return Err(corrupt("persisted local revision is invalid")),
11354 };
11355 let mut queries = Vec::with_capacity(request.statements.len());
11356 for statement in &request.statements {
11357 queries.push(query_connection(conn, statement.sql, statement.params)?);
11358 #[cfg(test)]
11359 run_snapshot_read_hook();
11360 }
11361 let mut pending = Vec::new();
11362 let mut missing = Vec::new();
11363 for requested in request.coverage {
11364 let base_key = window_base_key(&requested.base);
11365 let state = persisted_window_state(conn, &requested.base)?;
11366 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11367 let reference = WindowUnitRef {
11368 base_key: base_key.clone(),
11369 unit: unit.clone(),
11370 };
11371 if !state.units.iter().any(|held| held == &unit) {
11372 missing.push(reference);
11373 } else if state.pending.iter().any(|held| held == &unit) {
11374 pending.push(reference);
11375 }
11376 }
11377 }
11378 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11379 for id in &request.subscriptions {
11380 let row: Option<(String, String)> = conn
11381 .query_row(
11382 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11383 rusqlite::params![id],
11384 |row| Ok((row.get(0)?, row.get(1)?)),
11385 )
11386 .optional()
11387 .map_err(|error| {
11388 stored_failure(error, "persisted subscription state is invalid")
11389 })?;
11390 let Some((table, raw)) = row else {
11391 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11392 continue;
11393 };
11394 let state: Value = serde_json::from_str(&raw)
11397 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11398 let status = match state.get("status").and_then(Value::as_str) {
11399 Some(value @ ("active" | "revoked" | "failed")) => value,
11400 _ => return Err(corrupt("persisted subscription state is invalid")),
11401 };
11402 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11403 return Err(corrupt("persisted subscription state is invalid"));
11404 };
11405 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11406 return Err(corrupt("persisted subscription state is invalid"));
11407 }
11408 let effective_scopes = state
11409 .get("effectiveScopes")
11410 .filter(|value| !value.is_null());
11411 if let Some(scopes) = effective_scopes {
11412 json_to_scope_map(scopes)
11413 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11414 }
11415 let has_resume_token = match state.get("bootstrapState") {
11416 None | Some(Value::Null) => false,
11417 Some(Value::String(_)) => true,
11418 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11419 };
11420 let reason_code = match state.get("reasonCode") {
11423 None | Some(Value::Null) => None,
11424 Some(Value::String(value)) => Some(value.clone()),
11425 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11426 };
11427 let active = status == "active";
11428 subscriptions.push(SubscriptionCatchup::Known {
11429 id: id.clone(),
11430 table,
11431 status: status.to_owned(),
11432 cursor,
11433 has_resume_token,
11434 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11435 known_pending_pages: active && (has_resume_token || cursor < 0),
11436 effective_scopes: effective_scopes.cloned(),
11437 reason_code,
11438 });
11439 }
11440 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11441 for id in &request.commit_ids {
11442 let pending: bool = conn.query_row(
11443 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11444 rusqlite::params![id],
11445 |row| row.get(0),
11446 )?;
11447 if pending {
11448 deliveries.push(CommitDelivery::Pending {
11449 client_commit_id: id.clone(),
11450 });
11451 continue;
11452 }
11453 let row = conn
11454 .query_row(
11455 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11456 resolution, resolved_at_ms, replacement_client_commit_id
11457 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11458 rusqlite::params![id],
11459 StoredCommitOutcomeRow::from_row,
11460 )
11461 .optional()
11462 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11463 deliveries.push(match row {
11464 Some(row) => CommitDelivery::Known {
11465 client_commit_id: id.clone(),
11466 outcome: SyncClient::outcome_from_row(row)
11467 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11468 },
11469 None => CommitDelivery::Unknown {
11470 client_commit_id: id.clone(),
11471 },
11472 });
11473 }
11474 Ok(SnapshotRead {
11475 revision,
11476 queries,
11477 coverage: CoverageSnapshot {
11478 complete: pending.is_empty() && missing.is_empty(),
11479 pending,
11480 missing,
11481 },
11482 subscriptions,
11483 deliveries,
11484 })
11485 })();
11486 match result {
11487 Ok(read) => {
11488 conn.execute_batch("RELEASE syncular_snapshot_read")?;
11489 Ok(read)
11490 }
11491 Err(mut error) => {
11492 if let Err(rollback) = conn
11493 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11494 {
11495 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11496 }
11497 Err(error)
11498 }
11499 }
11500}
11501
11502fn snapshot_connection(
11503 conn: &Connection,
11504 sql: &str,
11505 params: &[Value],
11506 coverage: &[WindowCoverage],
11507) -> Result<QuerySnapshot, QueryReadFailure> {
11508 let read = snapshot_read_connection(
11509 conn,
11510 &SnapshotReadRequest {
11511 statements: vec![SnapshotStatement { sql, params }],
11512 coverage,
11513 subscriptions: Vec::new(),
11514 commit_ids: Vec::new(),
11515 },
11516 )?;
11517 Ok(QuerySnapshot {
11518 revision: read.revision,
11519 rows: read
11520 .queries
11521 .into_iter()
11522 .next()
11523 .expect("single-query snapshot returns one result"),
11524 coverage: read.coverage,
11525 })
11526}
11527
11528pub struct FileQuerySnapshotReader {
11533 path: String,
11534 conn: Option<Connection>,
11535}
11536
11537impl FileQuerySnapshotReader {
11538 #[must_use]
11539 pub fn new(path: impl Into<String>) -> Self {
11540 Self {
11541 path: path.into(),
11542 conn: None,
11543 }
11544 }
11545
11546 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11547 if self.conn.is_none() {
11548 let conn = Connection::open_with_flags(
11549 &self.path,
11550 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11551 )
11552 .map_err(QueryReadFailure::from)?;
11553 conn.busy_timeout(std::time::Duration::from_millis(250))
11554 .map_err(QueryReadFailure::from)?;
11555 self.conn = Some(conn);
11556 }
11557 self.conn
11558 .as_ref()
11559 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11560 }
11561
11562 pub fn snapshot_read(
11565 &mut self,
11566 request: &SnapshotReadRequest<'_>,
11567 ) -> Result<SnapshotRead, QueryReadFailure> {
11568 snapshot_read_connection(self.connection()?, request)
11569 }
11570
11571 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11574 Ok(self
11575 .snapshot_read(&SnapshotReadRequest::default())?
11576 .revision)
11577 }
11578
11579 pub fn subscription_catchup(
11581 &mut self,
11582 id: &str,
11583 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11584 let read = self.snapshot_read(&SnapshotReadRequest {
11585 subscriptions: vec![id.to_owned()],
11586 ..Default::default()
11587 })?;
11588 Ok(read
11589 .subscriptions
11590 .into_iter()
11591 .next()
11592 .expect("one requested catch-up"))
11593 }
11594
11595 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11598 let read = self.snapshot_read(&SnapshotReadRequest {
11599 commit_ids: vec![id.to_owned()],
11600 ..Default::default()
11601 })?;
11602 Ok(read
11603 .deliveries
11604 .into_iter()
11605 .next()
11606 .expect("one requested delivery"))
11607 }
11608
11609 pub fn query_snapshot(
11612 &mut self,
11613 sql: &str,
11614 params: &[Value],
11615 coverage: &[WindowCoverage],
11616 ) -> Result<QuerySnapshot, QueryReadFailure> {
11617 snapshot_connection(self.connection()?, sql, params, coverage)
11618 }
11619}
11620
11621fn validate_authority_reads(
11622 schema: &ClientSchema,
11623 declarations: &[crate::api::AuthorityReadDeclaration],
11624) -> Result<(), String> {
11625 let mut seen = BTreeSet::new();
11626 for read in declarations {
11627 let table = schema
11628 .table(&read.table)
11629 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11630 let plain = |name: &str| {
11631 table.columns.iter().enumerate().any(|(i, c)| {
11632 c.name == name
11633 && name != "authority_version"
11634 && !name.starts_with("_sync")
11635 && !table.encrypted_columns.iter().any(|e| e.index == i)
11636 && matches!(
11637 c.ty,
11638 ssp2::segment::ColumnType::String
11639 | ssp2::segment::ColumnType::Integer
11640 | ssp2::segment::ColumnType::Float
11641 | ssp2::segment::ColumnType::Boolean
11642 | ssp2::segment::ColumnType::Json
11643 )
11644 })
11645 };
11646 if !seen.insert(&read.table)
11647 || read.columns.is_empty()
11648 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11649 || !read.columns.contains(&table.primary_key)
11650 || !read.columns.iter().all(|c| plain(c))
11651 || read.scopes.is_empty()
11652 || !read.scopes.iter().all(|(v, values)| {
11653 table
11654 .scope_column(v)
11655 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11656 && !values.is_empty()
11657 && values.iter().all(|v| !v.is_empty() && v != "*")
11658 })
11659 {
11660 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11661 }
11662 }
11663 Ok(())
11664}
11665
11666fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11671 let failure = QueryReadFailure::from(error);
11672 if failure.code.is_some() {
11673 failure.to_string()
11674 } else {
11675 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11676 }
11677}
11678
11679fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11683 if limits.outcome_retention_max_entries == Some(0) {
11684 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11685 }
11686 if [
11687 limits.max_push_commits_per_request,
11688 limits.max_push_operations_per_request,
11689 limits.max_push_request_bytes,
11690 ]
11691 .into_iter()
11692 .flatten()
11693 .any(|value| value == 0 || value > u32::MAX as usize)
11694 {
11695 return Err(
11696 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11697 );
11698 }
11699 Ok(())
11700}
11701
11702fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11703 let corrupt =
11704 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11705 let table_type: Option<String> = conn
11706 .query_row(
11707 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11708 [],
11709 |row| row.get(0),
11710 )
11711 .optional()
11712 .map_err(schema_marker_read_failure)?;
11713 let Some(table_type) = table_type else {
11714 return Ok(None);
11715 };
11716 if table_type != "table" {
11717 return Err(corrupt());
11718 }
11719 let mut statement = conn
11723 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11724 .map_err(schema_marker_read_failure)?;
11725 let mut rows = statement
11726 .query([LOCAL_SCHEMA_VERSION_KEY])
11727 .map_err(schema_marker_read_failure)?;
11728 let value: Option<String> = rows
11729 .next()
11730 .map_err(schema_marker_read_failure)?
11731 .map(|row| row.get(0))
11732 .transpose()
11733 .map_err(schema_marker_read_failure)?;
11734 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11735 return Err(corrupt());
11736 }
11737 let Some(value) = value else {
11738 let has_descriptor = conn
11739 .query_row(
11740 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11741 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11742 |_| Ok(()),
11743 )
11744 .optional()
11745 .map_err(schema_marker_read_failure)?
11746 .is_some();
11747 if has_descriptor {
11748 return Err(corrupt());
11749 }
11750 return Ok(None);
11751 };
11752 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11753 || !value.bytes().all(|b| b.is_ascii_digit())
11754 {
11755 return Err(corrupt());
11756 }
11757 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11758 if version > requested {
11759 return Err(
11760 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11761 .to_owned(),
11762 );
11763 }
11764 Ok(Some(version))
11765}
11766
11767#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11771fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11772 if path.is_empty() || path == ":memory:" {
11773 return Ok(());
11774 }
11775 crate::wasm_vfs::disable_foreign_lock_check()
11776}
11777
11778#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11779fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11780 Ok(())
11781}
11782
11783impl SyncClient {
11784 pub fn new_with_identity(
11785 client_id: Option<String>,
11786 schema_json: &Value,
11787 limits: ClientLimits,
11788 ) -> Result<Self, String> {
11789 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11790 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11791 Self::with_connection(resolved, schema_json, limits, conn)
11792 }
11793
11794 pub fn new(
11795 client_id: String,
11796 schema_json: &Value,
11797 limits: ClientLimits,
11798 ) -> Result<Self, String> {
11799 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11800 Self::with_connection(client_id, schema_json, limits, conn)
11801 }
11802
11803 pub fn open_path(
11809 client_id: String,
11810 schema_json: &Value,
11811 limits: ClientLimits,
11812 path: &str,
11813 ) -> Result<Self, String> {
11814 validate_client_limits(&limits)?;
11815 parse_schema_json(schema_json)?;
11816 prepare_wasm_replica(path)?;
11817 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11818 Self::with_connection(client_id, schema_json, limits, conn)
11819 }
11820
11821 pub fn open_path_with_identity(
11825 client_id: Option<String>,
11826 schema_json: &Value,
11827 limits: ClientLimits,
11828 path: &str,
11829 ) -> Result<Self, String> {
11830 Self::open_path_with_identity_journal(
11831 client_id,
11832 schema_json,
11833 limits,
11834 path,
11835 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11836 )
11837 }
11838
11839 fn open_path_with_identity_journal(
11840 client_id: Option<String>,
11841 schema_json: &Value,
11842 limits: ClientLimits,
11843 path: &str,
11844 require_wal: bool,
11845 ) -> Result<Self, String> {
11846 validate_client_limits(&limits)?;
11847 parse_schema_json(schema_json)?;
11850 prepare_wasm_replica(path)?;
11851 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11852 conn.busy_timeout(std::time::Duration::from_millis(250))
11860 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11861 conn.pragma_update(None, "synchronous", "FULL")
11866 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11867 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
11868 if require_wal {
11872 let journal_mode: String = client
11873 .conn
11874 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
11875 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
11876 if journal_mode != "wal" {
11877 return Err(
11878 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
11879 );
11880 }
11881 }
11882 Ok(client)
11883 }
11884
11885 pub fn reset_replica(path: &str) -> Result<(), String> {
11897 if path.is_empty() || path == ":memory:" {
11898 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
11899 }
11900 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
11904 if vfs.is_null() {
11905 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
11906 }
11907 let delete = unsafe { (*vfs).xDelete }
11908 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
11909 for suffix in ["-wal", "-shm", "-journal", ""] {
11910 let file_name = format!("{path}{suffix}");
11911 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
11912 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
11913 })?;
11914 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
11917 if code != rusqlite::ffi::SQLITE_OK {
11918 return Err(format!(
11919 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
11920 ));
11921 }
11922 }
11923 Ok(())
11924 }
11925
11926 #[must_use]
11927 pub fn client_id(&self) -> &str {
11928 &self.client_id
11929 }
11930
11931 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
11938 self.disconnect_realtime(transport);
11939 let client_id = uuid::Uuid::new_v4().to_string();
11940 self.client_id.clone_from(&client_id);
11941 self.set_meta(CLIENT_ID_KEY, &client_id);
11942 self.round_failure = None;
11943 client_id
11944 }
11945
11946 pub fn with_connection(
11953 client_id: String,
11954 schema_json: &Value,
11955 limits: ClientLimits,
11956 conn: Connection,
11957 ) -> Result<Self, String> {
11958 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
11959 }
11960
11961 fn with_connection_identity(
11964 client_id: Option<String>,
11965 schema_json: &Value,
11966 limits: ClientLimits,
11967 conn: Connection,
11968 ) -> Result<Self, String> {
11969 validate_client_limits(&limits)?;
11970 let schema = parse_schema_json(schema_json)?;
11971 validate_authority_reads(&schema, &limits.authority_reads)?;
11972 let previous_version = match limits.previous_version_context {
11975 Some(config) => {
11976 config.validate()?;
11977 Some(config)
11978 }
11979 None => None,
11980 };
11981 let mut client = SyncClient {
11982 #[cfg(feature = "bench-internals")]
11983 benchmark_phases: Recorder::default(),
11984 conn,
11985 schema,
11986 client_id: client_id
11987 .clone()
11988 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
11989 limits,
11990 subs: Vec::new(),
11991 outbox: Vec::new(),
11992 failed_commits: Vec::new(),
11993 retain_failed_commits: false,
11994 conflicts: Vec::new(),
11995 rejections: Vec::new(),
11996 schema_floor: None,
11997 lease_state: None,
11998 stopped: false,
11999 upgrading: false,
12000 sync_needed: false,
12001 realtime_policy: RealtimePolicy::Optional,
12002 realtime_state: RealtimeState::Disconnected,
12003 realtime_reason_code: None,
12004 realtime_retry_delay_ms: None,
12005 presence: HashMap::new(),
12006 now_ms: None,
12007 encryption: crate::values::EncryptionConfig::default(),
12008 security_preflight: false,
12009 transport_enabled: true,
12010 previous_version,
12011 insert_sql: RefCell::new(HashMap::new()),
12012 overlay_dirty: OverlayDirty::clean(),
12013 storage_failure: RefCell::new(None),
12014 #[cfg(test)]
12015 overlay_rebuild_count: Cell::new(0),
12016 #[cfg(test)]
12017 acknowledged_replay_count: Cell::new(0),
12018 #[cfg(test)]
12019 outcome_prune_count: Cell::new(0),
12020 progress: ProgressObserver::default(),
12021 change_queue: VecDeque::new(),
12022 sync_intent_queue: VecDeque::new(),
12023 retry_delay_ms: 250,
12024 round_retry_delay_ms: None,
12025 round_failure: None,
12026 active_round: None,
12027 last_round: None,
12028 last_change: None,
12029 query_failures: Vec::new(),
12030 coalesced: None,
12031 coalesced_failure: None,
12032 };
12033 client
12037 .conn
12038 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12039 client
12045 .conn
12046 .execute_batch("BEGIN IMMEDIATE")
12047 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12048 let startup = (|| -> Result<(), String> {
12049 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12052 client.create_bookkeeping_tables()?;
12055 match client.get_meta(CLIENT_ID_KEY) {
12056 Some(existing) => match client_id {
12057 Some(requested) if existing != requested => {
12058 return Err(format!(
12059 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12060 ))
12061 }
12062 _ => client.client_id = existing,
12063 },
12064 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12065 }
12066 client.restore_persisted_state()?;
12067 client.failed_commits = client.load_failed_commits()?;
12068 match marker {
12069 None => {
12070 client.create_synced_tables()?;
12071 client.persist_schema_version()?;
12072 client.save_subscription_scope_schema();
12073 }
12074 Some(version) if version == client.schema.version => {
12075 client.create_synced_tables()?;
12076 set_local_schema_descriptor(&client.conn, &client.schema)
12080 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12081 client.save_subscription_scope_schema();
12082 }
12083 Some(_) => client.run_schema_reset()?,
12084 }
12085 client.reconcile_previous_version_at_boot()?;
12091 client.clear_satisfied_persisted_schema_floor();
12092 client.prune_unknown_subscriptions(false)?;
12093 client.drop_unencodable_outbox(false, None)?;
12102 if marker == Some(client.schema.version)
12103 && (!client.outbox.is_empty()
12104 || !client.failed_commits.is_empty()
12105 || client.has_acknowledged_rows())
12106 {
12107 client.overlay_dirty.set(true);
12111 client.rebuild_overlay()?;
12112 }
12113 client.enqueue_startup_sync_if_needed()?;
12120 Ok(())
12121 })();
12122 match startup {
12123 Ok(()) => match client.conn.execute_batch("COMMIT") {
12124 Ok(()) => Ok(client),
12125 Err(error) => {
12126 let failure = Self::sqlite_failure(&client.storage_failure, error);
12127 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12128 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12130 }
12131 Err(failure)
12132 }
12133 },
12134 Err(error) => {
12135 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12136 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12138 }
12139 Err(error)
12140 }
12141 }
12142 }
12143
12144 pub fn set_now_ms(&mut self, now_ms: i64) {
12147 self.now_ms = Some(now_ms);
12148 }
12149
12150 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12154 self.encryption = encryption;
12155 }
12156
12157 #[must_use]
12158 pub fn security_lifecycle(&self) -> &'static str {
12159 if self.security_preflight {
12160 "preflight"
12161 } else {
12162 "active"
12163 }
12164 }
12165
12166 #[must_use]
12167 pub fn security_preflight(&self) -> bool {
12168 self.security_preflight
12169 }
12170
12171 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12173 if self.transport_enabled != enabled {
12174 self.transport_enabled = enabled;
12175 self.sync_intent_queue.clear();
12176 self.sync_intent_queue
12177 .push_back(if enabled && !self.security_preflight {
12178 SyncIntent::Interactive
12179 } else {
12180 SyncIntent::None
12181 });
12182 }
12183 if !enabled && self.active_round.is_none() {
12185 self.disconnect_realtime(transport);
12186 }
12187 }
12188
12189 pub fn transport_enabled(&self) -> bool {
12190 self.transport_enabled
12191 }
12192
12193 pub fn begin_security_preflight(&mut self) {
12200 self.seal_security_on_teardown();
12201 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12204 }
12205
12206 pub fn seal_security_on_teardown(&mut self) {
12216 self.cancel_sync_round();
12217 self.security_preflight = true;
12218 self.encryption = crate::values::EncryptionConfig::default();
12219 self.sync_intent_queue.clear();
12220 }
12221
12222 pub fn activate_security(
12224 &mut self,
12225 encryption: crate::values::EncryptionConfig,
12226 ) -> Result<(), String> {
12227 if !self.security_preflight {
12228 return Err(
12229 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12230 );
12231 }
12232 self.enqueue_startup_sync_if_needed()?;
12233 self.encryption = encryption;
12234 self.security_preflight = false;
12235 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12236 Ok(())
12237 }
12238
12239 fn clock_now_ms(&self) -> i64 {
12240 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12241 }
12242
12243 fn create_bookkeeping_tables(&self) -> Result<(), String> {
12244 self.conn
12246 .execute_batch(
12247 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12248 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12249 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12250 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12251 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12252 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12253 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12254 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12255 PRIMARY KEY(commit_id, idx));
12256 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12257 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12258 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12259 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12260 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12261 client_commit_id TEXT NOT NULL UNIQUE,
12262 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12263 recorded_at_ms INTEGER NOT NULL,
12264 results_json TEXT NOT NULL,
12265 operations_json TEXT,
12266 resolution TEXT NOT NULL DEFAULT 'active'
12267 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12268 resolved_at_ms INTEGER,
12269 replacement_client_commit_id TEXT);
12270 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12271 ON _syncular_commit_outcomes(resolution, seq);
12272 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12273 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12274 CREATE TABLE IF NOT EXISTS _syncular_meta (
12275 key TEXT PRIMARY KEY, value TEXT NOT NULL);
12276 CREATE TABLE IF NOT EXISTS _syncular_windows (
12277 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12278 PRIMARY KEY (base, unit));
12279 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12280 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12281 effective_scopes TEXT NOT NULL);
12282 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12283 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12284 PRIMARY KEY(commit_id, blob_id));
12285 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12286 ON _syncular_blob_commit_refs(blob_id);",
12287 )
12288 .map_err(|e| e.to_string())?;
12289 let _ = self
12292 .conn
12293 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12294 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12295 self.set_meta(LOCAL_REVISION_KEY, "0");
12296 }
12297 if self.schema_has_blobs() {
12300 let exists = self
12301 .conn
12302 .query_row(
12303 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12304 [],
12305 |row| row.get::<_, bool>(0),
12306 )
12307 .map_err(|e| e.to_string())?;
12308 if exists {
12309 let mut statement = self
12310 .conn
12311 .prepare("PRAGMA table_info(_syncular_blobs)")
12312 .map_err(|e| e.to_string())?;
12313 let mut columns = statement
12314 .query_map([], |row| row.get::<_, String>(1))
12315 .map_err(|e| e.to_string())?
12316 .collect::<Result<Vec<_>, _>>()
12317 .map_err(|e| e.to_string())?;
12318 columns.sort();
12319 let mut expected = vec![
12320 "blob_id",
12321 "byte_length",
12322 "bytes",
12323 "created_at_ms",
12324 "media_type",
12325 ];
12326 expected.sort_unstable();
12327 if columns != expected {
12328 return Err(
12329 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12330 );
12331 }
12332 } else {
12333 self.conn
12334 .execute_batch(
12335 "CREATE TABLE _syncular_blobs(
12336 blob_id TEXT PRIMARY KEY,
12337 bytes BLOB NOT NULL,
12338 byte_length INTEGER NOT NULL,
12339 media_type TEXT,
12340 created_at_ms INTEGER NOT NULL);",
12341 )
12342 .map_err(|e| e.to_string())?;
12343 }
12344 self.conn
12345 .execute_batch(
12346 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12347 blob_id TEXT PRIMARY KEY,
12348 media_type TEXT,
12349 created_at_ms INTEGER NOT NULL);",
12350 )
12351 .map_err(|e| e.to_string())?;
12352 }
12353 Ok(())
12354 }
12355
12356 fn schema_has_blobs(&self) -> bool {
12358 self.schema
12359 .tables
12360 .iter()
12361 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12362 }
12363
12364 fn get_meta(&self, key: &str) -> Option<String> {
12367 meta_get(&self.conn, key)
12368 }
12369
12370 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12371 self.conn
12372 .query_row(
12373 "SELECT value FROM _syncular_meta WHERE key = ?1",
12374 rusqlite::params![key],
12375 |row| row.get::<_, String>(0),
12376 )
12377 .optional()
12378 .map_err(|_| {
12379 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12380 })
12381 }
12382
12383 fn persist_schema_version(&self) -> Result<(), String> {
12386 self.conn
12387 .execute(
12388 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12389 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12390 )
12391 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12392 set_local_schema_descriptor(&self.conn, &self.schema)
12393 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12394 }
12395
12396 pub(crate) fn set_meta(&self, key: &str, value: &str) {
12397 meta_set(&self.conn, key, value);
12398 }
12399
12400 fn delete_meta(&self, key: &str) {
12401 meta_delete(&self.conn, key);
12402 }
12403
12404 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12406 let mut stmt = self
12407 .conn
12408 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12409 .map_err(|error| error.to_string())?;
12410 let rows = stmt
12411 .query_map([], |row| {
12412 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12413 })
12414 .map_err(|error| error.to_string())?;
12415 let mut commits = Vec::new();
12416 for row in rows {
12417 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12418 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12419 format!("invalid persisted outbox {client_commit_id:?}: {error}")
12420 })?;
12421 let mut ops = Vec::with_capacity(entries.len());
12422 for entry in entries {
12423 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12424 ops.push(OutboxOp {
12425 upsert: op == "upsert",
12426 table: entry
12427 .get("table")
12428 .and_then(Value::as_str)
12429 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12430 .to_owned(),
12431 row_id: entry
12432 .get("rowId")
12433 .and_then(Value::as_str)
12434 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12435 .to_owned(),
12436 base_version: entry.get("baseVersion").and_then(Value::as_i64),
12437 values: entry.get("values").and_then(Value::as_object).cloned(),
12440 });
12441 }
12442 commits.push(OutboxCommit {
12443 client_commit_id,
12444 ops,
12445 });
12446 }
12447 Ok(commits)
12448 }
12449
12450 fn restore_persisted_state(&mut self) -> Result<(), String> {
12451 self.subs = {
12452 let mut stmt = self
12453 .conn
12454 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12455 .map_err(|error| error.to_string())?;
12456 let rows = stmt
12457 .query_map([], |row| {
12458 Ok((
12459 row.get::<_, String>(0)?,
12460 row.get::<_, String>(1)?,
12461 row.get::<_, String>(2)?,
12462 ))
12463 })
12464 .map_err(|error| error.to_string())?;
12465 let mut subscriptions = Vec::new();
12466 for row in rows {
12467 let (id, table, raw) = row.map_err(|error| error.to_string())?;
12468 let state: Value = serde_json::from_str(&raw)
12469 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12470 let requested = json_to_scope_map(
12471 state.get("requested").unwrap_or(&Value::Object(Map::new())),
12472 )?;
12473 let effective = state
12474 .get("effectiveScopes")
12475 .filter(|value| !value.is_null())
12476 .map(json_to_scope_map)
12477 .transpose()?;
12478 subscriptions.push(Subscription {
12479 id,
12480 table,
12481 requested,
12482 params: state
12483 .get("params")
12484 .and_then(Value::as_str)
12485 .map(str::to_owned),
12486 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12487 bootstrap_state: state
12488 .get("bootstrapState")
12489 .and_then(Value::as_str)
12490 .map(str::to_owned),
12491 state: SubState::parse(
12492 state
12493 .get("status")
12494 .and_then(Value::as_str)
12495 .unwrap_or("active"),
12496 ),
12497 reason_code: state
12498 .get("reasonCode")
12499 .and_then(Value::as_str)
12500 .map(str::to_owned),
12501 effective,
12502 synced_once: state
12503 .get("syncedOnce")
12504 .and_then(Value::as_bool)
12505 .unwrap_or(false),
12506 });
12507 }
12508 subscriptions
12509 };
12510
12511 self.outbox = self.load_outbox()?;
12512
12513 self.prune_commit_outcomes()?;
12514 let active = self.commit_outcomes(CommitOutcomeQuery {
12515 active_only: true,
12516 ..CommitOutcomeQuery::default()
12517 })?;
12518 self.conflicts = active
12519 .iter()
12520 .flat_map(|outcome| outcome.results.iter())
12521 .filter_map(|result| match result {
12522 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12523 _ => None,
12524 })
12525 .collect();
12526 self.rejections = active
12527 .iter()
12528 .flat_map(|outcome| outcome.results.iter())
12529 .filter_map(|result| match result {
12530 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12531 _ => None,
12532 })
12533 .collect();
12534
12535 self.lease_state = self
12536 .get_meta(LEASE_STATE_KEY)
12537 .map(|raw| serde_json::from_str(&raw))
12538 .transpose()
12539 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12540 self.schema_floor = self
12541 .get_meta(SCHEMA_FLOOR_KEY)
12542 .map(|raw| serde_json::from_str(&raw))
12543 .transpose()
12544 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12545 self.stopped = self.schema_floor.is_some();
12546 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12549 self.security_preflight = true;
12550 }
12551 Ok(())
12552 }
12553
12554 fn save_subscription_scope_schema(&self) {
12555 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12556 .schema
12557 .tables
12558 .iter()
12559 .map(|table| {
12560 (
12561 table.name.clone(),
12562 table
12563 .scope_variables
12564 .iter()
12565 .map(|scope| {
12566 (
12567 scope.variable.clone(),
12568 [scope.prefix.clone(), scope.column.clone()],
12569 )
12570 })
12571 .collect(),
12572 )
12573 })
12574 .collect();
12575 self.set_meta(
12576 "subscriptionScopeSchema",
12577 &serde_json::to_string(&declarations).expect("scope schema serialization"),
12578 );
12579 }
12580
12581 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12583 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12584 self.get_meta("subscriptionScopeSchema")
12585 .map(|raw| serde_json::from_str(&raw))
12586 .transpose()
12587 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12588 } else {
12589 None
12590 };
12591 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12592 let Some(table) = self.schema.table(table_name) else {
12593 return false;
12594 };
12595 if requested
12596 .iter()
12597 .any(|(variable, _)| table.scope_column(variable).is_none())
12598 {
12599 return false;
12600 }
12601 if !bump {
12602 return true;
12603 }
12604 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12605 return false;
12606 };
12607 let variables: BTreeSet<_> = if requested.is_empty() {
12608 old.keys()
12609 .cloned()
12610 .chain(
12611 table
12612 .scope_variables
12613 .iter()
12614 .map(|scope| scope.variable.clone()),
12615 )
12616 .collect()
12617 } else {
12618 requested
12619 .iter()
12620 .map(|(variable, _)| variable.clone())
12621 .collect()
12622 };
12623 variables.iter().all(|variable| {
12624 let current = table
12625 .scope_variables
12626 .iter()
12627 .find(|scope| &scope.variable == variable);
12628 match (old.get(variable), current) {
12629 (Some(old), Some(current)) => {
12630 old == &[current.prefix.clone(), current.column.clone()]
12631 }
12632 _ => false,
12633 }
12634 })
12635 };
12636 let mut stale_ids: BTreeSet<_> = self
12637 .subs
12638 .iter()
12639 .filter(|sub| !compatible(&sub.table, &sub.requested))
12640 .map(|sub| sub.id.clone())
12641 .collect();
12642 for (id, table, effective) in self.load_pending_evictions()? {
12643 if !compatible(&table, &effective) {
12644 stale_ids.insert(id);
12645 }
12646 }
12647 for id in &stale_ids {
12648 for sql in [
12649 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12650 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12651 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12652 ] {
12653 self.conn
12654 .execute(sql, rusqlite::params![id])
12655 .map_err(|error| error.to_string())?;
12656 }
12657 }
12658 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12659 Ok(())
12660 }
12661
12662 #[must_use]
12663 pub fn local_revision(&self) -> u64 {
12664 self.get_meta(LOCAL_REVISION_KEY)
12665 .and_then(|value| value.parse().ok())
12666 .unwrap_or(0)
12667 }
12668
12669 pub fn snapshot_read(
12675 &mut self,
12676 request: &SnapshotReadRequest<'_>,
12677 owner: Option<QueryOwner<'_>>,
12678 ) -> Result<SnapshotRead, QueryReadFailure> {
12679 let result = snapshot_read_connection(&self.conn, request);
12680 if let Some(owner) = owner {
12681 self.record_query_read(owner, result.as_ref().err());
12682 }
12683 result
12684 }
12685
12686 #[must_use]
12687 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12688 SyncStatusSnapshot {
12689 current_schema_version: self.schema.version,
12690 outbox: self.outbox.len(),
12691 upgrading: self.upgrading,
12692 lease_state: self.lease_state.clone(),
12693 schema_floor: self.schema_floor.clone(),
12694 sync_needed: self.sync_needed,
12695 previous_version_context: self.previous_version_status(),
12696 }
12697 }
12698
12699 fn previous_version_status(&self) -> PreviousVersionStatus {
12702 let Some(replica_path) = self.previous_version_replica_path() else {
12703 return PreviousVersionStatus {
12704 present: false,
12705 created_at_ms: None,
12706 };
12707 };
12708 match crate::previous_version::read_previous_version_record(&replica_path) {
12709 Ok(Some(record)) => PreviousVersionStatus {
12710 present: true,
12711 created_at_ms: Some(record.created_at_ms),
12712 },
12713 _ => PreviousVersionStatus {
12714 present: std::path::Path::new(
12715 &crate::previous_version::previous_version_container_path(&replica_path),
12716 )
12717 .exists(),
12718 created_at_ms: None,
12719 },
12720 }
12721 }
12722
12723 pub fn previous_version_snapshot(
12729 &self,
12730 spec: &PreviousVersionReadSpec,
12731 ) -> Result<PreviousVersionSnapshot, String> {
12732 let now_ms = self.clock_now_ms();
12733 crate::previous_version::previous_version_snapshot(
12734 &self.conn,
12735 self.previous_version_replica_path().as_deref(),
12736 self.previous_version.as_ref(),
12737 PreviousVersionLifecycle {
12738 lease_inactive: self.previous_version_lease_inactive(now_ms),
12739 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12740 coverage_complete: self.previous_version_coverage_complete(),
12741 },
12742 now_ms,
12743 self.schema.version,
12744 spec,
12745 )
12746 }
12747
12748 #[must_use]
12750 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12751 crate::previous_version::stored_previous_version_audit(&self.conn)
12752 }
12753
12754 pub fn previous_version_discard(
12758 &self,
12759 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12760 self.drop_previous_version()
12761 }
12762
12763 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12767 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12768 present: false,
12769 discarded: false,
12770 };
12771 let Some(replica_path) = self.previous_version_replica_path() else {
12772 return absent;
12773 };
12774 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12775 .unwrap_or(absent)
12776 }
12777
12778 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12780 let Some(lease) = &self.lease_state else {
12781 return false;
12782 };
12783 if lease.error_code.is_some() {
12784 return true;
12785 }
12786 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12787 }
12788
12789 fn previous_version_coverage_complete(&self) -> bool {
12793 let active = self
12794 .subs
12795 .iter()
12796 .filter(|sub| sub.state == SubState::Active)
12797 .collect::<Vec<_>>();
12798 if active.is_empty() {
12799 return false;
12800 }
12801 active
12802 .iter()
12803 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12804 }
12805
12806 fn previous_version_lifetime_check(&self) {
12810 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12811 return;
12812 };
12813 let now_ms = self.clock_now_ms();
12814 if self.previous_version_lease_inactive(now_ms) {
12815 let _ = self.drop_previous_version();
12816 return;
12817 }
12818 let Some(replica_path) = self.previous_version_replica_path() else {
12819 return;
12820 };
12821 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12822 else {
12823 return;
12824 };
12825 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12826 let _ = self.drop_previous_version();
12827 return;
12828 }
12829 if now_ms - record.created_at_ms > config.max_age_ms {
12830 let _ = self.drop_previous_version();
12831 return;
12832 }
12833 if self.previous_version_coverage_complete() {
12834 let _ = self.drop_previous_version();
12835 }
12836 }
12837
12838 pub fn diagnostics_snapshot(
12839 &self,
12840 request: &ClientDiagnosticsRequest,
12841 ) -> Result<ClientDiagnosticsSnapshot, String> {
12842 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12843 return Err(format!(
12844 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12845 ));
12846 }
12847 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12848 for sub in &self.subs {
12849 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12850 let complete =
12851 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12852 let state = match sub.state {
12853 SubState::Revoked => "revoked",
12854 SubState::Failed => "failed",
12855 SubState::Active if reset => "reset",
12856 SubState::Active if complete => "complete",
12857 SubState::Active => "bootstrapping",
12858 };
12859 subscriptions.insert(
12860 sub.id.clone(),
12861 DiagnosticSubscription {
12862 id: sub.id.clone(),
12863 table: sub.table.clone(),
12864 state: state.to_owned(),
12865 complete,
12866 cursor: Some(sub.cursor),
12867 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
12868 },
12869 );
12870 }
12871 for expected in &request.expected_subscriptions {
12872 if expected.id.is_empty() || expected.table.is_empty() {
12873 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
12874 }
12875 if subscriptions
12876 .get(&expected.id)
12877 .is_some_and(|registered| registered.table != expected.table)
12878 {
12879 subscriptions.insert(
12880 expected.id.clone(),
12881 DiagnosticSubscription {
12882 id: expected.id.clone(),
12883 table: expected.table.clone(),
12884 state: "failed".to_owned(),
12885 complete: false,
12886 cursor: None,
12887 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
12888 },
12889 );
12890 } else {
12891 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
12892 DiagnosticSubscription {
12893 id: expected.id.clone(),
12894 table: expected.table.clone(),
12895 state: "unregistered".to_owned(),
12896 complete: false,
12897 cursor: None,
12898 reason_code: None,
12899 }
12900 });
12901 }
12902 }
12903 let mut ordered_subscriptions = Vec::new();
12904 let mut included = BTreeSet::new();
12905 for expected in &request.expected_subscriptions {
12906 if included.insert(expected.id.clone()) {
12907 if let Some(subscription) = subscriptions.get(&expected.id) {
12908 ordered_subscriptions.push(subscription.clone());
12909 }
12910 }
12911 }
12912 for (id, subscription) in &subscriptions {
12913 if included.insert(id.clone()) {
12914 ordered_subscriptions.push(subscription.clone());
12915 }
12916 }
12917 let subscriptions_truncated =
12918 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
12919 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
12920 let captured_at_ms = self.clock_now_ms();
12921 let lease = if let Some(error_code) = self
12922 .lease_state
12923 .as_ref()
12924 .and_then(|state| state.error_code.clone())
12925 {
12926 ClientDiagnosticsLease {
12927 state: "stopped".to_owned(),
12928 expires_at_ms: self
12929 .lease_state
12930 .as_ref()
12931 .and_then(|state| state.expires_at_ms),
12932 error_code: Some(Self::diagnostic_code(&error_code)),
12933 }
12934 } else if let Some(expires_at_ms) = self
12935 .lease_state
12936 .as_ref()
12937 .and_then(|state| state.expires_at_ms)
12938 {
12939 ClientDiagnosticsLease {
12940 state: if expires_at_ms <= captured_at_ms {
12941 "expired".to_owned()
12942 } else {
12943 "active".to_owned()
12944 },
12945 expires_at_ms: Some(expires_at_ms),
12946 error_code: None,
12947 }
12948 } else {
12949 ClientDiagnosticsLease {
12950 state: "none".to_owned(),
12951 expires_at_ms: None,
12952 error_code: None,
12953 }
12954 };
12955 let connectivity = match self.last_round.as_ref() {
12956 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
12957 Some(round) if round.status == "succeeded" => "online",
12958 Some(round)
12959 if round.status == "failed"
12960 && round
12961 .error_code
12962 .as_deref()
12963 .is_some_and(Self::retryable_transport_code) =>
12964 {
12965 "offline"
12966 }
12967 _ => "unknown",
12968 };
12969 Ok(ClientDiagnosticsSnapshot {
12970 version: CLIENT_DIAGNOSTICS_VERSION,
12971 captured_at_ms,
12972 host: ClientDiagnosticsHost {
12973 kind: "direct".to_owned(),
12974 role: "single".to_owned(),
12975 connectivity: connectivity.to_owned(),
12976 realtime: self.realtime_state,
12977 realtime_policy: self.realtime_policy,
12978 realtime_reason_code: self.realtime_reason_code.clone(),
12979 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
12980 },
12981 security_lifecycle: self.security_lifecycle().to_owned(),
12982 schema: ClientDiagnosticsSchema {
12983 current_version: self.schema.version,
12984 upgrading: self.upgrading,
12985 required_version: self
12986 .schema_floor
12987 .as_ref()
12988 .and_then(|floor| floor.required_schema_version),
12989 latest_version: self
12990 .schema_floor
12991 .as_ref()
12992 .and_then(|floor| floor.latest_schema_version),
12993 },
12994 replica: ClientDiagnosticsReplica {
12995 local_revision: self.local_revision().to_string(),
12996 sync_needed: self.sync_needed,
12997 pending_outbox: self.outbox.len(),
12998 },
12999 lease,
13000 subscriptions: ordered_subscriptions,
13001 subscriptions_truncated,
13002 last_round: self.last_round.clone(),
13003 last_change: self.last_change.clone(),
13004 storage: self.diagnostics_storage(),
13005 query_failures: self.query_failures.clone(),
13006 })
13007 }
13008
13009 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13010 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13011 let page_count: i64 = self
13012 .conn
13013 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13014 let page_size: i64 = self
13015 .conn
13016 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13017 let outbox_bytes: i64 = self
13019 .conn
13020 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13021 .query_row([], |row| row.get(0))?;
13022 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13023 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13024 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13025 let blob_bytes = if self.schema_has_blobs() {
13026 self.conn
13027 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13028 .query_row([], |row| row.get(0))?
13029 } else {
13030 0
13031 };
13032 let pressure = self
13033 .limits
13034 .blob_cache_max_bytes
13035 .is_some_and(|limit| blob_bytes > limit);
13036 Ok(ClientDiagnosticsStorage {
13037 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13038 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13039 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13040 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13041 retained_outcome_entries: Some(outcome_entries.max(0)),
13042 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13043 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13044 })
13045 };
13046 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13047 status: "unreadable".to_owned(),
13048 database_bytes_approx: None,
13049 pending_outbox_bytes_approx: None,
13050 retained_outcome_bytes_approx: None,
13051 retained_outcome_entries: None,
13052 blob_cache_bytes_approx: None,
13053 pressure_reason_code: None,
13054 })
13055 }
13056
13057 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13058 self.change_queue.drain(..).collect()
13059 }
13060
13061 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13062 let intents = self.sync_intent_queue.drain(..).collect();
13063 if self.transport_enabled {
13064 intents
13065 } else {
13066 vec![SyncIntent::None]
13067 }
13068 }
13069
13070 fn schedule_background_retry(&mut self) -> u64 {
13071 let delay_ms = self.retry_delay_ms;
13072 self.sync_intent_queue
13073 .push_back(SyncIntent::Background { delay_ms });
13074 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13075 self.round_retry_delay_ms = Some(delay_ms);
13076 delay_ms
13077 }
13078
13079 fn reset_background_retry(&mut self) {
13080 self.retry_delay_ms = 250;
13081 }
13082
13083 fn retryable_transport_code(code: &str) -> bool {
13084 code == "transport.failed"
13085 || code == "transport.timeout"
13086 || code == "transport.unavailable"
13087 || code == "sync.transport_failed"
13088 }
13089
13090 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13095 if ssp2::model::valid_round_failure_code(&error.code) {
13096 self.round_failure = Some(error.code.clone());
13097 }
13098 SectionError::Abort(error.code, error.message)
13099 }
13100
13101 fn retryable_failure_code(code: &str) -> bool {
13106 Self::retryable_transport_code(code)
13107 || matches!(
13108 code,
13109 "sync.auth_required"
13110 | "sync.auth_lease_required"
13111 | "sync.auth_lease_revoked"
13112 | "sync.internal_error"
13113 | "sync.idempotency_cache_miss"
13114 | "sync.schema_not_ready"
13115 | "sync.segment_expired"
13116 | "sync.rate_limited"
13117 | "sync.websocket_connection_limit"
13118 )
13119 }
13120
13121 fn diagnostic_code(code: &str) -> String {
13122 let valid = !code.is_empty()
13123 && code.len() <= 96
13124 && code.contains('.')
13125 && code
13126 .bytes()
13127 .next()
13128 .is_some_and(|byte| byte.is_ascii_lowercase())
13129 && code.bytes().all(|byte| {
13130 byte.is_ascii_lowercase()
13131 || byte.is_ascii_digit()
13132 || matches!(byte, b'.' | b'_' | b'-')
13133 });
13134 if valid {
13135 code.to_owned()
13136 } else {
13137 "client.unknown_failure".to_owned()
13138 }
13139 }
13140
13141 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13142 if self.sync_needed != value {
13143 if self.begin_observation("syncular_status").is_ok() {
13144 self.sync_needed = value;
13145 let batch = ChangeAccumulator {
13146 status: true,
13147 ..ChangeAccumulator::default()
13148 };
13149 if self.finish_observation("syncular_status", batch).is_err() {
13150 self.rollback_observation("syncular_status");
13151 }
13152 } else {
13153 self.sync_needed = value;
13154 }
13155 }
13156 if value && interactive {
13157 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13158 }
13159 }
13160
13161 fn sqlite_failure(
13162 storage_failure: &RefCell<Option<QueryReadFailure>>,
13163 error: rusqlite::Error,
13164 ) -> String {
13165 let message = error.to_string();
13166 let failure = QueryReadFailure::from(error);
13167 let code = failure.code;
13168 if code.is_some() && storage_failure.borrow().is_none() {
13169 *storage_failure.borrow_mut() = Some(failure);
13170 }
13171 match code {
13172 Some(code) => format!("{code}: {message}"),
13173 None => message,
13174 }
13175 }
13176
13177 fn begin_observation(&self, name: &str) -> Result<(), String> {
13178 if let Some(failure) = &self.coalesced_failure {
13179 return Err(failure.clone());
13180 }
13181 if self.coalesced.is_some() && self.conn.is_autocommit() {
13184 return Err("the coalesced apply transaction was rolled back".to_owned());
13185 }
13186 self.conn
13187 .execute_batch(&format!("SAVEPOINT {name}"))
13188 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13189 }
13190
13191 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13194 if self.coalesced.is_some() || !self.conn.is_autocommit() {
13195 return Ok(());
13196 }
13197 self.conn
13198 .execute_batch("BEGIN")
13199 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13200 self.coalesced = Some(CoalescedApply {
13201 rows: 0,
13202 subs: self.subs.clone(),
13203 outbox: self.outbox.clone(),
13204 failed_commits: self.failed_commits.clone(),
13205 conflicts: self.conflicts.clone(),
13206 rejections: self.rejections.clone(),
13207 schema_floor: self.schema_floor.clone(),
13208 lease_state: self.lease_state.clone(),
13209 stopped: self.stopped,
13210 upgrading: self.upgrading,
13211 sync_needed: self.sync_needed,
13212 overlay_dirty: self.overlay_dirty.snapshot(),
13213 change_queue: self.change_queue.len(),
13214 sync_intent_queue: self.sync_intent_queue.len(),
13215 last_change: self.last_change.clone(),
13216 });
13217 Ok(())
13218 }
13219
13220 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13223 let Some(open) = &self.coalesced else {
13224 return Ok(());
13225 };
13226 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13227 self.commit_coalesced_apply()?;
13228 self.begin_coalesced_apply()?;
13229 }
13230 if let Some(open) = &mut self.coalesced {
13231 open.rows += rows;
13232 }
13233 Ok(())
13234 }
13235
13236 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13241 let Some(open) = self.coalesced.take() else {
13242 return Ok(());
13243 };
13244 let committed = if self.conn.is_autocommit() {
13245 Err("the coalesced apply transaction was rolled back".to_owned())
13246 } else {
13247 self.conn
13248 .execute_batch("COMMIT")
13249 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13250 };
13251 let Err(error) = committed else {
13252 return Ok(());
13253 };
13254 if !self.conn.is_autocommit() {
13255 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13256 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13257 }
13258 }
13259 self.subs = open.subs;
13260 self.outbox = open.outbox;
13261 self.failed_commits = open.failed_commits;
13262 self.conflicts = open.conflicts;
13263 self.rejections = open.rejections;
13264 self.schema_floor = open.schema_floor;
13265 self.lease_state = open.lease_state;
13266 self.stopped = open.stopped;
13267 self.upgrading = open.upgrading;
13268 self.sync_needed = open.sync_needed;
13269 self.overlay_dirty.restore(open.overlay_dirty);
13270 self.change_queue.truncate(open.change_queue);
13271 self.sync_intent_queue.truncate(open.sync_intent_queue);
13272 self.last_change = open.last_change;
13273 self.coalesced_failure = Some(error.clone());
13274 Err(error)
13275 }
13276
13277 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13279 let committed = self.commit_coalesced_apply();
13280 match self.coalesced_failure.take() {
13281 Some(failure) => Err(failure),
13282 None => committed,
13283 }
13284 }
13285
13286 fn rollback_observation(&self, name: &str) {
13287 if let Err(error) = self
13288 .conn
13289 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13290 {
13291 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13292 if failure.rollback_failure.is_none() {
13293 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13294 }
13295 }
13296 }
13297 }
13298
13299 fn finish_observation(
13300 &mut self,
13301 name: &str,
13302 mut batch: ChangeAccumulator,
13303 ) -> Result<(), String> {
13304 #[cfg(feature = "bench-internals")]
13305 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13306 if self.failed_commits.iter().any(|(commit, _)| {
13307 commit
13308 .ops
13309 .iter()
13310 .any(|operation| batch.tables.contains_key(&operation.table))
13311 }) {
13312 batch.outcomes = true;
13313 }
13314 if !batch.touched() {
13315 self.conn
13316 .execute_batch(&format!("RELEASE {name}"))
13317 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13318 return Ok(());
13319 }
13320 let revision = self
13321 .local_revision()
13322 .checked_add(1)
13323 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13324 self.conn
13325 .execute(
13326 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13327 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13328 )
13329 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13330 let status = batch.status.then(|| self.status_snapshot());
13331 let event = ClientChangeBatch {
13332 revision: revision.to_string(),
13333 tables: batch
13334 .tables
13335 .into_iter()
13336 .map(|(table, scope_keys)| TableChange {
13337 table,
13338 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13339 })
13340 .collect(),
13341 windows: batch
13342 .windows
13343 .into_iter()
13344 .map(|((base_key, table), units)| WindowChange {
13345 base_key,
13346 table,
13347 units: units.into_iter().collect(),
13348 })
13349 .collect(),
13350 status,
13351 conflicts_changed: batch.conflicts,
13352 rejections_changed: batch.rejections,
13353 outcomes_changed: batch.outcomes,
13354 };
13355 self.conn
13356 .execute_batch(&format!("RELEASE {name}"))
13357 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13358 let mut diagnostic_tables = event
13359 .tables
13360 .iter()
13361 .map(|entry| entry.table.clone())
13362 .collect::<BTreeSet<_>>()
13363 .into_iter()
13364 .collect::<Vec<_>>();
13365 let mut diagnostic_windows = event
13366 .windows
13367 .iter()
13368 .map(|entry| entry.table.clone())
13369 .collect::<BTreeSet<_>>()
13370 .into_iter()
13371 .collect::<Vec<_>>();
13372 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13373 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13374 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13375 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13376 self.last_change = Some(DiagnosticLastChange {
13377 revision: event.revision.clone(),
13378 recorded_at_ms: self.clock_now_ms(),
13379 tables: diagnostic_tables,
13380 windows: diagnostic_windows,
13381 domains_truncated,
13382 status_changed: event.status.is_some(),
13383 conflicts_changed: event.conflicts_changed,
13384 rejections_changed: event.rejections_changed,
13385 outcomes_changed: event.outcomes_changed,
13386 });
13387 self.change_queue.push_back(event);
13388 Ok(())
13389 }
13390
13391 fn record_scope_map(
13392 &self,
13393 batch: &mut ChangeAccumulator,
13394 table_name: &str,
13395 scopes: &[(String, Vec<String>)],
13396 ) {
13397 let Some(table) = self.schema.table(table_name) else {
13398 return;
13399 };
13400 for (variable, values) in scopes {
13401 let Some(scope) = table
13402 .scope_variables
13403 .iter()
13404 .find(|scope| &scope.variable == variable)
13405 else {
13406 continue;
13407 };
13408 for value in values {
13409 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13410 }
13411 }
13412 }
13413
13414 fn record_row_scopes(
13416 &self,
13417 batch: &mut ChangeAccumulator,
13418 table_name: &str,
13419 row_id: &str,
13420 base: bool,
13421 ) -> bool {
13422 let Some(table) = self.schema.table(table_name) else {
13423 return false;
13424 };
13425 if table.scope_variables.is_empty() {
13426 return false;
13427 }
13428 let columns = table
13429 .scope_variables
13430 .iter()
13431 .map(|scope| quote_ident(&scope.column))
13432 .collect::<Vec<_>>()
13433 .join(", ");
13434 let full_table = if base {
13435 base_table(table_name)
13436 } else {
13437 visible_table(table_name)
13438 };
13439 let sql = format!(
13440 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13441 row_id_predicate(table)
13442 );
13443 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13444 return false;
13445 };
13446 let values = stmt.query_row(rusqlite::params![row_id], |row| {
13447 let mut values = Vec::with_capacity(table.scope_variables.len());
13448 for index in 0..table.scope_variables.len() {
13449 values.push(row.get::<_, Option<String>>(index)?);
13450 }
13451 Ok(values)
13452 });
13453 let Ok(values) = values else {
13454 return false;
13455 };
13456 let mut recorded = false;
13457 for (scope, value) in table.scope_variables.iter().zip(values) {
13458 if let Some(value) = value {
13459 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13460 recorded = true;
13461 }
13462 }
13463 recorded
13464 }
13465
13466 fn record_commit_changes(
13467 &self,
13468 batch: &mut ChangeAccumulator,
13469 tables: &[String],
13470 changes: &[ssp2::model::Change],
13471 ) {
13472 #[cfg(feature = "bench-internals")]
13473 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13474 for change in changes {
13475 let Some(table_name) = tables.get(change.table_index as usize) else {
13476 continue;
13477 };
13478 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13479 if let Some(table) = self.schema.table(table_name) {
13480 for (variable, value) in &change.scopes {
13481 if let Some(scope) = table
13482 .scope_variables
13483 .iter()
13484 .find(|scope| &scope.variable == variable)
13485 {
13486 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13487 precise = true;
13488 }
13489 }
13490 }
13491 if !precise {
13492 batch.table(table_name);
13493 }
13494 }
13495 }
13496
13497 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13498 if effective.is_empty() {
13499 return false;
13500 }
13501 let Some(table) = self.schema.table(table_name) else {
13502 return false;
13503 };
13504 let mut clauses = Vec::new();
13505 let mut params = Vec::new();
13506 for (variable, values) in effective {
13507 let Some(column) = table.scope_column(variable) else {
13508 return false;
13509 };
13510 if values.is_empty() {
13511 return false;
13512 }
13513 let placeholders = values
13514 .iter()
13515 .map(|value| {
13516 params.push(SqlValue::Text(value.clone()));
13517 "?"
13518 })
13519 .collect::<Vec<_>>()
13520 .join(", ");
13521 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13522 }
13523 let sql = format!(
13524 "SELECT 1 FROM {} WHERE {} LIMIT 1",
13525 base_table(table_name),
13526 clauses.join(" AND ")
13527 );
13528 self.conn
13529 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13530 .is_ok()
13531 }
13532
13533 pub fn upgrading(&self) -> bool {
13535 self.upgrading
13536 }
13537
13538 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13546 let new_schema = parse_schema_json(schema_json)?;
13547 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13548 self.conn
13549 .execute_batch("BEGIN IMMEDIATE")
13550 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13551 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13552 let prior_subs = self.subs.clone();
13558 let prior_outbox = self.outbox.clone();
13559 let prior_rejections = self.rejections.clone();
13560 let prior_upgrading = self.upgrading;
13561 let prior_stopped = self.stopped;
13562 let prior_schema_floor = self.schema_floor.clone();
13563 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13564 let prior_sync_needed = self.sync_needed;
13565 let prior_active_round = self.active_round;
13566 let prior_changes = self.change_queue.len();
13567 let prior_intents = self.sync_intent_queue.len();
13568 let prior_last_change = self.last_change.clone();
13569 let guarded = (|| -> Result<(), String> {
13570 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13575 self.create_bookkeeping_tables()?;
13576 match marker {
13577 Some(version) if version < self.schema.version => self.run_schema_reset()?,
13578 None => {
13579 self.create_synced_tables()?;
13580 self.persist_schema_version()?;
13581 }
13582 _ => {}
13583 }
13584 self.outbox = self.load_outbox()?;
13589 self.drop_unencodable_outbox(false, None)?;
13594 if marker == Some(self.schema.version)
13595 && (!self.outbox.is_empty()
13596 || !self.failed_commits.is_empty()
13597 || self.has_acknowledged_rows())
13598 {
13599 self.overlay_dirty.set(true);
13600 self.rebuild_overlay()?;
13601 }
13602 self.prune_unknown_subscriptions(false)?;
13603 self.save_subscription_scope_schema();
13604 self.enqueue_startup_sync_if_needed()?;
13608 Ok(())
13609 })();
13610 let outcome = match guarded {
13611 Ok(()) => self
13612 .conn
13613 .execute_batch("COMMIT")
13614 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13615 Err(error) => Err(error),
13616 };
13617 if let Err(error) = outcome {
13618 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13619 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13621 }
13622 self.schema = prior_schema;
13623 self.subs = prior_subs;
13624 self.outbox = prior_outbox;
13625 self.rejections = prior_rejections;
13626 self.upgrading = prior_upgrading;
13627 self.stopped = prior_stopped;
13628 self.schema_floor = prior_schema_floor;
13629 self.overlay_dirty.restore(prior_overlay_dirty);
13630 self.sync_needed = prior_sync_needed;
13631 self.active_round = prior_active_round;
13632 self.change_queue.truncate(prior_changes);
13633 self.sync_intent_queue.truncate(prior_intents);
13634 self.last_change = prior_last_change;
13635 self.insert_sql.borrow_mut().clear();
13637 return Err(error);
13638 }
13639 Ok(())
13640 }
13641
13642 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13643 let startup_work = !self.stopped
13644 && (!self.outbox.is_empty()
13645 || self.has_acknowledged_rows()
13646 || !self.load_pending_evictions()?.is_empty()
13647 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13648 if startup_work {
13649 self.sync_needed = true;
13650 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13651 }
13652 Ok(())
13653 }
13654
13655 fn clear_satisfied_persisted_schema_floor(&mut self) {
13662 let satisfied = self
13663 .schema_floor
13664 .as_ref()
13665 .and_then(|floor| floor.required_schema_version)
13666 .is_some_and(|required| self.schema.version >= required);
13667 if !satisfied {
13668 return;
13669 }
13670 self.schema_floor = None;
13671 self.stopped = false;
13672 self.delete_meta(SCHEMA_FLOOR_KEY);
13673 }
13674
13675 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13680 let Some(replica_path) = self.previous_version_replica_path() else {
13681 return Ok(());
13682 };
13683 let max_age_ms = self
13684 .previous_version
13685 .filter(|config| config.enabled)
13686 .map(|config| config.max_age_ms);
13687 reconcile_previous_version_at_boot(
13688 &self.conn,
13689 &replica_path,
13690 self.schema.version,
13691 max_age_ms,
13692 self.clock_now_ms(),
13693 )
13694 }
13695
13696 fn previous_version_replica_path(&self) -> Option<String> {
13700 let path = self.conn.path()?;
13701 if path.is_empty() || path.starts_with("file:") {
13702 return None;
13703 }
13704 Some(path.to_owned())
13705 }
13706
13707 fn run_schema_reset(&mut self) -> Result<(), String> {
13713 self.begin_observation("syncular_schema_reset")?;
13714 let prior_subs = self.subs.clone();
13715 let prior_outbox = self.outbox.clone();
13716 let prior_rejections = self.rejections.clone();
13717 let prior_upgrading = self.upgrading;
13718 let prior_stopped = self.stopped;
13719 let prior_schema_floor = self.schema_floor.clone();
13720 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13721 let prior_sync_needed = self.sync_needed;
13722 let prior_active_round = self.active_round;
13723 let mut batch = ChangeAccumulator::default();
13724 let result = self
13725 .run_schema_reset_observed(&mut batch, true)
13726 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13727 if let Err(error) = result {
13728 self.rollback_observation("syncular_schema_reset");
13729 self.subs = prior_subs;
13730 self.outbox = prior_outbox;
13731 self.rejections = prior_rejections;
13732 self.upgrading = prior_upgrading;
13733 self.stopped = prior_stopped;
13734 self.schema_floor = prior_schema_floor;
13735 self.overlay_dirty.restore(prior_overlay_dirty);
13736 self.sync_needed = prior_sync_needed;
13737 self.active_round = prior_active_round;
13738 return Err(error);
13739 }
13740 Ok(())
13741 }
13742
13743 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13744 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13745 self.begin_observation("syncular_epoch_acquisition")?;
13746 let previous_sync_needed = self.sync_needed;
13747 if let Err(error) = self.conn.execute(
13748 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13749 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13750 ) {
13751 self.rollback_observation("syncular_epoch_acquisition");
13752 return Err(Self::sqlite_failure(&self.storage_failure, error));
13753 }
13754 self.sync_needed = true;
13755 let batch = ChangeAccumulator {
13756 status: true,
13757 ..ChangeAccumulator::default()
13758 };
13759 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13760 self.rollback_observation("syncular_epoch_acquisition");
13761 self.sync_needed = previous_sync_needed;
13762 return Err(error);
13763 }
13764 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13765 return Ok(Vec::new());
13766 }
13767 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13768 self.begin_observation("syncular_log_epoch_reset")?;
13769 let prior_subs = self.subs.clone();
13770 let prior_outbox = self.outbox.clone();
13771 let prior_rejections = self.rejections.clone();
13772 let prior_upgrading = self.upgrading;
13773 let prior_stopped = self.stopped;
13774 let prior_schema_floor = self.schema_floor.clone();
13775 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13776 let prior_sync_needed = self.sync_needed;
13777 let prior_active_round = self.active_round;
13778 let mut batch = ChangeAccumulator::default();
13779 let result = self
13780 .run_schema_reset_observed(&mut batch, false)
13781 .and_then(|()| {
13782 self.conn
13783 .execute(
13784 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13785 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13786 )
13787 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13788 self.sync_needed = true;
13789 batch.status = true;
13790 self.finish_observation("syncular_log_epoch_reset", batch)
13791 });
13792 if let Err(error) = result {
13793 self.rollback_observation("syncular_log_epoch_reset");
13794 self.subs = prior_subs;
13795 self.outbox = prior_outbox;
13796 self.rejections = prior_rejections;
13797 self.upgrading = prior_upgrading;
13798 self.stopped = prior_stopped;
13799 self.schema_floor = prior_schema_floor;
13800 self.overlay_dirty.restore(prior_overlay_dirty);
13801 self.sync_needed = prior_sync_needed;
13802 self.active_round = prior_active_round;
13803 return Err(error);
13804 }
13805 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13806 Ok(resets)
13807 }
13808
13809 fn run_schema_reset_observed(
13810 &mut self,
13811 batch: &mut ChangeAccumulator,
13812 capture: bool,
13813 ) -> Result<(), String> {
13814 read_local_schema_version(&self.conn, self.schema.version)?;
13820 self.conn
13821 .execute_batch(
13822 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13823 )
13824 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13825 self.cancel_sync_round();
13826 self.upgrading = true;
13827 batch.status = true;
13828 if self.previous_version.is_some_and(|config| config.enabled) {
13836 let previous_version = self
13837 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13838 .and_then(|value| value.parse::<i32>().ok())
13839 .unwrap_or(self.schema.version);
13840 let pending = self
13841 .outbox
13842 .iter()
13843 .map(|commit| PendingCommitAudit {
13844 commit_id: &commit.client_commit_id,
13845 operations: commit_audit_operations(commit),
13846 })
13847 .collect::<Vec<_>>();
13848 crate::previous_version::write_previous_version_audit(
13849 &self.conn,
13850 &crate::previous_version::build_previous_version_audit(
13851 &self.schema,
13852 &pending,
13853 previous_version,
13854 self.schema.version,
13855 self.clock_now_ms(),
13856 ),
13857 );
13858 }
13859 if let Some(replica_path) = self.previous_version_replica_path() {
13864 sweep_previous_version_container(&self.conn, &replica_path)?;
13865 if capture {
13866 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
13867 let previous_version = self
13868 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13869 .and_then(|value| value.parse::<i32>().ok())
13870 .unwrap_or(self.schema.version);
13871 capture_previous_version_from_replica(
13872 &self.conn,
13873 &replica_path,
13874 previous_version,
13875 self.schema.version,
13876 &config,
13877 self.clock_now_ms(),
13878 )?;
13879 }
13880 }
13881 }
13882 for table in &self.schema.tables {
13883 batch.table(&table.name);
13884 }
13885 for (base_key, unit, table) in self.load_registered_window_units() {
13886 batch.window(&base_key, &table, &unit);
13887 }
13888 if capture {
13889 self.prune_unknown_subscriptions(true)?;
13890 }
13891 self.insert_sql.borrow_mut().clear();
13893 self.overlay_dirty.set(true);
13894 let virtual_tables: Vec<String> = {
13902 let mut stmt = self
13903 .conn
13904 .prepare(
13905 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
13906 )
13907 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13908 let rows = stmt
13909 .query_map([], |row| row.get::<_, String>(0))
13910 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13911 rows.filter_map(Result::ok)
13912 .filter(|name| is_synced_table_name(name))
13913 .collect()
13914 };
13915 for name in virtual_tables {
13916 self.conn
13917 .execute_batch(&format!(
13918 "DROP TABLE IF EXISTS {}",
13919 quote_ident(&format!("_syncular_fts_{name}"))
13920 ))
13921 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13922 self.conn
13923 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13924 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13925 }
13926 let existing: Vec<String> = {
13927 let mut stmt = self
13928 .conn
13929 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
13930 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13931 let rows = stmt
13932 .query_map([], |row| row.get::<_, String>(0))
13933 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13934 rows.filter_map(Result::ok)
13935 .filter(|name| is_synced_table_name(name))
13936 .collect()
13937 };
13938 for name in &existing {
13939 if let Some(table) = name.strip_prefix("_syncular_base_") {
13940 batch.table(table);
13941 } else if !name.starts_with("_syncular_") {
13942 batch.table(name);
13943 }
13944 }
13945 for name in existing {
13946 self.conn
13947 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
13948 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13949 }
13950 self.create_synced_tables()?;
13952 for sub in &mut self.subs {
13954 sub.cursor = -1;
13955 sub.bootstrap_state = None;
13956 sub.effective = None;
13957 sub.state = SubState::Active;
13958 sub.reason_code = None;
13959 sub.synced_once = false;
13960 }
13961 let subs = self.subs.clone();
13962 for sub in &subs {
13963 self.persist_sub(sub)?;
13964 }
13965 self.stopped = false;
13967 self.schema_floor = None;
13968 self.delete_meta(SCHEMA_FLOOR_KEY);
13969 self.persist_schema_version()?;
13971 self.save_subscription_scope_schema();
13972 self.drop_unencodable_outbox(false, Some(batch))?;
13983 self.rebuild_overlay()?;
13985 Ok(())
13986 }
13987
13988 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
13995 let incompatible = self
13996 .outbox
13997 .iter()
13998 .filter(|commit| {
13999 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14000 })
14001 .cloned()
14002 .collect::<Vec<_>>();
14003 if incompatible.is_empty() {
14004 return Ok(false);
14005 }
14006 let prior_outbox = self.outbox.clone();
14012 let prior_rejections = self.rejections.clone();
14013 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14014 let owns_transaction = self.conn.is_autocommit();
14015 self.begin_observation("syncular_outbox_drop")?;
14016 let mut batch = ChangeAccumulator::default();
14017 let dropped = (|| -> Result<(), String> {
14018 let mut rejections = Vec::new();
14019 for commit in &incompatible {
14020 let results = commit
14021 .ops
14022 .iter()
14023 .enumerate()
14024 .map(|(op_index, operation)| {
14025 let rejection = RejectionRecord {
14026 client_commit_id: commit.client_commit_id.clone(),
14027 op_index: op_index as i32,
14028 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14029 message: "the persisted commit cannot encode under the current schema"
14030 .to_owned(),
14031 retryable: false,
14032 details: None,
14033 operation: Some(CommitOperation::from(operation)),
14034 };
14035 rejections.push(rejection.clone());
14036 CommitOperationOutcome::Error { rejection }
14037 })
14038 .collect::<Vec<_>>();
14039 self.persist_commit_outcome(
14040 &commit.client_commit_id,
14041 CommitOutcomeStatus::Rejected,
14042 &results,
14043 Some(&commit.ops),
14044 )?;
14045 self.delete_outbox_persisted(&commit.client_commit_id)?;
14046 for operation in &commit.ops {
14049 if self.schema.table(&operation.table).is_some() {
14050 batch.table(&operation.table);
14051 }
14052 }
14053 }
14054 self.prune_commit_outcomes()?;
14055 let incompatible_ids = incompatible
14056 .iter()
14057 .map(|commit| commit.client_commit_id.as_str())
14058 .collect::<BTreeSet<_>>();
14059 self.outbox
14060 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14061 self.rejections.extend(rejections);
14062 self.overlay_dirty.set(true);
14065 self.rebuild_overlay()?;
14066 batch.status = true;
14067 batch.rejections = true;
14068 batch.outcomes = true;
14069 Ok(())
14070 })();
14071 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14072 if let Err(error) = result {
14073 if owns_transaction {
14077 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14078 let mut failure = self.storage_failure.borrow_mut();
14079 let failure =
14080 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14081 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14082 }
14083 } else {
14084 self.rollback_observation("syncular_outbox_drop");
14085 }
14086 self.outbox = prior_outbox;
14087 self.rejections = prior_rejections;
14088 self.overlay_dirty.restore(prior_overlay_dirty);
14089 return Err(error);
14090 }
14091 Ok(true)
14092 }
14093
14094 fn create_synced_tables(&self) -> Result<(), String> {
14097 for table in &self.schema.tables {
14098 for (full, index_prefix) in [
14102 (base_table(&table.name), "_syncular_base_"),
14103 (visible_table(&table.name), ""),
14104 ] {
14105 let mut cols: Vec<String> =
14106 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14107 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14108 let sql = format!(
14109 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14110 cols.join(", "),
14111 quote_ident(&table.primary_key)
14112 );
14113 self.conn
14114 .execute(&sql, [])
14115 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14116 for index in &table.indexes {
14120 let unique = if index.unique { "UNIQUE " } else { "" };
14121 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14122 let cols_sql = index
14123 .columns
14124 .iter()
14125 .map(|c| quote_ident(c))
14126 .collect::<Vec<_>>()
14127 .join(", ");
14128 let index_sql = format!(
14129 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14130 );
14131 self.conn
14132 .execute(&index_sql, [])
14133 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14134 }
14135 }
14136 }
14137 for table in &self.schema.tables {
14140 for index in &table.fts_indexes {
14141 self.create_fts_projection(table, index)?;
14142 }
14143 }
14144 Ok(())
14145 }
14146
14147 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14148 let count: i64 = self
14149 .conn
14150 .query_row(
14151 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14152 rusqlite::params![index.name],
14153 |row| row.get(0),
14154 )
14155 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14156 Ok(count > 0)
14157 }
14158
14159 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14160 for suffix in ["ai", "ad", "au"] {
14161 self.conn
14162 .execute(
14163 &format!(
14164 "DROP TRIGGER IF EXISTS {}",
14165 quote_ident(&format!("{}_{suffix}", index.name))
14166 ),
14167 [],
14168 )
14169 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14170 }
14171 Ok(())
14172 }
14173
14174 fn create_fts_triggers(
14175 &self,
14176 table: &TableSchema,
14177 index: &FtsIndexSchema,
14178 ) -> Result<(), String> {
14179 let fts = quote_ident(&index.name);
14180 let source = visible_table(&table.name);
14181 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14182 let pk = quote_ident(&table.primary_key);
14183 let projection_columns = std::iter::once(source_id.clone())
14184 .chain(index.columns.iter().map(|column| quote_ident(column)))
14185 .collect::<Vec<_>>()
14186 .join(", ");
14187 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14188 .chain(
14189 index
14190 .columns
14191 .iter()
14192 .map(|column| format!("new.{}", quote_ident(column))),
14193 )
14194 .collect::<Vec<_>>()
14195 .join(", ");
14196 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14197 let delete_new = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)");
14198 let delete_old = format!("DELETE FROM {fts} WHERE rowid = (SELECT id FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)); DELETE FROM {mapping} WHERE source_id = CAST(old.{pk} AS TEXT)");
14199 let insert_new = format!("INSERT INTO {mapping}(source_id) VALUES (CAST(new.{pk} AS TEXT)); INSERT INTO {fts} (rowid, {projection_columns}) VALUES ((SELECT id FROM {mapping} WHERE source_id = CAST(new.{pk} AS TEXT)), {new_values})");
14200 let bi = quote_ident(&format!("{}_bi", index.name));
14201 let ai = quote_ident(&format!("{}_ai", index.name));
14202 let ad = quote_ident(&format!("{}_ad", index.name));
14203 let au = quote_ident(&format!("{}_au", index.name));
14204 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14205 let sql = format!(
14206 "DROP TRIGGER IF EXISTS {bi};
14207 DROP TRIGGER IF EXISTS {ai};
14208 DROP TRIGGER IF EXISTS {ad};
14209 DROP TRIGGER IF EXISTS {au};
14210 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14211 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14212 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14213 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14214 );
14215 self.conn
14216 .execute_batch(&sql)
14217 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14218 }
14219
14220 fn rebuild_fts_projection(
14221 &self,
14222 table: &TableSchema,
14223 index: &FtsIndexSchema,
14224 ) -> Result<(), String> {
14225 let fts = quote_ident(&index.name);
14226 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14227 let pk = quote_ident(&table.primary_key);
14228 let indexed_columns = index
14229 .columns
14230 .iter()
14231 .map(|column| quote_ident(column))
14232 .collect::<Vec<_>>();
14233 let projection_columns = std::iter::once(source_id.clone())
14234 .chain(indexed_columns.iter().cloned())
14235 .collect::<Vec<_>>()
14236 .join(", ");
14237 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14238 let sql = format!(
14239 "DELETE FROM {fts}; DELETE FROM {mapping}; INSERT INTO {fts} ({projection_columns}) SELECT CAST({pk} AS TEXT), {columns} FROM {source}; INSERT INTO {mapping}(id, source_id) SELECT rowid, {source_id} FROM {fts};",
14240 columns = indexed_columns.join(", "),
14241 source = visible_table(&table.name),
14242 );
14243 self.conn
14244 .execute_batch(&sql)
14245 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14246 }
14247
14248 fn create_fts_projection(
14249 &self,
14250 table: &TableSchema,
14251 index: &FtsIndexSchema,
14252 ) -> Result<(), String> {
14253 self.begin_observation("syncular_fts_schema")?;
14254 let result = (|| {
14255 let existed = self.fts_projection_exists(index)?;
14256 let tokenizer = index.tokenize.replace('\'', "''");
14257 let columns = index
14258 .columns
14259 .iter()
14260 .map(|column| quote_ident(column))
14261 .collect::<Vec<_>>()
14262 .join(", ");
14263 let sql = format!(
14264 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14265 fts = quote_ident(&index.name),
14266 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14267 );
14268 self.conn.execute(&sql, []).map_err(|error| {
14269 format!(
14270 "cannot create local FTS5 projection {:?}: {error}",
14271 index.name
14272 )
14273 })?;
14274 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14275 let mapped: bool = self
14276 .conn
14277 .query_row(
14278 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14279 [format!("_syncular_fts_{}", index.name)],
14280 |row| row.get(0),
14281 )
14282 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14283 self.conn.execute_batch(&format!("CREATE TABLE IF NOT EXISTS {mapping}(id INTEGER PRIMARY KEY, source_id TEXT NOT NULL UNIQUE)")).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14284 if !mapped && existed {
14285 self.conn
14286 .execute_batch(&format!(
14287 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14288 quote_ident(FTS_SOURCE_ID_COLUMN),
14289 quote_ident(&index.name)
14290 ))
14291 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14292 }
14293 self.create_fts_triggers(table, index)?;
14294 if !existed {
14295 self.rebuild_fts_projection(table, index)?;
14296 }
14297 Ok(())
14298 })();
14299 if let Err(error) = result {
14300 self.rollback_observation("syncular_fts_schema");
14301 return Err(error);
14302 }
14303 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14304 self.rollback_observation("syncular_fts_schema");
14305 return Err(error.to_string());
14306 }
14307 Ok(())
14308 }
14309
14310 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14313 let state = serde_json::json!({
14314 "requested": scope_map_to_json(&sub.requested),
14315 "params": sub.params,
14316 "cursor": sub.cursor,
14317 "bootstrapState": sub.bootstrap_state,
14318 "status": sub.state.name(),
14319 "reasonCode": sub.reason_code,
14320 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14321 "syncedOnce": sub.synced_once,
14322 });
14323 self.conn.execute(
14324 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14325 rusqlite::params![sub.id, sub.table, state.to_string()],
14326 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14327 }
14328
14329 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14330 let ops: Vec<Value> = commit
14331 .ops
14332 .iter()
14333 .map(|op| {
14334 serde_json::json!({
14335 "op": if op.upsert { "upsert" } else { "delete" },
14336 "table": op.table,
14337 "rowId": op.row_id,
14338 "baseVersion": op.base_version,
14339 "values": op.values.clone().map(Value::Object),
14340 })
14341 })
14342 .collect();
14343 self.conn
14344 .execute(
14345 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14346 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14347 )
14348 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14349 for operation in &commit.ops {
14350 let Some(values) = &operation.values else {
14351 continue;
14352 };
14353 let Some(table) = self.schema.table(&operation.table) else {
14354 continue;
14355 };
14356 for column in table
14357 .columns
14358 .iter()
14359 .filter(|column| column.ty == ColumnType::BlobRef)
14360 {
14361 let Some(Value::String(raw)) = values.get(&column.name) else {
14362 continue;
14363 };
14364 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14365 continue;
14366 };
14367 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14368 continue;
14369 };
14370 self.conn
14371 .execute(
14372 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14373 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14374 rusqlite::params![commit.client_commit_id, blob_id],
14375 )
14376 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14377 }
14378 }
14379 Ok(())
14380 }
14381
14382 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14383 if let Some(commit) = self
14384 .outbox
14385 .iter()
14386 .find(|commit| commit.client_commit_id == client_commit_id)
14387 {
14388 for operation in &commit.ops {
14389 self.overlay_dirty.table(&operation.table);
14390 }
14391 }
14392
14393 self.conn
14394 .execute(
14395 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14396 rusqlite::params![client_commit_id],
14397 )
14398 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14399 self.conn
14400 .execute(
14401 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14402 rusqlite::params![client_commit_id],
14403 )
14404 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14405 self.conn.execute("DELETE FROM _syncular_row_deliveries WHERE NOT EXISTS(SELECT 1 FROM _syncular_outbox,json_each(ops_json) WHERE json_extract(json_each.value,'$.table')=tbl AND json_extract(json_each.value,'$.rowId')=id)", [])
14406 .map(|_| ())
14407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14408 }
14409
14410 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14411 match status {
14412 CommitOutcomeStatus::Applied => "applied",
14413 CommitOutcomeStatus::Cached => "cached",
14414 CommitOutcomeStatus::Conflict => "conflict",
14415 CommitOutcomeStatus::Rejected => "rejected",
14416 }
14417 }
14418
14419 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14420 match resolution {
14421 CommitOutcomeResolution::Active => "active",
14422 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14423 CommitOutcomeResolution::Superseded => "superseded",
14424 CommitOutcomeResolution::Dismissed => "dismissed",
14425 }
14426 }
14427
14428 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14429 match value {
14430 "applied" => Ok(CommitOutcomeStatus::Applied),
14431 "cached" => Ok(CommitOutcomeStatus::Cached),
14432 "conflict" => Ok(CommitOutcomeStatus::Conflict),
14433 "rejected" => Ok(CommitOutcomeStatus::Rejected),
14434 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14435 }
14436 }
14437
14438 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14439 match value {
14440 "active" => Ok(CommitOutcomeResolution::Active),
14441 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14442 "superseded" => Ok(CommitOutcomeResolution::Superseded),
14443 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14444 _ => Err(format!(
14445 "invalid persisted commit outcome resolution {value:?}"
14446 )),
14447 }
14448 }
14449
14450 fn persist_commit_outcome(
14451 &self,
14452 client_commit_id: &str,
14453 status: CommitOutcomeStatus,
14454 results: &[CommitOperationOutcome],
14455 operations: Option<&[OutboxOp]>,
14456 ) -> Result<(), String> {
14457 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14458 let operations_json = operations
14459 .map(|items| {
14460 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14461 })
14462 .transpose()
14463 .map_err(|error| error.to_string())?;
14464 self.conn
14465 .execute(
14466 "INSERT INTO _syncular_commit_outcomes (
14467 client_commit_id, status, recorded_at_ms, results_json,
14468 operations_json, resolution
14469 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14470 rusqlite::params![
14471 client_commit_id,
14472 Self::outcome_status_name(status),
14473 self.clock_now_ms(),
14474 results_json,
14475 operations_json
14476 ],
14477 )
14478 .map(|_| ())
14479 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14480 }
14481
14482 fn stored_cell_is_representable(cell: &Value) -> bool {
14485 cell.is_null()
14486 || cell.is_string()
14487 || cell.is_boolean()
14488 || cell.is_number()
14489 || is_canonical_bytes_envelope(cell)
14490 }
14491
14492 fn stored_row_is_representable(value: &Value) -> bool {
14493 value
14494 .as_object()
14495 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14496 }
14497
14498 fn stored_operation_is_representable(value: &Value) -> bool {
14499 value.get("table").is_some_and(Value::is_string)
14500 && value.get("rowId").is_some_and(Value::is_string)
14501 && value.get("baseVersion").is_none_or(|v| {
14502 v.as_i64()
14503 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14504 })
14505 && match value.get("op").and_then(Value::as_str) {
14506 Some("upsert") => value
14507 .get("values")
14508 .is_some_and(Self::stored_row_is_representable),
14509 Some("delete") => value.get("values").is_none(),
14510 _ => false,
14511 }
14512 }
14513
14514 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14518 serde_json::to_value(CommitOperation::from(operation))
14519 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14520 }
14521
14522 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14523 let StoredCommitOutcomeRow {
14524 sequence,
14525 client_commit_id,
14526 status,
14527 recorded_at_ms,
14528 results_json,
14529 operations_json,
14530 resolution,
14531 resolved_at_ms,
14532 replacement_client_commit_id,
14533 } = row;
14534 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14535 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14536 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14537 {
14538 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14539 }
14540 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14541 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14542 for result in &stored {
14543 let nested = match result.get("status").and_then(Value::as_str) {
14544 Some("conflict") => result.get("conflict"),
14545 Some("error") => result.get("rejection"),
14546 _ => None,
14547 };
14548 if nested
14549 .and_then(|record| record.get("operation"))
14550 .is_some_and(|op| !Self::stored_operation_is_representable(op))
14551 || (result.get("status").and_then(Value::as_str) == Some("conflict")
14552 && nested
14553 .and_then(|record| record.get("serverRow"))
14554 .is_some_and(|row| !Self::stored_row_is_representable(row)))
14555 {
14556 return Err(invalid());
14557 }
14558 }
14559 let results: Vec<CommitOperationOutcome> =
14560 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14561 let has_conflict = results
14562 .iter()
14563 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14564 let has_error = results
14565 .iter()
14566 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14567 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14568 let redacted = results.is_empty() && operations_json.is_none();
14569 if match status {
14570 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14571 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14572 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14573 } {
14574 return Err(invalid());
14575 }
14576 let operations: Option<Vec<CommitOperation>> = operations_json
14577 .map(|raw| {
14578 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14579 if !stored.iter().all(Self::stored_operation_is_representable) {
14580 return Err(invalid());
14581 }
14582 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14583 })
14584 .transpose()?;
14585 for result in &results {
14586 match result {
14587 CommitOperationOutcome::Conflict { conflict }
14588 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14589 {
14590 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14591 }
14592 CommitOperationOutcome::Error { rejection } => {
14593 if let Some(details) = &rejection.details {
14594 let encoded = serde_json::to_string(details).map_err(|_| {
14595 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14596 })?;
14597 RejectionDetails::parse(&encoded).map_err(|_| {
14598 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14599 })?;
14600 }
14601 }
14602 _ => {}
14603 }
14604 }
14605 Ok(CommitOutcome {
14606 sequence,
14607 client_commit_id,
14608 status,
14609 recorded_at_ms,
14610 results,
14611 operations,
14612 retained_rows: None,
14613 resolution: Self::parse_outcome_resolution(&resolution)?,
14614 resolved_at_ms,
14615 replacement_client_commit_id,
14616 })
14617 }
14618
14619 fn unique_conflicts(
14621 &self,
14622 table: &TableSchema,
14623 values: &Map<String, Value>,
14624 base: bool,
14625 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14626 let source = if base {
14627 base_table(&table.name)
14628 } else {
14629 visible_table(&table.name)
14630 };
14631 let mut conflicts = Vec::new();
14632 for index in table.indexes.iter().filter(|index| index.unique) {
14633 let mut params = vec![json_param_to_sql(
14634 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14635 )?];
14636 for name in &index.columns {
14637 let column = table
14638 .columns
14639 .iter()
14640 .find(|column| column.name == *name)
14641 .ok_or("sync.local_corrupt")?;
14642 let cell = json_to_column_value(column, values.get(name))?;
14643 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14644 }
14645 let sql = format!(
14646 "SELECT * FROM {source} WHERE {} != ? AND {}",
14647 quote_ident(&table.primary_key),
14648 index
14649 .columns
14650 .iter()
14651 .map(|column| format!("{} = ?", quote_ident(column)))
14652 .collect::<Vec<_>>()
14653 .join(" AND ")
14654 );
14655 let mut statement = self
14656 .conn
14657 .prepare_cached(&sql)
14658 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14659 let records = statement
14660 .query_map(rusqlite::params_from_iter(params), |record| {
14661 let values = table
14662 .columns
14663 .iter()
14664 .enumerate()
14665 .map(|(index, column)| {
14666 let value = sql_ref_to_json(column, record.get_ref(index)?);
14667 json_to_column_value(column, Some(&value)).map_err(|message| {
14668 rusqlite::Error::ToSqlConversionFailure(message.into())
14669 })
14670 })
14671 .collect::<Result<Row, _>>()?;
14672 Ok((values, record.get::<_, i64>(table.columns.len())?))
14673 })
14674 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14675 for record in records {
14676 let (row, version) =
14677 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14678 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14679 conflicts.push(RetainedUniqueConflict {
14680 index: index.name.clone(),
14681 columns: index.columns.clone(),
14682 row_id,
14683 server_row: table
14684 .columns
14685 .iter()
14686 .zip(&row)
14687 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14688 .collect(),
14689 server_version: version,
14690 });
14691 }
14692 }
14693 Ok(conflicts)
14694 }
14695
14696 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14697 if let Some((commit, initial)) = self
14698 .failed_commits
14699 .iter()
14700 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14701 {
14702 let mut rows = Vec::new();
14703 for operation in &commit.ops {
14704 let table = self
14705 .schema
14706 .table(&operation.table)
14707 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14708 let key =
14709 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14710 .map_err(|_| "sync.local_corrupt".to_owned())?;
14711 let base = self.stored_row(table, &operation.row_id, true)?;
14712 rows.push(RetainedCommitRow {
14713 table: operation.table.clone(),
14714 row_id: operation.row_id.clone(),
14715 unique_conflicts: if operation.upsert {
14716 self.unique_conflicts(
14717 table,
14718 initial
14719 .get(&key)
14720 .and_then(Value::as_object)
14721 .or(operation.values.as_ref())
14722 .ok_or("sync.local_corrupt")?,
14723 true,
14724 )?
14725 } else {
14726 Vec::new()
14727 },
14728 local_row: if operation.upsert {
14729 initial
14730 .get(&key)
14731 .and_then(Value::as_object)
14732 .cloned()
14733 .or_else(|| operation.values.clone())
14734 } else {
14735 None
14736 },
14737 server_row: base.as_ref().map(|(values, _)| {
14738 table
14739 .columns
14740 .iter()
14741 .zip(values)
14742 .map(|(column, value)| {
14743 (column.name.clone(), column_value_to_json(value))
14744 })
14745 .collect()
14746 }),
14747 server_version: base.map(|(_, version)| version),
14748 });
14749 }
14750 outcome.retained_rows = Some(rows);
14751 }
14752 Ok(outcome)
14753 }
14754
14755 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14756 let row = self
14757 .conn
14758 .query_row(
14759 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14760 resolution, resolved_at_ms, replacement_client_commit_id
14761 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14762 rusqlite::params![client_commit_id],
14763 |row| {
14764 Ok(StoredCommitOutcomeRow {
14765 sequence: row.get(0)?,
14766 client_commit_id: row.get(1)?,
14767 status: row.get(2)?,
14768 recorded_at_ms: row.get(3)?,
14769 results_json: row.get(4)?,
14770 operations_json: row.get(5)?,
14771 resolution: row.get(6)?,
14772 resolved_at_ms: row.get(7)?,
14773 replacement_client_commit_id: row.get(8)?,
14774 })
14775 },
14776 )
14777 .optional()
14778 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14779 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14780 .transpose()
14781 }
14782
14783 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14784 if query.limit == Some(0) {
14785 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14786 }
14787 let mut sql = String::from(
14788 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14789 resolution, resolved_at_ms, replacement_client_commit_id
14790 FROM _syncular_commit_outcomes",
14791 );
14792 if query.active_only {
14793 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14794 }
14795 sql.push_str(" ORDER BY seq DESC");
14796 if let Some(limit) = query.limit {
14797 sql.push_str(&format!(" LIMIT {limit}"));
14798 }
14799 let mut stmt = self
14800 .conn
14801 .prepare(&sql)
14802 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14803 let rows = stmt
14804 .query_map([], |row| {
14805 Ok(StoredCommitOutcomeRow {
14806 sequence: row.get(0)?,
14807 client_commit_id: row.get(1)?,
14808 status: row.get(2)?,
14809 recorded_at_ms: row.get(3)?,
14810 results_json: row.get(4)?,
14811 operations_json: row.get(5)?,
14812 resolution: row.get(6)?,
14813 resolved_at_ms: row.get(7)?,
14814 replacement_client_commit_id: row.get(8)?,
14815 })
14816 })
14817 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14818 let mut outcomes = Vec::new();
14819 for row in rows {
14820 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14821 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14822 )?)?);
14823 }
14824 Ok(outcomes)
14825 }
14826
14827 fn prune_commit_outcomes(&self) -> Result<(), String> {
14828 #[cfg(test)]
14829 self.outcome_prune_count
14830 .set(self.outcome_prune_count.get() + 1);
14831 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14832 let count = self
14833 .conn
14834 .query_row(
14835 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14836 [],
14837 |row| row.get::<_, i64>(0),
14838 )
14839 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14840 as usize;
14841 let excess = count.saturating_sub(max_entries);
14842 if excess == 0 {
14843 return Ok(());
14844 }
14845 let mut stmt = self
14846 .conn
14847 .prepare(
14848 "SELECT seq FROM _syncular_commit_outcomes
14849 WHERE status IN ('applied', 'cached') OR resolution != 'active'
14850 ORDER BY seq ASC LIMIT ?1",
14851 )
14852 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14853 let rows = stmt
14854 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
14855 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14856 let sequences = rows
14857 .collect::<Result<Vec<_>, _>>()
14858 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14859 drop(stmt);
14860 for sequence in sequences {
14861 self.conn
14862 .execute(
14863 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
14864 rusqlite::params![sequence],
14865 )
14866 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14867 }
14868 Ok(())
14869 }
14870
14871 pub fn subscribe(
14874 &mut self,
14875 id: String,
14876 table: String,
14877 scopes: Vec<(String, Vec<String>)>,
14878 params: Option<String>,
14879 ) -> Result<(), String> {
14880 if self.schema.table(&table).is_none() {
14881 return Err(format!("unknown table {table:?}"));
14882 }
14883 let registered_at = self
14884 .subs
14885 .iter()
14886 .position(|subscription| subscription.id == id);
14887 if let Some(index) = registered_at {
14888 let existing = &self.subs[index];
14889 let same_intent = existing.table == table
14890 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
14891 && existing.params == params;
14892 if !same_intent {
14893 return Err(
14894 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
14895 .to_owned(),
14896 );
14897 }
14898 if existing.state != SubState::Revoked {
14903 return Ok(());
14904 }
14905 }
14906 let sub = Subscription {
14907 id: id.clone(),
14908 table,
14909 requested: scopes,
14910 params,
14911 cursor: -1,
14912 bootstrap_state: None,
14913 state: SubState::Active,
14914 reason_code: None,
14915 effective: None,
14916 synced_once: false,
14917 };
14918 self.persist_sub(&sub)?;
14919 match registered_at {
14920 Some(index) => self.subs[index] = sub,
14921 None => self.subs.push(sub),
14922 }
14923 if registered_at.is_some() {
14927 self.set_sync_needed(true, true);
14928 }
14929 Ok(())
14930 }
14931
14932 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
14938 let prior_subs = self.subs.clone();
14939 self.begin_observation("syncular_subscribe")?;
14940 let registered = (|| {
14941 for request in requests {
14942 self.subscribe(request.id, request.table, request.scopes, request.params)?;
14943 }
14944 self.conn
14945 .execute_batch("RELEASE syncular_subscribe")
14946 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14947 })();
14948 if let Err(error) = registered {
14949 self.rollback_observation("syncular_subscribe");
14950 self.subs = prior_subs;
14951 return Err(error);
14952 }
14953 Ok(())
14954 }
14955
14956 pub fn unsubscribe(&mut self, id: &str) {
14957 if self.subs.iter().any(|s| s.id == id) {
14958 self.cancel_sync_round();
14959 }
14960 self.subs.retain(|s| s.id != id);
14961 let _ = self.conn.execute(
14962 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
14963 rusqlite::params![id],
14964 );
14965 }
14966
14967 pub fn set_window(
14975 &mut self,
14976 base: &WindowBase,
14977 units: &[String],
14978 ) -> Result<CommandEffects, String> {
14979 let table = self
14980 .schema
14981 .table(&base.table)
14982 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
14983 if table.scope_column(&base.variable).is_none() {
14984 return Err(format!(
14985 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
14986 base.table, base.variable
14987 ));
14988 }
14989 let base_key = window_base_key(base);
14990 let wanted: std::collections::HashSet<&String> = units.iter().collect();
14991 let live = self.load_window_units(&base_key);
14992 self.begin_observation("syncular_window")?;
14993 let mut batch = ChangeAccumulator::default();
14994 let mut changed = false;
14995
14996 let prior_subs = self.subs.clone();
14997 let widened = (|| {
14998 for unit in units {
15000 if live.iter().any(|(u, _)| u == unit) {
15001 continue;
15002 }
15003 let sub_id = derive_sub_id(base, unit);
15004 self.delete_pending_evict(&sub_id)?;
15005 self.insert_window_unit(&base_key, unit, &sub_id)?;
15006 self.subscribe(
15007 sub_id,
15008 base.table.clone(),
15009 unit_scopes(base, unit),
15010 base.params.clone(),
15011 )?;
15012 batch.window(&base_key, &base.table, unit);
15013 changed = true;
15014 }
15015
15016 self.finish_observation("syncular_window", batch)
15017 })();
15018 if let Err(error) = widened {
15019 self.rollback_observation("syncular_window");
15020 self.subs = prior_subs;
15021 return Err(error);
15022 }
15023 for (unit, sub_id) in live {
15024 if wanted.contains(&unit) {
15025 continue;
15026 }
15027 self.rebuild_overlay_if_dirty()?;
15028 let prior_subs = self.subs.clone();
15029 self.begin_observation("syncular_window")?;
15030 let effective = self
15031 .subs
15032 .iter()
15033 .find(|sub| sub.id == sub_id)
15034 .and_then(|sub| sub.effective.clone())
15035 .unwrap_or_else(|| unit_scopes(base, &unit));
15036 let mut batch = ChangeAccumulator::default();
15037 self.record_scope_map(&mut batch, &base.table, &effective);
15038 batch.window(&base_key, &base.table, &unit);
15039 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15040 let remaining = match result {
15041 Ok(remaining) => remaining,
15042 Err(error) => {
15043 self.rollback_observation("syncular_window");
15044 self.subs = prior_subs;
15045 return Err(error);
15046 }
15047 };
15048 if let Err(error) = self.finish_observation("syncular_window", batch) {
15049 self.rollback_observation("syncular_window");
15050 self.subs = prior_subs;
15051 return Err(error);
15052 }
15053 if remaining {
15054 self.drain_pending_evictions()?;
15055 }
15056 changed = true;
15057 }
15058 Ok(if changed {
15059 CommandEffects::interactive()
15060 } else {
15061 CommandEffects::none()
15062 })
15063 }
15064
15065 pub fn window_state(&self, base: &WindowBase) -> WindowState {
15070 let mut units = Vec::new();
15071 let mut pending = Vec::new();
15072 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15073 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15074 Some(sub) => {
15075 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15076 }
15077 None => true,
15078 };
15079 if is_pending {
15080 pending.push(unit.clone());
15081 }
15082 units.push(unit);
15083 }
15084 WindowState { units, pending }
15085 }
15086
15087 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15088 let mut stmt = match self
15089 .conn
15090 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15091 {
15092 Ok(stmt) => stmt,
15093 Err(_) => return Vec::new(),
15094 };
15095 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15096 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15097 });
15098 match rows {
15099 Ok(rows) => rows.filter_map(Result::ok).collect(),
15100 Err(_) => Vec::new(),
15101 }
15102 }
15103
15104 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15105 let mut stmt = match self.conn.prepare(
15106 "SELECT windows.base, windows.unit, subscriptions.tbl
15107 FROM _syncular_windows AS windows
15108 JOIN _syncular_subscriptions AS subscriptions
15109 ON subscriptions.id = windows.sub_id
15110 ORDER BY windows.base, windows.unit",
15111 ) {
15112 Ok(stmt) => stmt,
15113 Err(_) => return Vec::new(),
15114 };
15115 let rows = stmt.query_map([], |row| {
15116 Ok((
15117 row.get::<_, String>(0)?,
15118 row.get::<_, String>(1)?,
15119 row.get::<_, String>(2)?,
15120 ))
15121 });
15122 match rows {
15123 Ok(rows) => rows.filter_map(Result::ok).collect(),
15124 Err(_) => Vec::new(),
15125 }
15126 }
15127
15128 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15129 self.conn
15130 .query_row(
15131 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15132 rusqlite::params![sub_id],
15133 |row| Ok((row.get(0)?, row.get(1)?)),
15134 )
15135 .ok()
15136 }
15137
15138 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15139 self.conn
15140 .execute(
15141 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15142 rusqlite::params![base_key, unit, sub_id],
15143 )
15144 .map(|_| ())
15145 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15146 }
15147
15148 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15149 self.conn
15150 .execute(
15151 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15152 rusqlite::params![base_key, unit],
15153 )
15154 .map(|_| ())
15155 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15156 }
15157
15158 fn evict_unit(
15165 &mut self,
15166 base_key: &str,
15167 base: &WindowBase,
15168 unit: &str,
15169 sub_id: &str,
15170 ) -> Result<bool, String> {
15171 let effective = self
15172 .subs
15173 .iter()
15174 .find(|s| s.id == sub_id)
15175 .and_then(|s| s.effective.clone())
15176 .unwrap_or_else(|| unit_scopes(base, unit));
15177 let pinned = self.pinned_row_ids(&base.table);
15178 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15179 self.delete_window_unit(base_key, unit)?;
15180 self.conn
15181 .execute(
15182 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15183 [sub_id],
15184 )
15185 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15186 self.subs.retain(|sub| sub.id != sub_id);
15187 if deferred {
15188 self.save_pending_evict(sub_id, &base.table, &effective)?;
15189 } else {
15190 self.delete_pending_evict(sub_id)?;
15191 }
15192 Ok(remaining)
15193 }
15194
15195 fn evict_scope_rows(
15199 &self,
15200 table_name: &str,
15201 effective: &[(String, Vec<String>)],
15202 pinned: &std::collections::HashSet<String>,
15203 ) -> Result<(bool, bool), String> {
15204 if effective.is_empty() {
15205 return Ok((false, false));
15206 }
15207 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15208 let mut clauses = Vec::new();
15209 let mut params: Vec<SqlValue> = Vec::new();
15210 for (variable, values) in effective {
15211 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15212 if values.is_empty() {
15213 return Ok((false, false));
15214 }
15215 let holes = values
15216 .iter()
15217 .map(|v| {
15218 params.push(SqlValue::Text(v.clone()));
15219 "?"
15220 })
15221 .collect::<Vec<_>>()
15222 .join(", ");
15223 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15224 }
15225 let scope = clauses.join(" AND ");
15226 let scope_params = params.len();
15227 let pk = quote_ident(&table.primary_key);
15228 if !pinned.is_empty() {
15229 let holes = pinned
15230 .iter()
15231 .map(|id| {
15232 params.push(SqlValue::Text(id.clone()));
15233 "?"
15234 })
15235 .collect::<Vec<_>>()
15236 .join(", ");
15237 clauses.push(format!("{pk} NOT IN ({holes})"));
15238 }
15239 let predicate = clauses.join(" AND ");
15240 let base = base_table(table_name);
15241 let visible = visible_table(table_name);
15242 let mut ids: Vec<SqlValue> = Vec::new();
15243 for source in [&base, &visible] {
15244 let mut stmt = self
15245 .conn
15246 .prepare_cached(&format!(
15247 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15248 ))
15249 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15250 ids = stmt
15251 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
15252 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15253 .collect::<Result<_, _>>()
15254 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15255 if !ids.is_empty() {
15256 break;
15257 }
15258 }
15259 if !ids.is_empty() {
15260 for id in &ids {
15261 self.conn
15262 .execute(
15263 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15264 rusqlite::params![table_name, id],
15265 )
15266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15267 }
15268 let holes = vec!["?"; ids.len()].join(", ");
15269 for source in [&base, &visible] {
15270 self.conn
15271 .execute(
15272 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15273 rusqlite::params_from_iter(&ids),
15274 )
15275 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15276 }
15277 }
15278 let mut remaining = false;
15279 let mut deferred = false;
15280 for source in [&base, &visible] {
15281 remaining |= self
15282 .conn
15283 .query_row(
15284 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15285 rusqlite::params_from_iter(¶ms),
15286 |row| row.get::<_, bool>(0),
15287 )
15288 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15289 deferred |= self
15290 .conn
15291 .query_row(
15292 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15293 rusqlite::params_from_iter(¶ms[..scope_params]),
15294 |row| row.get::<_, bool>(0),
15295 )
15296 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15297 }
15298 Ok((remaining, deferred))
15299 }
15300
15301 fn drain_pending_evictions(&mut self) -> Result<(), String> {
15303 let pending = self.load_pending_evictions()?;
15304 if pending.is_empty() {
15305 return Ok(());
15306 }
15307 self.rebuild_overlay_if_dirty()?;
15308 for (sub_id, table_name, effective) in pending {
15309 if self.schema.table(&table_name).is_none() {
15310 self.delete_pending_evict(&sub_id)?;
15311 continue;
15312 }
15313 loop {
15314 std::thread::yield_now();
15315 self.begin_observation("syncular_evict")?;
15316 let pinned = self.pinned_row_ids(&table_name);
15317 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15318 let (remaining, deferred) = match result {
15319 Ok(result) => result,
15320 Err(error) => {
15321 self.rollback_observation("syncular_evict");
15322 return Err(error);
15323 }
15324 };
15325 if !deferred {
15326 if let Err(error) = self.delete_pending_evict(&sub_id) {
15327 self.rollback_observation("syncular_evict");
15328 return Err(error);
15329 }
15330 }
15331 let mut batch = ChangeAccumulator::default();
15332 self.record_scope_map(&mut batch, &table_name, &effective);
15333 if let Err(error) = self.finish_observation("syncular_evict", batch) {
15334 self.rollback_observation("syncular_evict");
15335 return Err(error);
15336 }
15337 if !remaining {
15338 break;
15339 }
15340 }
15341 }
15342 Ok(())
15343 }
15344
15345 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15348 let mut pinned = std::collections::HashSet::new();
15349 for commit in self
15350 .outbox
15351 .iter()
15352 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15353 {
15354 for op in &commit.ops {
15355 if op.table == table {
15356 pinned.insert(op.row_id.clone());
15357 }
15358 }
15359 }
15360 pinned
15361 }
15362
15363 fn save_pending_evict(
15364 &self,
15365 sub_id: &str,
15366 table: &str,
15367 effective: &[(String, Vec<String>)],
15368 ) -> Result<(), String> {
15369 self.conn.execute(
15370 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15371 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15372 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15373 }
15374
15375 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15376 self.conn
15377 .execute(
15378 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15379 rusqlite::params![sub_id],
15380 )
15381 .map(|_| ())
15382 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15383 }
15384
15385 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15386 let mut stmt = self
15387 .conn
15388 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15389 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15390 let rows = stmt
15391 .query_map([], |row| {
15392 Ok((
15393 row.get::<_, String>(0)?,
15394 row.get::<_, String>(1)?,
15395 row.get::<_, String>(2)?,
15396 ))
15397 })
15398 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15399 rows.map(|row| {
15400 let (sub_id, table, json) =
15401 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15402 let value: Value = serde_json::from_str(&json)
15403 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15404 let effective = json_to_scope_map(&value)
15405 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15406 Ok((sub_id, table, effective))
15407 })
15408 .collect()
15409 }
15410
15411 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15413 self.retain_failed_commits = enabled;
15414 for (commit, _) in &self.failed_commits {
15415 for operation in &commit.ops {
15416 self.overlay_dirty.table(&operation.table);
15417 }
15418 }
15419 self.failed_commits = self.load_failed_commits()?;
15420 for (commit, _) in &self.failed_commits {
15421 for operation in &commit.ops {
15422 self.overlay_dirty.table(&operation.table);
15423 }
15424 }
15425 Ok(())
15426 }
15427
15428 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15429 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15430 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15431 let rows = statement
15432 .query_map([], |row| {
15433 Ok((
15434 row.get::<_, String>(0)?,
15435 row.get::<_, String>(1)?,
15436 row.get::<_, String>(2)?,
15437 ))
15438 })
15439 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15440 rows.map(|row| {
15441 let (id, operations, initial) =
15442 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15443 let operations: Vec<CommitOperation> =
15444 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15445 let initial =
15446 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15447 Ok((
15448 OutboxCommit {
15449 client_commit_id: id,
15450 ops: operations
15451 .into_iter()
15452 .map(|op| OutboxOp {
15453 upsert: op.op == "upsert",
15454 table: op.table,
15455 row_id: op.row_id,
15456 base_version: op.base_version,
15457 values: op.values,
15458 })
15459 .collect(),
15460 },
15461 initial,
15462 ))
15463 })
15464 .collect()
15465 }
15466
15467 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15468 let mut initial = Map::new();
15469 for operation in operations {
15470 let table = self
15471 .schema
15472 .table(&operation.table)
15473 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15474 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15475 Some(row) => Some(row),
15476 None => self.visible_row(table, &operation.row_id)?,
15477 };
15478 if let Some((values, _)) = initial_row {
15479 let mut intended: Map<String, Value> = table
15480 .columns
15481 .iter()
15482 .zip(values.iter())
15483 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15484 .collect();
15485 if let Some(patch) = &operation.values {
15486 intended.extend(patch.clone());
15487 }
15488 initial.insert(
15489 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15490 .map_err(|_| "sync.local_corrupt".to_owned())?,
15491 Value::Object(intended),
15492 );
15493 }
15494 }
15495 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15496 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15497 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15498 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15499 self.failed_commits.push((
15500 OutboxCommit {
15501 client_commit_id: id.to_owned(),
15502 ops: operations.to_vec(),
15503 },
15504 initial,
15505 ));
15506 Ok(())
15507 }
15508
15509 fn drop_failed_commits(
15510 &mut self,
15511 matches: impl Fn(&str, &Map<String, Value>) -> bool,
15512 ) -> Result<Vec<OutboxCommit>, String> {
15513 let mut doomed = self.drop_acknowledged_rows(&matches)?;
15514 for (commit, initial) in &self.failed_commits {
15515 for operation in &commit.ops {
15516 let key =
15517 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15518 .map_err(|_| "sync.local_corrupt".to_owned())?;
15519 if initial
15520 .get(&key)
15521 .and_then(Value::as_object)
15522 .is_some_and(|values| matches(&operation.table, values))
15523 {
15524 doomed.push(commit.clone());
15525 break;
15526 }
15527 }
15528 }
15529 for commit in &doomed {
15530 self.conn
15531 .execute(
15532 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15533 rusqlite::params![commit.client_commit_id],
15534 )
15535 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15536 }
15537 for commit in &doomed {
15538 self.conn.execute("UPDATE _syncular_commit_outcomes SET operations_json = NULL, results_json = '[]' WHERE client_commit_id = ?1", rusqlite::params![commit.client_commit_id]).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15539 }
15540 self.conflicts.retain(|record| {
15541 !doomed
15542 .iter()
15543 .any(|commit| commit.client_commit_id == record.client_commit_id)
15544 });
15545 self.rejections.retain(|record| {
15546 !doomed
15547 .iter()
15548 .any(|commit| commit.client_commit_id == record.client_commit_id)
15549 });
15550 self.failed_commits.retain(|(commit, _)| {
15551 !doomed
15552 .iter()
15553 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15554 });
15555 for commit in &doomed {
15556 for operation in &commit.ops {
15557 self.overlay_dirty.table(&operation.table);
15558 }
15559 }
15560 Ok(doomed)
15561 }
15562
15563 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15567 let prior = self.storage_failure.borrow_mut().take();
15568 let outcome = (|| -> Result<String, String> {
15569 if mutations.is_empty() {
15570 return Err(
15571 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15572 .to_owned(),
15573 );
15574 }
15575 let mut ops = Vec::with_capacity(mutations.len());
15576 for mutation in mutations {
15577 match mutation {
15578 Mutation::Patch {
15579 table,
15580 values,
15581 base_version,
15582 } => {
15583 let schema_table = self
15584 .schema
15585 .table(&table)
15586 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15587 let values = normalize_values_casing(schema_table, values)?;
15588 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15589 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15590 }
15591 Mutation::Upsert {
15592 table,
15593 values,
15594 base_version,
15595 } => {
15596 let schema_table = self.schema.table(&table).ok_or_else(|| {
15597 format!("sync.unknown_table: unknown table {table:?}")
15598 })?;
15599 let values = normalize_values_casing(schema_table, values)?;
15603 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15604 let values = full_row_values(schema_table, values)?;
15607 self.validate_author_encode(schema_table, &row_id, &values)?;
15613 ops.push(OutboxOp {
15614 upsert: true,
15615 table,
15616 row_id,
15617 base_version,
15618 values: Some(values),
15619 });
15620 }
15621 Mutation::Delete {
15622 table,
15623 row_id,
15624 base_version,
15625 } => {
15626 if self.schema.table(&table).is_none() {
15627 return Err(format!("sync.unknown_table: unknown table {table:?}"));
15628 }
15629 ops.push(OutboxOp {
15630 upsert: false,
15631 table,
15632 row_id,
15633 base_version,
15634 values: None,
15635 });
15636 }
15637 }
15638 }
15639 self.record_outbox_commit(ops)
15640 })();
15641 self.finish_authoring(prior, outcome)
15642 }
15643
15644 fn finish_authoring(
15650 &self,
15651 prior: Option<QueryReadFailure>,
15652 outcome: Result<String, String>,
15653 ) -> Result<String, ClientError> {
15654 let fresh = self.storage_failure.borrow_mut().take();
15655 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15656 match outcome {
15657 Ok(id) => Ok(id),
15658 Err(message) => Err(fresh
15659 .map(ClientError::from)
15660 .unwrap_or_else(|| ClientError::from(message))),
15661 }
15662 }
15663
15664 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15665 let commit = OutboxCommit {
15666 client_commit_id: uuid::Uuid::new_v4().to_string(),
15667 ops,
15668 };
15669 self.begin_observation("syncular_mutation")?;
15670 let mut batch = ChangeAccumulator::default();
15671 for op in &commit.ops {
15672 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15673 if let Some(values) = &op.values {
15674 if let Some(table) = self.schema.table(&op.table) {
15675 for scope in &table.scope_variables {
15676 if let Some(Value::String(value)) = values.get(&scope.column) {
15677 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15678 precise = true;
15679 }
15680 }
15681 }
15682 }
15683 if !precise {
15684 batch.table(&op.table);
15685 }
15686 }
15687 if let Err(error) = self.persist_outbox_insert(&commit) {
15688 self.rollback_observation("syncular_mutation");
15689 return Err(error);
15690 }
15691 let was_dirty = self.overlay_dirty.snapshot();
15692 if let Err(error) = self.rebuild_overlay_if_dirty() {
15697 self.rollback_observation("syncular_mutation");
15698 self.overlay_dirty.restore(was_dirty.clone());
15699 return Err(error);
15700 }
15701 if let Some(error) = commit
15702 .ops
15703 .iter()
15704 .find_map(|op| self.apply_outbox_op(op).err())
15705 {
15706 self.rollback_observation("syncular_mutation");
15707 self.overlay_dirty.restore(was_dirty.clone());
15708 return Err(error);
15709 }
15710 let id = commit.client_commit_id.clone();
15711 self.outbox.push(commit);
15712 batch.status = true;
15713 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15714 self.rollback_observation("syncular_mutation");
15715 self.outbox.pop();
15716 self.overlay_dirty.restore(was_dirty.clone());
15717 return Err(error);
15718 }
15719 Ok(id)
15720 }
15721
15722 pub fn patch(
15728 &mut self,
15729 table: &str,
15730 row_id: &str,
15731 partial: Map<String, Value>,
15732 base_version: Option<i64>,
15733 ) -> Result<String, ClientError> {
15734 let prior = self.storage_failure.borrow_mut().take();
15735 let outcome = self.perform_patch(table, row_id, partial, base_version);
15736 self.finish_authoring(prior, outcome)
15737 }
15738
15739 fn perform_patch(
15742 &mut self,
15743 table: &str,
15744 row_id: &str,
15745 partial: Map<String, Value>,
15746 base_version: Option<i64>,
15747 ) -> Result<String, String> {
15748 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15749 self.record_outbox_commit(vec![operation])
15750 }
15751
15752 fn prepare_patch(
15753 &self,
15754 table: &str,
15755 row_id: &str,
15756 partial: Map<String, Value>,
15757 base_version: Option<i64>,
15758 ) -> Result<OutboxOp, String> {
15759 let schema_table = self
15760 .schema
15761 .table(table)
15762 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15763 let partial = normalize_values_casing(schema_table, partial)?;
15764 if let Some(pk) = partial.get(&schema_table.primary_key) {
15765 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15766 return Err(format!(
15767 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15768 ));
15769 }
15770 }
15771 let exists: bool = self
15774 .conn
15775 .query_row(
15776 &format!(
15777 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
15778 visible_table(table),
15779 row_id_predicate(schema_table)
15780 ),
15781 rusqlite::params![row_id],
15782 |row| row.get(0),
15783 )
15784 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15785 if !exists {
15786 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15787 }
15788 for scope in &schema_table.scope_variables {
15798 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15799 continue;
15800 }
15801 let Some(column) = schema_table
15802 .columns
15803 .iter()
15804 .find(|column| column.name == scope.column)
15805 else {
15806 continue;
15807 };
15808 let stored = self
15809 .conn
15810 .query_row(
15811 &format!(
15812 "SELECT {} FROM {} WHERE {}",
15813 quote_ident(&scope.column),
15814 visible_table(&schema_table.name),
15815 row_id_predicate(schema_table)
15816 ),
15817 rusqlite::params![row_id],
15818 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15819 )
15820 .optional()
15821 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15822 let incoming = json_to_column_value(column, partial.get(&scope.column))
15827 .ok()
15828 .map(|value| column_value_to_json(&value));
15829 let equal = stored.as_ref().is_some_and(|stored| {
15833 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15834 });
15835 if !equal {
15836 return Err(format!(
15837 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15838 scope.column
15839 ));
15840 }
15841 }
15842 let mut values = partial;
15843 for scope in &schema_table.scope_variables {
15846 values.remove(&scope.column);
15847 }
15848 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
15849 ColumnType::Integer => match row_id.parse::<i64>() {
15851 Ok(v)
15852 if v.to_string() == row_id
15853 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
15854 .contains(&v) =>
15855 {
15856 Value::from(v)
15857 }
15858 _ => {
15859 return Err(format!(
15860 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
15861 ))
15862 }
15863 },
15864 ColumnType::Boolean => match row_id {
15865 "true" => Value::Bool(true),
15866 "false" => Value::Bool(false),
15867 _ => {
15868 return Err(format!(
15869 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
15870 ))
15871 }
15872 },
15873 _ => Value::from(row_id.to_owned()),
15874 };
15875 values.insert(schema_table.primary_key.clone(), pk_value);
15876 self.validate_author_encode(schema_table, row_id, &values)?;
15882 Ok(OutboxOp {
15883 upsert: true,
15884 table: table.to_owned(),
15885 row_id: row_id.to_owned(),
15886 base_version,
15887 values: Some(values),
15888 })
15889 }
15890
15891 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
15896 let selector = self.encryption.key_id_columns.get(&table.name)?;
15897 let index = table
15898 .columns
15899 .iter()
15900 .position(|column| &column.name == selector)?;
15901 let sql = format!(
15902 "SELECT {} FROM {} WHERE {}",
15903 quote_ident(selector),
15904 visible_table(&table.name),
15905 row_id_predicate(table)
15906 );
15907 let key: Option<String> = self
15908 .conn
15909 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
15910 .ok()?;
15911 let key = key.filter(|key| !key.is_empty())?;
15912 let mut fallback: Row = vec![None; table.columns.len()];
15913 fallback[index] = Some(ColumnValue::String(key));
15914 Some(fallback)
15915 }
15916
15917 fn validate_author_encode(
15922 &self,
15923 table: &TableSchema,
15924 row_id: &str,
15925 values: &Map<String, Value>,
15926 ) -> Result<(), String> {
15927 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
15928 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
15929 result => result.map(|_| ()),
15930 }
15931 }
15932
15933 fn drop_unencodable_outbox(
15947 &mut self,
15948 with_encryption: bool,
15949 batch: Option<&mut ChangeAccumulator>,
15950 ) -> Result<bool, String> {
15951 let failures = self
15952 .outbox
15953 .iter()
15954 .filter_map(|commit| {
15955 commit
15956 .ops
15957 .iter()
15958 .find_map(|op| {
15959 let values = op.values.as_ref().filter(|_| op.upsert)?;
15960 let table = self.schema.table(&op.table)?;
15961 let fallback = self.stored_key_fallback(table, &op.row_id);
15962 match encode_sparse_row_json(
15963 table,
15964 &op.row_id,
15965 values,
15966 &self.encryption,
15967 fallback.as_ref(),
15968 ) {
15969 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
15970 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
15971 }
15972 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
15977 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
15978 OUTBOX_INCOMPATIBLE_CODE,
15979 )),
15980 _ => None,
15981 }
15982 })
15983 .map(|(error, code)| (commit.clone(), error, code))
15984 })
15985 .collect::<Vec<_>>();
15986 if failures.is_empty() {
15987 return Ok(false);
15988 }
15989 let prior_outbox = self.outbox.clone();
15995 let prior_rejections = self.rejections.clone();
15996 let prior_failed_commits = self.failed_commits.clone();
16000 let prior_changes = self.change_queue.len();
16001 let prior_intents = self.sync_intent_queue.len();
16002 let prior_last_change = self.last_change.clone();
16003 let prior_overlay_dirty = self.overlay_dirty.snapshot();
16004 let owns_observation = batch.is_none();
16005 let owns_transaction = self.conn.is_autocommit();
16006 let mut domains = ChangeAccumulator::default();
16007 if owns_observation {
16008 self.begin_observation("syncular_value_recovery")?;
16009 }
16010 let dropped = (|| -> Result<(), String> {
16011 for (commit, message, code) in failures {
16012 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16013 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16014 }
16015 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16016 let rejection = RejectionRecord {
16017 client_commit_id: commit.client_commit_id.clone(),
16018 op_index: 0,
16019 code: code.to_owned(),
16020 message,
16021 retryable: false,
16022 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16023 field_paths: None,
16024 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16025 required_action: None,
16026 references: None,
16027 }),
16028 operation: representable
16029 .then(|| commit.ops.first())
16030 .flatten()
16031 .map(CommitOperation::from),
16032 };
16033 self.persist_commit_outcome(
16034 &commit.client_commit_id,
16035 CommitOutcomeStatus::Rejected,
16036 &[CommitOperationOutcome::Error {
16037 rejection: rejection.clone(),
16038 }],
16039 representable.then_some(commit.ops.as_slice()),
16040 )?;
16041 self.delete_outbox_persisted(&commit.client_commit_id)?;
16042 self.outbox
16043 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16044 self.rejections.push(rejection);
16045 for operation in &commit.ops {
16046 domains.table(operation.table.as_str());
16047 }
16048 }
16049 domains.status = true;
16050 domains.rejections = true;
16051 domains.outcomes = true;
16052 self.prune_commit_outcomes()?;
16053 self.overlay_dirty.set(true);
16055 self.rebuild_overlay()?;
16056 Ok(())
16057 })();
16058 let result = match batch {
16059 Some(batch) => {
16060 if dropped.is_ok() {
16061 for table in domains.tables.keys() {
16062 batch.table(table.as_str());
16063 }
16064 batch.status |= domains.status;
16065 batch.rejections |= domains.rejections;
16066 batch.outcomes |= domains.outcomes;
16067 }
16068 dropped
16069 }
16070 None => {
16071 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16072 }
16073 };
16074 if let Err(error) = result {
16075 if owns_observation {
16076 if owns_transaction {
16077 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16078 let mut failure = self.storage_failure.borrow_mut();
16079 let failure =
16080 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16081 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16082 }
16083 } else {
16084 self.rollback_observation("syncular_value_recovery");
16085 }
16086 }
16087 self.outbox = prior_outbox;
16088 self.rejections = prior_rejections;
16089 self.failed_commits = prior_failed_commits;
16090 self.change_queue.truncate(prior_changes);
16091 self.sync_intent_queue.truncate(prior_intents);
16092 self.last_change = prior_last_change;
16093 self.overlay_dirty.restore(prior_overlay_dirty);
16094 return Err(error);
16095 }
16096 Ok(true)
16097 }
16098
16099 pub fn pending_commit_ids(&self) -> Vec<String> {
16100 self.outbox
16101 .iter()
16102 .map(|c| c.client_commit_id.clone())
16103 .collect()
16104 }
16105
16106 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16110 let mut payloads = Vec::new();
16111 for commit in &self.outbox {
16112 for op in &commit.ops {
16113 let Some(values) = op.values.as_ref().filter(|_| op.upsert) else {
16114 continue;
16115 };
16116 let Some(table) = self.schema.table(&op.table) else {
16117 continue;
16118 };
16119 let fallback = self.stored_key_fallback(table, &op.row_id);
16120 if let Ok(payload) = encode_sparse_row_json(
16121 table,
16122 &op.row_id,
16123 values,
16124 &self.encryption,
16125 fallback.as_ref(),
16126 ) {
16127 payloads.push(payload);
16128 }
16129 }
16130 }
16131 payloads
16132 }
16133
16134 pub fn conflicts(&self) -> &[ConflictRecord] {
16135 &self.conflicts
16136 }
16137
16138 pub fn rejections(&self) -> &[RejectionRecord] {
16139 &self.rejections
16140 }
16141
16142 pub fn resolve_commit_outcome(
16143 &mut self,
16144 input: ResolveCommitOutcomeInput,
16145 ) -> Result<CommitOutcome, String> {
16146 let current = self
16147 .commit_outcome(&input.client_commit_id)?
16148 .ok_or_else(|| {
16149 format!(
16150 "sync.outcome_not_found: no durable outcome exists for {:?}",
16151 input.client_commit_id
16152 )
16153 })?;
16154 if current.resolution != CommitOutcomeResolution::Active {
16155 return Ok(current);
16156 }
16157 if input.resolution == CommitOutcomeResolution::Active {
16158 return Err("sync.invalid_request: resolution must leave active state".to_owned());
16159 }
16160 match input.resolution {
16161 CommitOutcomeResolution::Superseded => {
16162 let replacement = input
16163 .replacement_client_commit_id
16164 .as_deref()
16165 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16166 .ok_or_else(|| {
16167 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16168 .to_owned()
16169 })?;
16170 let _ = replacement;
16171 }
16172 _ if input.replacement_client_commit_id.is_some() => {
16173 return Err(
16174 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16175 .to_owned(),
16176 );
16177 }
16178 _ => {}
16179 }
16180 let allowed = match current.status {
16181 CommitOutcomeStatus::Conflict => matches!(
16182 input.resolution,
16183 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16184 ),
16185 CommitOutcomeStatus::Rejected => {
16186 matches!(
16187 input.resolution,
16188 CommitOutcomeResolution::Superseded
16189 | CommitOutcomeResolution::ResolvedKeepServer
16190 )
16191 }
16192 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16193 input.resolution == CommitOutcomeResolution::Dismissed
16194 }
16195 };
16196 if !allowed {
16197 return Err(format!(
16198 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16199 input.resolution, current.status
16200 ));
16201 }
16202
16203 self.begin_observation("syncular_outcome_resolution")?;
16204 let previous_failed = self.failed_commits.clone();
16205 let previous_conflicts = self.conflicts.clone();
16206 let previous_rejections = self.rejections.clone();
16207 let result = (|| {
16208 self.conn
16209 .execute(
16210 "UPDATE _syncular_commit_outcomes
16211 SET resolution = ?1, resolved_at_ms = ?2,
16212 replacement_client_commit_id = ?3
16213 WHERE client_commit_id = ?4 AND resolution = 'active'",
16214 rusqlite::params![
16215 Self::outcome_resolution_name(input.resolution),
16216 self.clock_now_ms(),
16217 input.replacement_client_commit_id,
16218 input.client_commit_id
16219 ],
16220 )
16221 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16222 let resolved = self
16223 .commit_outcome(¤t.client_commit_id)?
16224 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16225 self.conn
16226 .execute(
16227 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16228 rusqlite::params![input.client_commit_id],
16229 )
16230 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16231 for (commit, _) in &self.failed_commits {
16232 if commit.client_commit_id == input.client_commit_id {
16233 for operation in &commit.ops {
16234 self.overlay_dirty.table(&operation.table);
16235 }
16236 }
16237 }
16238 self.failed_commits
16239 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16240 self.prune_commit_outcomes()?;
16241 Ok(resolved)
16242 })();
16243 let resolved = match result {
16244 Ok(outcome) => outcome,
16245 Err(error) => {
16246 self.rollback_observation("syncular_outcome_resolution");
16247 self.failed_commits = previous_failed;
16248 self.conflicts = previous_conflicts;
16249 self.rejections = previous_rejections;
16250 return Err(error);
16251 }
16252 };
16253 self.conflicts
16254 .retain(|record| record.client_commit_id != current.client_commit_id);
16255 self.rejections
16256 .retain(|record| record.client_commit_id != current.client_commit_id);
16257 let mut batch = ChangeAccumulator {
16258 conflicts: current.status == CommitOutcomeStatus::Conflict,
16259 rejections: current.status == CommitOutcomeStatus::Rejected,
16260 outcomes: true,
16261 ..ChangeAccumulator::default()
16262 };
16263 if let Some(operations) = ¤t.operations {
16264 for operation in operations {
16265 batch.table(&operation.table);
16266 }
16267 }
16268 if let Err(error) = self
16269 .rebuild_overlay_if_dirty()
16270 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16271 {
16272 self.rollback_observation("syncular_outcome_resolution");
16273 self.failed_commits = previous_failed;
16274 self.conflicts = previous_conflicts;
16275 self.rejections = previous_rejections;
16276 return Err(error);
16277 }
16278 Ok(resolved)
16279 }
16280
16281 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16282 self.schema_floor.as_ref()
16283 }
16284
16285 pub fn lease_state(&self) -> Option<&LeaseState> {
16287 self.lease_state.as_ref()
16288 }
16289
16290 fn record_lease_error(&mut self, code: &str) {
16293 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16294 return;
16295 }
16296 let mut next = self.lease_state.clone().unwrap_or_default();
16297 next.error_code = Some(code.to_owned());
16298 self.set_lease_state(Some(next));
16299 }
16300
16301 fn set_lease_state(&mut self, next: Option<LeaseState>) {
16302 if self.lease_state == next {
16303 return;
16304 }
16305 if self.begin_observation("syncular_lease").is_err() {
16306 return;
16307 }
16308 self.lease_state = next;
16309 if let Some(lease) = &self.lease_state {
16310 if let Ok(json) = serde_json::to_string(lease) {
16311 self.set_meta(LEASE_STATE_KEY, &json);
16312 }
16313 } else {
16314 self.delete_meta(LEASE_STATE_KEY);
16315 }
16316 let batch = ChangeAccumulator {
16317 status: true,
16318 ..ChangeAccumulator::default()
16319 };
16320 if self.finish_observation("syncular_lease", batch).is_err() {
16321 self.rollback_observation("syncular_lease");
16322 }
16323 }
16324
16325 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16326 if self.schema_floor == next {
16327 return;
16328 }
16329 if self.begin_observation("syncular_schema_floor").is_err() {
16330 return;
16331 }
16332 self.schema_floor = next;
16333 self.stopped = self.schema_floor.is_some();
16334 if let Some(floor) = &self.schema_floor {
16335 if let Ok(json) = serde_json::to_string(floor) {
16336 self.set_meta(SCHEMA_FLOOR_KEY, &json);
16337 }
16338 } else {
16339 self.delete_meta(SCHEMA_FLOOR_KEY);
16340 }
16341 let batch = ChangeAccumulator {
16342 status: true,
16343 ..ChangeAccumulator::default()
16344 };
16345 if self
16346 .finish_observation("syncular_schema_floor", batch)
16347 .is_err()
16348 {
16349 self.rollback_observation("syncular_schema_floor");
16350 }
16351 }
16352
16353 fn set_upgrading(&mut self, value: bool) {
16354 if self.upgrading == value {
16355 return;
16356 }
16357 if self.begin_observation("syncular_upgrading").is_err() {
16358 return;
16359 }
16360 self.upgrading = value;
16361 let batch = ChangeAccumulator {
16362 status: true,
16363 ..ChangeAccumulator::default()
16364 };
16365 if self
16366 .finish_observation("syncular_upgrading", batch)
16367 .is_err()
16368 {
16369 self.rollback_observation("syncular_upgrading");
16370 }
16371 }
16372
16373 pub fn sync_needed(&self) -> bool {
16374 self.sync_needed
16375 }
16376
16377 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16378 let sub = self.subs.iter().find(|s| s.id == id)?;
16379 Some(SubscriptionStateView {
16380 id: sub.id.clone(),
16381 table: sub.table.clone(),
16382 status: sub.state.name().to_owned(),
16383 cursor: sub.cursor,
16384 has_resume_token: sub.bootstrap_state.is_some(),
16385 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16386 reason_code: sub.reason_code.clone(),
16387 })
16388 }
16389
16390 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16391 let schema_table = self
16392 .schema
16393 .table(table)
16394 .ok_or_else(|| format!("unknown table {table:?}"))?;
16395 let sql = format!(
16396 "SELECT * FROM {} ORDER BY {} ASC",
16397 visible_table(table),
16398 quote_ident(&schema_table.primary_key)
16399 );
16400 let mut stmt = self
16401 .conn
16402 .prepare(&sql)
16403 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16404 let mut rows = stmt
16405 .query([])
16406 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16407 let mut out = Vec::new();
16408 while let Some(row) = rows
16409 .next()
16410 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16411 {
16412 let mut values = Map::new();
16413 for (i, column) in schema_table.columns.iter().enumerate() {
16414 let value = row
16415 .get_ref(i)
16416 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16417 values.insert(column.name.clone(), sql_ref_to_json(column, value));
16418 }
16419 let version: i64 = row
16420 .get(schema_table.columns.len())
16421 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16422 let row_id = match values.get(&schema_table.primary_key) {
16423 Some(Value::String(s)) => s.clone(),
16424 Some(Value::Number(n)) => n.to_string(),
16425 Some(Value::Bool(b)) => b.to_string(),
16426 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16427 };
16428 out.push(RowState {
16429 row_id,
16430 version,
16431 values,
16432 });
16433 }
16434 Ok(out)
16435 }
16436
16437 #[cfg(feature = "crdt-yjs")]
16453 fn crdt_column_bytes(
16454 &self,
16455 table: &str,
16456 row_id: &str,
16457 column: &str,
16458 ) -> Result<Option<Vec<u8>>, String> {
16459 let schema_table = self
16460 .schema
16461 .table(table)
16462 .ok_or_else(|| format!("unknown table {table:?}"))?;
16463 let col = schema_table
16464 .columns
16465 .iter()
16466 .find(|c| c.name == column)
16467 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16468 if col.ty != ColumnType::Crdt {
16469 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16470 }
16471 let sql = format!(
16472 "SELECT {} FROM {} WHERE {}",
16473 quote_ident(column),
16474 visible_table(table),
16475 row_id_predicate(schema_table)
16476 );
16477 let bytes: Option<Vec<u8>> = self
16478 .conn
16479 .prepare_cached(&sql)
16480 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16481 .query_row(rusqlite::params![row_id], |row| {
16482 row.get::<_, Option<Vec<u8>>>(0)
16483 })
16484 .map_err(|e| match e {
16485 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16486 other => Self::sqlite_failure(&self.storage_failure, other),
16487 })?;
16488 Ok(bytes)
16489 }
16490
16491 #[cfg(feature = "crdt-yjs")]
16495 pub fn crdt_text(
16496 &self,
16497 table: &str,
16498 row_id: &str,
16499 column: &str,
16500 name: &str,
16501 ) -> Result<String, String> {
16502 let bytes = self
16503 .crdt_column_bytes(table, row_id, column)?
16504 .unwrap_or_default();
16505 crate::crdt::text(&bytes, name)
16506 }
16507
16508 #[cfg(feature = "crdt-yjs")]
16512 pub fn crdt_insert_text(
16513 &mut self,
16514 table: &str,
16515 row_id: &str,
16516 column: &str,
16517 name: &str,
16518 index: u32,
16519 value: &str,
16520 ) -> Result<String, ClientError> {
16521 let prior = self.storage_failure.borrow_mut().take();
16522 let outcome = (|| -> Result<String, String> {
16523 let current = self
16524 .crdt_column_bytes(table, row_id, column)?
16525 .unwrap_or_default();
16526 let update = crate::crdt::insert_text(¤t, name, index, value)?;
16527 self.perform_crdt_push_update(table, row_id, column, &update)
16528 })();
16529 self.finish_authoring(prior, outcome)
16530 }
16531
16532 #[cfg(feature = "crdt-yjs")]
16536 pub fn crdt_delete_text(
16537 &mut self,
16538 table: &str,
16539 row_id: &str,
16540 column: &str,
16541 name: &str,
16542 index: u32,
16543 len: u32,
16544 ) -> Result<String, ClientError> {
16545 let prior = self.storage_failure.borrow_mut().take();
16546 let outcome = (|| -> Result<String, String> {
16547 let current = self
16548 .crdt_column_bytes(table, row_id, column)?
16549 .unwrap_or_default();
16550 let update = crate::crdt::delete_text(¤t, name, index, len)?;
16551 self.perform_crdt_push_update(table, row_id, column, &update)
16552 })();
16553 self.finish_authoring(prior, outcome)
16554 }
16555
16556 #[cfg(feature = "crdt-yjs")]
16561 pub fn crdt_apply_update(
16562 &mut self,
16563 table: &str,
16564 row_id: &str,
16565 column: &str,
16566 update: &[u8],
16567 ) -> Result<String, ClientError> {
16568 let prior = self.storage_failure.borrow_mut().take();
16569 let outcome = (|| -> Result<String, String> {
16570 let current = self
16571 .crdt_column_bytes(table, row_id, column)?
16572 .unwrap_or_default();
16573 let next = crate::crdt::apply_update(¤t, update)?;
16574 self.perform_crdt_push_update(table, row_id, column, &next)
16575 })();
16576 self.finish_authoring(prior, outcome)
16577 }
16578
16579 #[cfg(feature = "crdt-yjs")]
16586 fn perform_crdt_push_update(
16587 &mut self,
16588 table: &str,
16589 row_id: &str,
16590 column: &str,
16591 crdt_bytes: &[u8],
16592 ) -> Result<String, String> {
16593 let mut bytes_obj = Map::new();
16594 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16595 let mut partial = Map::new();
16596 partial.insert(column.to_owned(), Value::Object(bytes_obj));
16597 self.perform_patch(table, row_id, partial, None)
16598 }
16599
16600 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16615 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16616 }
16617
16618 #[cfg(feature = "bench-internals")]
16620 #[doc(hidden)]
16621 pub fn benchmark_connection(&mut self) -> &mut Connection {
16622 &mut self.conn
16623 }
16624
16625 #[cfg(feature = "bench-internals")]
16627 #[doc(hidden)]
16628 pub fn benchmark_phases(
16629 &self,
16630 enabled: Option<bool>,
16631 reset: bool,
16632 ) -> Result<Option<Value>, String> {
16633 self.benchmark_phases.configure(enabled, reset)?;
16634 Ok(self.benchmark_phases.snapshot())
16635 }
16636
16637 pub fn authority_snapshot(&self) -> Result<Value, String> {
16639 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16640 if self.limits.authority_reads.is_empty() {
16641 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16642 }
16643 self.conn
16644 .execute_batch("SAVEPOINT authority_snapshot")
16645 .map_err(|e| e.to_string())?;
16646 let result = (|| {
16647 let revision = self
16648 .get_meta(LOCAL_REVISION_KEY)
16649 .ok_or("sync.local_corrupt: authority revision is missing")?;
16650 let parsed_revision = revision
16651 .parse::<u64>()
16652 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16653 if parsed_revision.to_string() != revision {
16654 return Err("sync.local_corrupt: authority revision is invalid".into());
16655 }
16656 let mut tables = Vec::new();
16657 for read in &self.limits.authority_reads {
16658 let table = self
16659 .schema
16660 .table(&read.table)
16661 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16662 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16663 let ty = table
16664 .columns
16665 .iter()
16666 .find(|c| c.name == column)
16667 .expect("validated authority column")
16668 .ty;
16669 if ty == ColumnType::Boolean {
16670 if let Some(value) = value.as_bool() {
16671 return Ok(value.to_string());
16672 }
16673 return match value.as_i64() {
16674 Some(0) => Ok("false".into()),
16675 Some(1) => Ok("true".into()),
16676 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16677 };
16678 }
16679 match value {
16680 Value::String(s) => Ok(s.clone()),
16681 Value::Number(n) => Ok(n.to_string()),
16682 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16683 .get("$bigint")
16684 .and_then(Value::as_str)
16685 .map(str::to_owned)
16686 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16687 _ => {
16688 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16689 }
16690 }
16691 };
16692 let matches = |row: &QueryRow| -> Result<bool, String> {
16693 for (variable, values) in &read.scopes {
16694 let column = table
16695 .scope_column(variable)
16696 .expect("validated authority scope");
16697 let value = row
16698 .get(column)
16699 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16700 if value.is_null() || !values.contains(&scalar(column, value)?) {
16701 return Ok(false);
16702 }
16703 }
16704 Ok(true)
16705 };
16706 let selected = read
16707 .columns
16708 .iter()
16709 .map(|c| quote_ident(c))
16710 .collect::<Vec<_>>()
16711 .join(",");
16712 let mut intent = BTreeSet::new();
16713 let mut relevant = BTreeSet::new();
16714 for row in query_connection(&self.conn,
16715 "SELECT id,values_json FROM (SELECT json_extract(value,'$.table') AS tbl,json_extract(value,'$.rowId') AS id,json_extract(value,'$.values') AS values_json FROM _syncular_outbox,json_each(ops_json) UNION ALL SELECT json_extract(value,'$.table'),json_extract(value,'$.rowId'),json_extract(value,'$.values') FROM _syncular_failed_commits,json_each(operations_json) UNION ALL SELECT tbl,id,intent_json FROM _syncular_acked_rows) WHERE tbl=?",
16716 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16717 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16718 intent.insert(id.clone());
16719 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16720 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16721 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16722 }
16723 }
16724 let mut rows = BTreeMap::new();
16725 for full in [base_table(&read.table), visible_table(&read.table)] {
16726 for mut values in query_connection(
16727 &self.conn,
16728 &format!(
16729 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16730 ),
16731 &[],
16732 )
16733 .map_err(|e| e.to_string())?
16734 {
16735 if !matches(&values)? {
16736 continue;
16737 }
16738 let id = scalar(
16739 &table.primary_key,
16740 values
16741 .get(&table.primary_key)
16742 .expect("selected primary key"),
16743 )?;
16744 relevant.insert(id.clone());
16745 if full == base_table(&read.table) {
16746 let version = values
16747 .remove("authority_version")
16748 .expect("selected version");
16749 let version = version
16750 .as_i64()
16751 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16752 if version >= 0 {
16753 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16754 }
16755 }
16756 }
16757 }
16758 intent.retain(|id| relevant.contains(id));
16759 let rows = rows.into_values().collect::<Vec<_>>();
16760 let mut persisted = Vec::new();
16761 for row in query_connection(
16762 &self.conn,
16763 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16764 &[Value::String(read.table.clone())],
16765 )
16766 .map_err(|e| e.message)?
16767 {
16768 let state: Value = serde_json::from_str(
16769 row.get("state_json")
16770 .and_then(Value::as_str)
16771 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16772 )
16773 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16774 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16775 let status = state
16776 .get("status")
16777 .and_then(Value::as_str)
16778 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16779 let cursor = state
16780 .get("cursor")
16781 .and_then(Value::as_i64)
16782 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16783 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16784 && status == "active"
16785 && cursor >= 0
16786 && state.get("bootstrapState").is_none_or(Value::is_null)
16787 && state.get("params").is_none_or(Value::is_null);
16788 let exposed = effective.map(|s| {
16789 read.scopes
16790 .keys()
16791 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16792 .collect::<Map<_, _>>()
16793 });
16794 let requested = read
16795 .scopes
16796 .keys()
16797 .map(|v| {
16798 (
16799 v.clone(),
16800 state
16801 .get("requested")
16802 .and_then(|s| s.get(v))
16803 .cloned()
16804 .unwrap_or(json!([])),
16805 )
16806 })
16807 .collect::<Map<_, _>>();
16808 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16809 }
16810 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16811 match selectors.split_first() {
16812 None => !candidates.is_empty(),
16813 Some(((variable, values), rest)) => values.iter().all(|value| {
16814 covered(
16815 rest,
16816 &candidates
16817 .iter()
16818 .copied()
16819 .filter(|p| {
16820 p["effectiveScopes"][variable.as_str()]
16821 .as_array()
16822 .is_some_and(|held| {
16823 held.contains(&Value::String(value.clone()))
16824 || held.contains(&Value::String("*".into()))
16825 })
16826 })
16827 .collect::<Vec<_>>(),
16828 )
16829 }),
16830 }
16831 }
16832 let coverage = if covered(
16833 &read.scopes.iter().collect::<Vec<_>>(),
16834 &persisted
16835 .iter()
16836 .filter(|p| p["complete"] == true)
16837 .collect::<Vec<_>>(),
16838 ) {
16839 "complete"
16840 } else if persisted.iter().any(|p| {
16841 p["status"] == "active"
16842 && read.scopes.iter().all(|(v, values)| {
16843 values.iter().any(|value| {
16844 p["requestedScopes"][v].as_array().is_some_and(|held| {
16845 held.contains(&Value::String(value.clone()))
16846 || held.contains(&Value::String("*".into()))
16847 })
16848 })
16849 })
16850 }) {
16851 "pending"
16852 } else {
16853 "missing"
16854 };
16855 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
16856 }
16857 Ok(
16858 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
16859 )
16860 })();
16861 match result {
16862 Ok(snapshot) => {
16863 self.conn
16864 .execute_batch("RELEASE authority_snapshot")
16865 .map_err(|e| e.to_string())?;
16866 Ok(snapshot)
16867 }
16868 Err(error) => {
16869 self.conn
16870 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
16871 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
16872 Err(error)
16873 }
16874 }
16875 }
16876
16877 pub fn query_snapshot(
16881 &mut self,
16882 sql: &str,
16883 params: &[QueryValue],
16884 coverage: &[WindowCoverage],
16885 owner: Option<QueryOwner<'_>>,
16886 ) -> Result<QuerySnapshot, String> {
16887 let result = snapshot_connection(&self.conn, sql, params, coverage);
16888 if let Some(owner) = owner {
16889 self.record_query_read(owner, result.as_ref().err());
16890 }
16891 result.map_err(|failure| failure.to_string())
16892 }
16893
16894 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
16898 let position = self
16899 .query_failures
16900 .iter()
16901 .position(|entry| entry.id == owner.id);
16902 let Some(failure) = failure else {
16903 if let Some(index) = position {
16904 self.query_failures.remove(index);
16905 }
16906 return;
16907 };
16908 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
16909 .into_iter()
16910 .map(str::to_owned)
16911 .collect();
16912 let code = failure.code.unwrap_or("client.query_failed").to_owned();
16913 if position.is_some_and(|index| {
16914 let entry = &self.query_failures[index];
16915 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
16916 }) {
16917 return;
16918 }
16919 if let Some(index) = position {
16920 self.query_failures.remove(index);
16921 }
16922 self.query_failures.push(DiagnosticQueryFailure {
16923 id: owner.id.to_owned(),
16924 tables,
16925 code,
16926 sqlite_code: failure.sqlite_code,
16927 at_ms: self.clock_now_ms(),
16928 });
16929 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
16930 self.query_failures.remove(0);
16931 }
16932 }
16933
16934 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
16937 #[cfg(feature = "bench-internals")]
16938 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
16939 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
16940 let max_commits = self.limits.max_push_commits_per_request;
16941 let max_ops = self
16942 .limits
16943 .max_push_operations_per_request
16944 .unwrap_or(PUSH_OPS_PER_REQUEST);
16945 let byte_cap = self.limits.max_push_request_bytes;
16946 let accept = self.limits.accept.unwrap_or(if url_capable {
16949 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
16950 } else {
16951 DEFAULT_ACCEPT
16952 });
16953 let header = Frame::ReqHeader {
16954 client_id: self.client_id.clone(),
16955 schema_version: self.schema.version,
16956 log_epoch: log_epoch.clone(),
16957 };
16958 let mut lead = vec![header];
16962 lead.extend(
16963 self.round_failure
16964 .iter()
16965 .map(|code| Frame::RoundFailure { code: code.clone() }),
16966 );
16967 let pull = Frame::PullHeader {
16968 limit_commits: self.limits.limit_commits.unwrap_or(0),
16969 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
16970 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
16971 accept,
16972 };
16973 let mut fresh = Vec::new();
16974 let mut sub_frames = Vec::new();
16975 for sub in &self.subs {
16976 if sub.state != SubState::Active {
16977 continue;
16978 }
16979 let mut scopes = sub.requested.clone();
16980 sort_scope_map(&mut scopes);
16981 sub_frames.push(Frame::Subscription {
16982 id: sub.id.clone(),
16983 table: sub.table.clone(),
16984 scopes,
16985 params: sub.params.clone().map(RawJson),
16986 cursor: sub.cursor,
16987 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
16988 });
16989 fresh.push((
16990 sub.id.clone(),
16991 sub.cursor < 0 && sub.bootstrap_state.is_none(),
16992 ));
16993 }
16994 let fixed_bytes = match byte_cap {
16997 Some(_) => {
16998 let mut frames = lead.clone();
16999 frames.push(pull.clone());
17000 frames.extend(sub_frames.iter().cloned());
17001 encode_message(&Message {
17002 wire_version: WIRE_VERSION,
17003 msg_kind: MsgKind::Request,
17004 frames,
17005 })
17006 .len()
17007 }
17008 None => 0,
17009 };
17010 if let Some(cap) = byte_cap {
17011 if fixed_bytes > cap {
17012 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17013 }
17014 }
17015 let base_bytes = match byte_cap {
17016 Some(_) => {
17017 let mut frames = lead.clone();
17018 frames.push(pull.clone());
17019 encode_message(&Message {
17020 wire_version: WIRE_VERSION,
17021 msg_kind: MsgKind::Request,
17022 frames,
17023 })
17024 .len()
17025 }
17026 None => 0,
17027 };
17028 let total_outbox = if log_epoch.is_some() {
17029 self.outbox.len()
17030 } else {
17031 0
17032 };
17033 let mut push_frames: Vec<Frame> = Vec::new();
17034 let mut pushed_ids = Vec::new();
17035 let mut ops_in_request = 0usize;
17036 let mut bytes_in_request = 0usize;
17037 let mut deferred_commits = 0usize;
17038 #[cfg(feature = "bench-internals")]
17039 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17040 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17041 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17045 deferred_commits = total_outbox - index;
17046 break;
17047 }
17048 if ops_in_request + commit.ops.len() > max_ops {
17049 if push_frames.is_empty() {
17050 return Err(Self::push_capacity_outcome(
17051 "operations",
17052 max_ops,
17053 commit.ops.len(),
17054 Some(commit.client_commit_id.as_str()),
17055 ));
17056 }
17057 deferred_commits = total_outbox - index;
17058 break;
17059 }
17060 let operations = commit
17061 .ops
17062 .iter()
17063 .map(|op| {
17064 let payload = op.values.as_ref().and_then(|values| {
17065 let table = self.schema.table(&op.table)?;
17066 let fallback = self.stored_key_fallback(table, &op.row_id);
17075 encode_sparse_row_json(
17076 table,
17077 &op.row_id,
17078 values,
17079 &self.encryption,
17080 fallback.as_ref(),
17081 )
17082 .ok()
17083 });
17084 ssp2::model::Operation {
17085 table: op.table.clone(),
17086 row_id: op.row_id.clone(),
17087 op: if op.upsert { Op::Upsert } else { Op::Delete },
17088 base_version: op.base_version,
17089 payload,
17090 }
17091 })
17092 .collect();
17093 let frame = Frame::PushCommit {
17094 client_commit_id: commit.client_commit_id.clone(),
17095 operations,
17096 };
17097 let frame_bytes = match byte_cap {
17098 Some(_) => {
17099 let mut frames = lead.clone();
17100 frames.push(frame.clone());
17101 frames.push(pull.clone());
17102 encode_message(&Message {
17103 wire_version: WIRE_VERSION,
17104 msg_kind: MsgKind::Request,
17105 frames,
17106 })
17107 .len()
17108 - base_bytes
17109 }
17110 None => 0,
17111 };
17112 if let Some(cap) = byte_cap {
17116 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17117 deferred_commits = total_outbox - index;
17118 break;
17119 }
17120 }
17121 push_frames.push(frame);
17122 pushed_ids.push(commit.client_commit_id.clone());
17123 ops_in_request += commit.ops.len();
17124 bytes_in_request += frame_bytes;
17125 }
17126 #[cfg(feature = "bench-internals")]
17127 drop(outbox_phase);
17128 if let Some(cap) = byte_cap {
17129 if fixed_bytes + bytes_in_request > cap {
17130 let blocked = if push_frames.is_empty() {
17133 None
17134 } else {
17135 self.outbox.first().map(|c| c.client_commit_id.as_str())
17136 };
17137 return Err(Self::push_capacity_outcome(
17138 "bytes",
17139 cap,
17140 fixed_bytes + bytes_in_request,
17141 blocked,
17142 ));
17143 }
17144 }
17145 let mut frames = lead;
17146 frames.extend(push_frames);
17147 frames.push(pull);
17148 frames.extend(sub_frames);
17149 let message = Message {
17150 wire_version: WIRE_VERSION,
17151 msg_kind: MsgKind::Request,
17152 frames,
17153 };
17154 Ok((
17155 message,
17156 RequestMeta {
17157 pushed_ids,
17158 fresh,
17159 accept,
17160 deferred_commits,
17161 },
17162 ))
17163 }
17164
17165 fn push_capacity_outcome(
17167 kind: &str,
17168 limit: usize,
17169 size: usize,
17170 client_commit_id: Option<&str>,
17171 ) -> Box<SyncOutcome> {
17172 let mut details = Map::new();
17173 details.insert("kind".to_owned(), Value::from(kind));
17174 details.insert("limit".to_owned(), Value::from(limit as u64));
17175 details.insert("size".to_owned(), Value::from(size as u64));
17176 if let Some(id) = client_commit_id {
17177 details.insert("clientCommitId".to_owned(), Value::from(id));
17178 }
17179 Box::new(SyncOutcome::Failed {
17180 error_code: "client.push_request_too_large".into(),
17181 message: "the push request exceeds a configured capacity limit".into(),
17182 details: Some(Value::Object(details)),
17183 recommended_action: None,
17184 })
17185 }
17186
17187 pub fn progress(&self) -> ProgressObserver {
17191 self.progress.clone()
17192 }
17193
17194 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17195 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17196 Ok(prepared) => prepared,
17197 Err(outcome) => return *outcome,
17198 };
17199 let mut next = prepared;
17204 loop {
17205 match self.apply_sync_round(next.exchange(transport)) {
17206 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17207 crate::AppliedSyncRound::Complete {
17208 outcome, controls, ..
17209 } => {
17210 if self.transport_enabled {
17211 for text in controls {
17212 let _ = transport.realtime_send(&text);
17213 }
17214 } else {
17215 self.disconnect_realtime(transport);
17216 }
17217 return outcome;
17218 }
17219 }
17220 }
17221 }
17222
17223 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17224 if let SyncOutcome::Failed {
17225 error_code,
17226 message,
17227 details,
17228 ..
17229 } = &mut outcome
17230 {
17231 if let Some(failure) = self.storage_failure.borrow().as_ref() {
17232 let code = failure
17233 .code
17234 .expect("retained storage failures are classified");
17235 *error_code = code.to_owned();
17236 *message = match code {
17237 "client.storage_busy" => "local SQLite storage is busy",
17238 "client.storage_full" => "local SQLite storage is full",
17239 "client.storage_io" => "local SQLite storage I/O failed",
17240 "client.storage_corrupt" => "local SQLite storage is corrupt",
17241 _ => unreachable!("unrecognized classified storage failure"),
17242 }
17243 .to_owned();
17244 *details = failure.details();
17245 }
17246 }
17247 let retry_delay_ms = self.round_retry_delay_ms;
17248 self.progress.update(|p| match &outcome {
17249 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17250 if report.failed.is_empty() =>
17251 {
17252 p.state = ProgressState::Complete
17253 }
17254 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17255 p.state = ProgressState::Failed;
17256 p.error_code = Some("sync.scope_revoked".into());
17257 }
17258 SyncOutcome::Failed { error_code, .. } => {
17259 p.state = ProgressState::Failed;
17260 p.error_code = Some(Self::diagnostic_code(error_code));
17261 p.retry_delay_ms = retry_delay_ms;
17262 }
17263 SyncOutcome::RealtimeUnavailable { .. } => {
17264 p.state = ProgressState::Failed;
17265 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17266 p.retry_delay_ms = retry_delay_ms;
17267 }
17268 });
17269 let completed_at_ms = self.clock_now_ms();
17270 self.last_round = Some(match &outcome {
17271 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17272 status: "succeeded".to_owned(),
17273 started_at_ms,
17274 completed_at_ms,
17275 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17276 counters: Some(DiagnosticRoundCounters {
17277 pushed: report.pushed,
17278 applied: report.applied.len(),
17279 rejected: report.rejected.len(),
17280 retryable: report.retryable.len(),
17281 conflicts: report.conflicts,
17282 commits_applied: report.commits_applied,
17283 segment_rows_applied: report.segment_rows_applied,
17284 bootstrapping: report.bootstrapping.len(),
17285 resets: report.resets.len(),
17286 revoked: report.revoked.len(),
17287 failed: report.failed.len(),
17288 deferred_commits: report.deferred_commits,
17289 }),
17290 error_code: None,
17291 },
17292 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17293 status: "failed".to_owned(),
17294 started_at_ms,
17295 completed_at_ms,
17296 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17297 counters: None,
17298 error_code: Some(Self::diagnostic_code(error_code)),
17299 },
17300 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17301 status: "failed".to_owned(),
17302 started_at_ms,
17303 completed_at_ms,
17304 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17305 counters: None,
17306 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17307 },
17308 });
17309 outcome
17310 }
17311
17312 pub fn prepare_sync_round(
17315 &mut self,
17316 url_capable: bool,
17317 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17318 if !self.transport_enabled {
17319 return Err(Box::new(SyncOutcome::Failed {
17320 error_code: "sync.offline".into(),
17321 message: "transport gate is closed".into(),
17322 details: None,
17323 recommended_action: None,
17324 }));
17325 }
17326 if self.active_round.is_some() {
17327 return Err(Box::new(SyncOutcome::Failed {
17328 error_code: "client.round_in_progress".into(),
17329 message: "a sync round is already in flight".into(),
17330 details: None,
17331 recommended_action: None,
17332 }));
17333 }
17334 self.storage_failure.borrow_mut().take();
17335 self.progress.start();
17336 self.round_retry_delay_ms = None;
17337 let started_at_ms = self.clock_now_ms();
17338 let prepared = (|| {
17339 if self.stopped {
17340 return Err(Box::new(SyncOutcome::Ok(SyncReport {
17341 schema_floor: self.schema_floor.clone(),
17342 ..SyncReport::default()
17343 })));
17344 }
17345 if self.security_preflight {
17346 return Err(Box::new(SyncOutcome::Failed {
17347 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17348 message: "security preflight must complete before syncing".into(),
17349 details: None,
17350 recommended_action: None,
17351 }));
17352 }
17353 self.set_sync_needed(false, false);
17354 self.drain_pending_evictions()
17355 .and_then(|_| self.drop_incompatible_outbox())
17356 .and_then(|_| self.drop_unencodable_outbox(true, None))
17357 .map_err(|message| {
17358 Box::new(SyncOutcome::Failed {
17359 error_code: "storage.failed".into(),
17360 message,
17361 details: None,
17362 recommended_action: None,
17363 })
17364 })?;
17365 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17366 self.pending_blob_uploads().map_err(|e| {
17367 Box::new(SyncOutcome::Failed {
17368 error_code: e.code,
17369 message: e.message,
17370 details: e.details,
17371 recommended_action: None,
17372 })
17373 })?
17374 } else {
17375 Vec::new()
17376 };
17377 if self.realtime_state != RealtimeState::Connected
17378 && self.realtime_policy == RealtimePolicy::Required
17379 {
17380 let retry_delay_ms = self.schedule_background_retry();
17381 self.realtime_retry_delay_ms = Some(retry_delay_ms);
17382 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17383 state: self.realtime_state,
17384 reason_code: self.realtime_reason_code.clone(),
17385 retry_delay_ms,
17386 }));
17387 }
17388 let (message, meta) = self.build_request(url_capable)?;
17389 let id = uuid::Uuid::new_v4();
17390 self.active_round = Some(id);
17391 let phase = if uploads.is_empty() {
17392 crate::round::RoundPhase::Sync
17393 } else {
17394 crate::round::RoundPhase::UploadGrant
17395 };
17396 Ok(crate::PreparedSyncRound {
17397 id,
17398 started_at_ms,
17399 message,
17400 meta,
17401 uploads: uploads.into(),
17402 phase,
17403 encoded: None,
17404 continuation: None,
17405 completed: None,
17406 realtime: self.realtime_state == RealtimeState::Connected,
17407 scopes: self
17408 .subs
17409 .iter()
17410 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17411 .collect(),
17412 #[cfg(feature = "bench-internals")]
17413 benchmark_phases: self.benchmark_phases.clone(),
17414 progress: self.progress.clone(),
17415 fixed_now: self.now_ms,
17416 round_deadline_at: None,
17417 })
17418 })();
17419 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17420 }
17421
17422 pub fn cancel_sync_round(&mut self) {
17425 self.active_round = None;
17426 }
17427
17428 pub fn apply_sync_round(
17431 &mut self,
17432 completed: crate::CompletedSyncRound,
17433 ) -> crate::AppliedSyncRound {
17434 let prepared = completed.prepared;
17435 if self.active_round != Some(prepared.id) || self.security_preflight {
17436 return crate::AppliedSyncRound::Complete {
17437 outcome: SyncOutcome::Failed {
17438 error_code: "client.round_cancelled".into(),
17439 message: "sync round belongs to a released client context".into(),
17440 details: None,
17441 recommended_action: None,
17442 },
17443 controls: Vec::new(),
17444 more: false,
17445 bootstrap_advanced: false,
17446 };
17447 }
17448 self.storage_failure.borrow_mut().take();
17449 let (response, mut downloads, transport_failed) = match completed.exchange {
17450 crate::round::ExchangeResult::Upload { id, result } => {
17451 let result = result.and_then(|()| {
17452 self.conn
17453 .execute(
17454 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17455 rusqlite::params![id],
17456 )
17457 .map(|_| ())
17458 .map_err(|error| {
17459 TransportError::new(
17460 "client.failed",
17461 Self::sqlite_failure(&self.storage_failure, error),
17462 )
17463 })
17464 });
17465 match result {
17466 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17467 Err(TransportError {
17468 code,
17469 message,
17470 details,
17471 }) => {
17472 self.active_round = None;
17473 if Self::retryable_failure_code(&code) {
17474 self.schedule_background_retry();
17475 }
17476 let recommended_action = recommended_action_of(&details);
17477 let outcome = self.finish_sync_round(
17478 prepared.started_at_ms,
17479 SyncOutcome::Failed {
17480 error_code: code,
17481 message,
17482 details,
17483 recommended_action,
17484 },
17485 );
17486 return crate::AppliedSyncRound::Complete {
17487 outcome,
17488 controls: Vec::new(),
17489 more: false,
17490 bootstrap_advanced: false,
17491 };
17492 }
17493 }
17494 }
17495 crate::round::ExchangeResult::Reply {
17496 response,
17497 downloads,
17498 transport_failed,
17499 } => (response, downloads, transport_failed),
17500 };
17501 self.active_round = None;
17502 let before = self
17503 .subs
17504 .iter()
17505 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17506 .collect::<Vec<_>>();
17507 let outcome = (|| {
17508 let response = match response {
17509 Ok(response) => {
17510 self.round_failure = None;
17513 response
17514 }
17515 Err(TransportError {
17516 code,
17517 message,
17518 details,
17519 }) => {
17520 if ssp2::model::valid_round_failure_code(&code) {
17525 self.round_failure = Some(code.clone());
17526 }
17527 if transport_failed {
17528 if prepared.realtime {
17529 self.set_realtime_state(RealtimeState::Lost, Some(&code));
17530 }
17531 self.record_lease_error(&code);
17532 }
17533 if Self::retryable_failure_code(&code) {
17534 let delay = self.schedule_background_retry();
17535 if prepared.realtime && transport_failed {
17536 self.realtime_retry_delay_ms = Some(delay);
17537 }
17538 }
17539 let recommended_action = recommended_action_of(&details);
17540 return SyncOutcome::Failed {
17541 error_code: code,
17542 message,
17543 details,
17544 recommended_action,
17545 };
17546 }
17547 };
17548 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17549 if let SyncOutcome::Ok(report) = &mut outcome {
17550 report.deferred_commits = prepared.meta.deferred_commits;
17551 }
17552 match &outcome {
17553 SyncOutcome::Ok(_) => self.reset_background_retry(),
17554 SyncOutcome::Failed { error_code, .. }
17555 if Self::retryable_failure_code(error_code) =>
17556 {
17557 self.schedule_background_retry();
17558 }
17559 _ => {}
17560 }
17561 if prepared.meta.deferred_commits > 0 {
17562 self.set_sync_needed(true, true);
17563 }
17564 self.previous_version_lifetime_check();
17565 outcome
17566 })();
17567 let more = self.transport_enabled
17568 && matches!(&outcome, SyncOutcome::Ok(report)
17569 if report.schema_floor.is_none()
17570 && (!report.bootstrapping.is_empty()
17571 || report.commits_applied > 0
17572 || report.segment_rows_applied > 0
17573 || !report.resets.is_empty()
17574 || report.deferred_commits > 0
17575 || self.sync_needed));
17576 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17577 && before
17578 != self
17579 .subs
17580 .iter()
17581 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17582 .collect::<Vec<_>>();
17583 crate::AppliedSyncRound::Complete {
17584 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17585 controls: if self.transport_enabled {
17586 downloads.controls
17587 } else {
17588 Vec::new()
17589 },
17590 more,
17591 bootstrap_advanced,
17592 }
17593 }
17594
17595 pub fn sync_until_idle(
17596 &mut self,
17597 transport: &mut dyn Transport,
17598 max_rounds: Option<u32>,
17599 ) -> SyncOutcome {
17600 if max_rounds == Some(0) {
17603 return SyncOutcome::Failed {
17604 details: None,
17605 error_code: "sync.invalid_request".into(),
17606 message: "maxRounds must be a positive integer".into(),
17607 recommended_action: None,
17608 };
17609 }
17610 let rounds = max_rounds.unwrap_or(20);
17611 let mut aggregate = SyncReport::default();
17612 let mut spent = 0;
17613 while spent < rounds {
17614 spent += 1;
17615 let before = self
17616 .subs
17617 .iter()
17618 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17619 .collect::<Vec<_>>();
17620 match self.sync(transport) {
17621 SyncOutcome::Failed {
17622 error_code,
17623 message,
17624 details,
17625 recommended_action,
17626 } => {
17627 return SyncOutcome::Failed {
17628 details,
17629 error_code,
17630 message,
17631 recommended_action,
17632 };
17633 }
17634 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17635 SyncOutcome::BudgetExhausted(_) => {
17636 unreachable!("sync() never exhausts a round budget")
17637 }
17638 SyncOutcome::Ok(report) => {
17639 if max_rounds.is_none()
17640 && report.segment_rows_applied > 0
17641 && !report.bootstrapping.is_empty()
17642 && before
17643 != self
17644 .subs
17645 .iter()
17646 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17647 .collect::<Vec<_>>()
17648 {
17649 spent = 0;
17650 }
17651 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17654 aggregate.merge(&report);
17655 if stop {
17656 return SyncOutcome::Ok(aggregate);
17657 }
17658 let more = !report.bootstrapping.is_empty()
17664 || report.commits_applied > 0
17665 || report.segment_rows_applied > 0
17666 || !report.resets.is_empty()
17667 || report.deferred_commits > 0
17668 || self.sync_needed;
17669 if !more {
17670 return SyncOutcome::Ok(aggregate);
17671 }
17672 }
17673 }
17674 }
17675 SyncOutcome::BudgetExhausted(aggregate)
17678 }
17679
17680 fn process_response(
17681 &mut self,
17682 transport: &mut dyn Transport,
17683 response: Message,
17684 meta: &RequestMeta,
17685 ) -> SyncOutcome {
17686 #[cfg(feature = "bench-internals")]
17687 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17688 let mut report = SyncReport {
17689 pushed: meta.pushed_ids.len() as u32,
17690 ..SyncReport::default()
17691 };
17692 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17693 HashMap::new();
17694 let pushed_ids = meta
17695 .pushed_ids
17696 .iter()
17697 .map(String::as_str)
17698 .collect::<HashSet<_>>();
17699 let mut last_final_push_result_id: Option<String> = None;
17700 for frame in &response.frames {
17701 match frame {
17702 Frame::PushResultDetails {
17703 client_commit_id,
17704 entries,
17705 } => {
17706 let details = rejection_details_by_commit
17707 .entry(client_commit_id.clone())
17708 .or_default();
17709 for entry in entries {
17710 let parsed = match RejectionDetails::parse(&entry.details.0) {
17711 Ok(value) => value,
17712 Err(message) => {
17713 return SyncOutcome::Failed {
17714 details: None,
17715 error_code: "sync.invalid_request".to_owned(),
17716 message,
17717 recommended_action: None,
17718 };
17719 }
17720 };
17721 details.insert(entry.op_index, parsed);
17722 }
17723 }
17724 Frame::PushResult {
17725 client_commit_id,
17726 status,
17727 results,
17728 ..
17729 } if pushed_ids.contains(client_commit_id.as_str())
17730 && Self::push_result_is_final(*status, results) =>
17731 {
17732 last_final_push_result_id = Some(client_commit_id.clone());
17733 }
17734 _ => {}
17735 }
17736 }
17737 if response.wire_version < 2 {
17738 return SyncOutcome::Failed {
17739 details: None,
17740 error_code: "client.invalid_host_response".to_owned(),
17741 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17742 recommended_action: None,
17743 };
17744 }
17745 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17746 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17747 });
17748 let mut frames = response.frames.into_iter().peekable();
17749 match frames.next() {
17750 Some(Frame::RespHeader {
17751 required_schema_version,
17752 latest_schema_version,
17753 log_epoch,
17754 reset_required,
17755 }) => {
17756 let Some(log_epoch) = log_epoch else {
17757 return SyncOutcome::Failed {
17758 details: None,
17759 error_code: "client.invalid_host_response".to_owned(),
17760 message: "response header omits logEpoch".to_owned(),
17761 recommended_action: None,
17762 };
17763 };
17764 let Some(reset_required) = reset_required else {
17765 return SyncOutcome::Failed {
17766 details: None,
17767 error_code: "client.invalid_host_response".to_owned(),
17768 message: "response header omits resetRequired".to_owned(),
17769 recommended_action: None,
17770 };
17771 };
17772 if let Some(required) = required_schema_version {
17773 let floor = SchemaFloor {
17776 required_schema_version: Some(required),
17777 latest_schema_version,
17778 };
17779 self.set_schema_floor(Some(floor.clone()));
17780 report.schema_floor = Some(floor);
17781 return SyncOutcome::Ok(report);
17782 }
17783 if reset_required {
17784 if frames.next().is_some() {
17785 return SyncOutcome::Failed {
17786 details: None,
17787 error_code: "client.invalid_host_response".to_owned(),
17788 message: "log-epoch reset response contains body frames".to_owned(),
17789 recommended_action: None,
17790 };
17791 }
17792 match self.run_log_epoch_reset(&log_epoch) {
17793 Ok(resets) => {
17794 report.resets = resets;
17795 return SyncOutcome::Ok(report);
17796 }
17797 Err(message) => {
17798 return SyncOutcome::Failed {
17799 details: None,
17800 error_code: "sync.local_corrupt".to_owned(),
17801 message,
17802 recommended_action: None,
17803 };
17804 }
17805 }
17806 }
17807 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
17808 return SyncOutcome::Failed {
17809 details: None,
17810 error_code: "client.invalid_host_response".to_owned(),
17811 message: "server changed logEpoch without requiring a reset".to_owned(),
17812 recommended_action: None,
17813 };
17814 }
17815 }
17816 _ => {
17817 return SyncOutcome::Failed {
17818 details: None,
17819 error_code: "sync.invalid_request".to_owned(),
17820 message: "response does not start with RESP_HEADER".to_owned(),
17821 recommended_action: None,
17822 };
17823 }
17824 }
17825
17826 if let Err(message) = self.begin_coalesced_apply() {
17827 return SyncOutcome::Failed {
17828 details: None,
17829 error_code: "storage.failed".into(),
17830 message,
17831 recommended_action: None,
17832 };
17833 }
17834 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
17835 while let Some(frame) = frames.next() {
17836 match frame {
17837 frame @ Frame::PushResult { .. } => {
17838 let successful = matches!(
17839 &frame,
17840 Frame::PushResult {
17841 status: PushStatus::Applied | PushStatus::Cached,
17842 ..
17843 }
17844 );
17845 let mut results = vec![frame];
17846 if successful {
17847 while matches!(
17848 frames.peek(),
17849 Some(Frame::PushResult {
17850 status: PushStatus::Applied | PushStatus::Cached,
17851 ..
17852 })
17853 ) {
17854 if let Some(next) = frames.next() {
17855 results.push(next);
17856 }
17857 }
17858 }
17859 if self
17860 .handle_push_results(
17861 &results,
17862 &pushed_ids,
17863 &rejection_details_by_commit,
17864 &mut report,
17865 last_final_push_result_id.as_deref(),
17866 )
17867 .is_err()
17868 {
17869 failure = Some((
17870 "client.outcome_persistence_failed".to_owned(),
17871 "local commit outcome could not be persisted".to_owned(),
17872 None,
17873 None,
17874 ));
17875 break;
17876 }
17877 }
17878 Frame::PushResultDetails { .. } => {}
17879 Frame::SubStart {
17880 id,
17881 status,
17882 reason_code,
17883 effective_scopes,
17884 bootstrap: _,
17885 } => {
17886 let mut body = Vec::new();
17887 let mut sub_end: Option<(i64, Option<String>)> = None;
17888 for inner in frames.by_ref() {
17889 match inner {
17890 Frame::SubEnd {
17891 next_cursor,
17892 bootstrap_state,
17893 } => {
17894 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
17895 break;
17896 }
17897 Frame::Unknown { .. } => {}
17898 other => body.push(other),
17899 }
17900 }
17901 if let Err(SectionError::Abort(code, message)) = self.process_section(
17902 transport,
17903 &id,
17904 status,
17905 &reason_code,
17906 effective_scopes,
17907 body,
17908 sub_end,
17909 meta,
17910 &mut report,
17911 &coalesced_deletes,
17912 ) {
17913 failure = Some((code, message, None, None));
17914 break;
17915 }
17916 }
17917 Frame::Lease {
17918 lease_id,
17919 expires_at_ms,
17920 } => {
17921 self.set_lease_state(Some(LeaseState {
17924 lease_id: Some(lease_id),
17925 expires_at_ms: Some(expires_at_ms),
17926 error_code: None,
17927 }));
17928 }
17929 Frame::Error {
17930 code,
17931 message,
17932 recommended_action,
17933 details,
17934 ..
17935 } => {
17936 let recommended_action =
17939 (!recommended_action.is_empty()).then_some(recommended_action);
17940 let details = details
17941 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
17942 .filter(Value::is_object);
17943 failure = Some((code, message, recommended_action, details));
17944 break;
17945 }
17946 Frame::Unknown { .. } => {}
17947 _ => {
17948 failure = Some((
17949 "sync.invalid_request".to_owned(),
17950 "unexpected frame in response".to_owned(),
17951 None,
17952 None,
17953 ));
17954 break;
17955 }
17956 }
17957 }
17958
17959 let applied = match failure {
17960 Some(failure) => Err(failure),
17961 None => self
17966 .rebuild_overlay_if_dirty()
17967 .and_then(|()| self.drain_pending_evictions())
17968 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
17969 };
17970 if let Err(message) = self.finish_coalesced_apply() {
17973 return SyncOutcome::Failed {
17974 details: None,
17975 error_code: "storage.failed".into(),
17976 message,
17977 recommended_action: None,
17978 };
17979 }
17980 if let Err((error_code, message, recommended_action, details)) = applied {
17981 return SyncOutcome::Failed {
17982 details,
17983 error_code,
17984 message,
17985 recommended_action,
17986 };
17987 }
17988 self.ack_after_pull(transport);
17989 if self.upgrading && report.bootstrapping.is_empty() {
17992 self.set_upgrading(false);
17993 }
17994 SyncOutcome::Ok(report)
17995 }
17996
17997 fn handle_push_results(
18000 &mut self,
18001 frames: &[Frame],
18002 pushed_ids: &HashSet<&str>,
18003 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18004 report: &mut SyncReport,
18005 last_final_push_result_id: Option<&str>,
18006 ) -> Result<(), String> {
18007 self.begin_observation("syncular_push_results")?;
18008 let previous_failed = self.failed_commits.clone();
18009 let previous_sync_needed = self.sync_needed;
18010 let conflict_count = self.conflicts.len();
18011 let rejection_count = self.rejections.len();
18012 let applied_count = report.applied.len();
18013 let rejected_count = report.rejected.len();
18014 let retryable_count = report.retryable.len();
18015 let was_dirty = self.overlay_dirty.snapshot();
18016 let mut removed = Vec::new();
18017 let mut batch = ChangeAccumulator::default();
18018 let persisted = (|| {
18019 for frame in frames {
18020 let Frame::PushResult {
18021 client_commit_id,
18022 status,
18023 results,
18024 commit_seq,
18025 } = frame
18026 else {
18027 return Err("unexpected acknowledgement frame".to_owned());
18028 };
18029 if !pushed_ids.contains(client_commit_id.as_str()) {
18030 continue;
18031 }
18032 let Some(index) = self
18033 .outbox
18034 .iter()
18035 .position(|commit| commit.client_commit_id == *client_commit_id)
18036 else {
18037 continue;
18038 };
18039 let status = *status;
18040 let rejection_details = rejection_details_by_commit.get(client_commit_id);
18041 let operations = self.outbox[index].ops.clone();
18042 match status {
18043 PushStatus::Applied | PushStatus::Cached => {
18044 let sequence =
18047 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18048 let affected: HashSet<_> = operations
18049 .iter()
18050 .map(|op| (op.table.as_str(), op.row_id.as_str()))
18051 .collect();
18052 let prior = self.acknowledged_operations(Some(&affected), None)?;
18053 for (op_index, operation) in operations.iter().enumerate() {
18054 let delivered: bool = self.conn.query_row("SELECT EXISTS(SELECT 1 FROM _syncular_row_deliveries WHERE tbl=?1 AND id=?2 AND commit_seq>=?3)", rusqlite::params![operation.table, operation.row_id, sequence], |row| row.get(0))
18055 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18056 if delivered {
18057 continue;
18058 }
18059 let table = self
18060 .schema
18061 .table(&operation.table)
18062 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18063 let mut intended: Map<String, Value> = self
18064 .stored_row(table, &operation.row_id, true)?
18065 .map(|(row, _)| {
18066 table
18067 .columns
18068 .iter()
18069 .zip(&row)
18070 .map(|(column, value)| {
18071 (column.name.clone(), column_value_to_json(value))
18072 })
18073 .collect()
18074 })
18075 .unwrap_or_default();
18076 let mut scope_image = intended.clone();
18077 for op in prior
18078 .iter()
18079 .chain(
18080 self.failed_commits
18081 .iter()
18082 .flat_map(|(commit, _)| &commit.ops),
18083 )
18084 .chain(
18085 self.outbox
18086 .iter()
18087 .take(index)
18088 .flat_map(|commit| &commit.ops),
18089 )
18090 .chain(operations.iter().take(op_index + 1))
18091 {
18092 if op.table != operation.table || op.row_id != operation.row_id {
18093 continue;
18094 }
18095 if let Some(values) = &op.values {
18096 intended.extend(values.clone());
18097 } else {
18098 scope_image = intended.clone();
18099 intended.clear();
18100 }
18101 }
18102 self.conn.execute("INSERT INTO _syncular_acked_rows(commit_id,idx,tbl,id,commit_seq,op_json,intent_json) VALUES (?1,?2,?3,?4,?5,?6,?7)",
18103 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18104 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18105 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18106 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18107 }
18108 let journal_results = results
18109 .iter()
18110 .map(|result| {
18111 let op_index = match result {
18112 OpResult::Applied { op_index }
18113 | OpResult::Conflict { op_index, .. }
18114 | OpResult::Error { op_index, .. } => *op_index,
18115 };
18116 CommitOperationOutcome::Applied { op_index }
18117 })
18118 .collect::<Vec<_>>();
18119 let outcome_status = if status == PushStatus::Applied {
18120 CommitOutcomeStatus::Applied
18121 } else {
18122 CommitOutcomeStatus::Cached
18123 };
18124 let persisted = self
18125 .persist_commit_outcome(
18126 client_commit_id,
18127 outcome_status,
18128 &journal_results,
18129 None,
18130 )
18131 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18132 persisted?;
18133 for operation in &operations {
18134 self.overlay_dirty.table(&operation.table);
18135 }
18136 self.sync_needed = true;
18137 batch.status = true;
18138 batch.outcomes = true;
18139 }
18140 PushStatus::Rejected => {
18141 if results.iter().any(|result| {
18142 matches!(
18143 result,
18144 OpResult::Error {
18145 code,
18146 retryable: true,
18147 ..
18148 } if code == "sync.idempotency_cache_miss"
18149 )
18150 }) {
18151 report.retryable.push(client_commit_id.to_owned());
18154 continue;
18155 }
18156
18157 let mut journal_results = Vec::with_capacity(results.len());
18158 let mut conflicts = Vec::new();
18159 let mut rejections = Vec::new();
18160 for result in results {
18161 match result {
18162 OpResult::Applied { op_index } => {
18163 journal_results.push(CommitOperationOutcome::Applied {
18164 op_index: *op_index,
18165 });
18166 }
18167 OpResult::Conflict {
18168 op_index,
18169 code,
18170 message,
18171 server_version,
18172 server_row,
18173 conflict_columns: conflict_columns_bitmap,
18174 } => {
18175 let operation = operations
18176 .get(*op_index as usize)
18177 .map(CommitOperation::from);
18178 let (table, row_id) = operation
18179 .as_ref()
18180 .map(|op| (op.table.clone(), op.row_id.clone()))
18181 .unwrap_or_default();
18182 let server_row_json = self
18183 .schema
18184 .table(&table)
18185 .and_then(|t| {
18186 decode_row_bytes(t, server_row, &self.encryption)
18187 .ok()
18188 .map(|row| (t, row))
18189 })
18190 .map(|(t, row)| {
18191 let mut map = Map::new();
18192 for (i, column) in t.columns.iter().enumerate() {
18193 map.insert(
18194 column.name.clone(),
18195 column_value_to_json(
18196 row.get(i).unwrap_or(&None),
18197 ),
18198 );
18199 }
18200 map
18201 })
18202 .unwrap_or_default();
18203 let conflict_columns = self
18206 .schema
18207 .table(&table)
18208 .map(|t| {
18209 t.columns
18210 .iter()
18211 .enumerate()
18212 .filter(|(index, _)| {
18213 conflict_columns_bitmap
18214 .get(index / 8)
18215 .is_some_and(|byte| {
18216 byte & (1 << (index % 8)) != 0
18217 })
18218 })
18219 .map(|(_, column)| column.name.clone())
18220 .collect::<Vec<_>>()
18221 })
18222 .unwrap_or_default();
18223 let conflict = ConflictRecord {
18224 client_commit_id: client_commit_id.to_owned(),
18225 op_index: *op_index,
18226 table,
18227 row_id,
18228 code: code.clone(),
18229 message: message.clone(),
18230 server_version: *server_version,
18231 server_row: server_row_json,
18232 conflict_columns,
18233 operation,
18234 };
18235 journal_results.push(CommitOperationOutcome::Conflict {
18236 conflict: conflict.clone(),
18237 });
18238 conflicts.push(conflict);
18239 }
18240 OpResult::Error {
18241 op_index,
18242 code,
18243 message,
18244 retryable,
18245 } => {
18246 let rejection = RejectionRecord {
18247 client_commit_id: client_commit_id.to_owned(),
18248 op_index: *op_index,
18249 code: code.clone(),
18250 message: message.clone(),
18251 retryable: *retryable,
18252 details: rejection_details
18253 .and_then(|details| details.get(op_index))
18254 .cloned(),
18255 operation: operations
18256 .get(*op_index as usize)
18257 .map(CommitOperation::from),
18258 };
18259 journal_results.push(CommitOperationOutcome::Error {
18260 rejection: rejection.clone(),
18261 });
18262 rejections.push(rejection);
18263 }
18264 }
18265 }
18266 if self.retain_failed_commits {
18267 self.retain_failed_commit(client_commit_id, &operations)?;
18268 }
18269 let outcome_status = if conflicts.is_empty() {
18270 CommitOutcomeStatus::Rejected
18271 } else {
18272 CommitOutcomeStatus::Conflict
18273 };
18274 let persisted = self
18275 .persist_commit_outcome(
18276 client_commit_id,
18277 outcome_status,
18278 &journal_results,
18279 Some(&operations),
18280 )
18281 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18282 persisted?;
18283 batch.conflicts = !conflicts.is_empty();
18284 batch.rejections = !rejections.is_empty();
18285 batch.status = true;
18286 batch.outcomes = true;
18287 self.conflicts.extend(conflicts);
18288 self.rejections.extend(rejections);
18289 for operation in &operations {
18290 self.overlay_dirty.table(&operation.table);
18291 }
18292 }
18293 }
18294 removed.push((index, self.outbox.remove(index)));
18295 if batch.status {
18296 for operation in &operations {
18297 if !self.record_row_scopes(
18298 &mut batch,
18299 &operation.table,
18300 &operation.row_id,
18301 false,
18302 ) {
18303 batch.table(&operation.table);
18304 }
18305 }
18306 }
18307 match status {
18308 PushStatus::Applied | PushStatus::Cached => {
18309 report.applied.push(client_commit_id.to_owned())
18310 }
18311 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18312 }
18313 }
18314 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)) {
18315 self.prune_commit_outcomes()?;
18316 }
18317 self.rebuild_overlay_if_dirty()?;
18318 self.finish_observation("syncular_push_results", batch)
18319 })();
18320 if let Err(error) = persisted {
18321 self.rollback_observation("syncular_push_results");
18322 self.failed_commits = previous_failed;
18323 self.sync_needed = previous_sync_needed;
18324 for (index, commit) in removed.into_iter().rev() {
18325 self.outbox.insert(index, commit);
18326 }
18327 self.conflicts.truncate(conflict_count);
18328 self.rejections.truncate(rejection_count);
18329 self.overlay_dirty.restore(was_dirty.clone());
18330 report.applied.truncate(applied_count);
18331 report.rejected.truncate(rejected_count);
18332 report.retryable.truncate(retryable_count);
18333 return Err(error);
18334 }
18335 if !previous_sync_needed && self.sync_needed {
18336 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18337 }
18338 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18339 Ok(())
18340 }
18341
18342 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18343 status != PushStatus::Rejected
18344 || !results.iter().any(|result| {
18345 matches!(
18346 result,
18347 OpResult::Error {
18348 code,
18349 retryable: true,
18350 ..
18351 } if code == "sync.idempotency_cache_miss"
18352 )
18353 })
18354 }
18355
18356 #[allow(clippy::too_many_arguments)]
18359 fn process_section(
18360 &mut self,
18361 transport: &mut dyn Transport,
18362 id: &str,
18363 status: SubStatus,
18364 reason_code: &str,
18365 effective_scopes: Vec<(String, Vec<String>)>,
18366 body: Vec<Frame>,
18367 sub_end: Option<(i64, Option<String>)>,
18368 meta: &RequestMeta,
18369 report: &mut SyncReport,
18370 coalesced_deletes: &HashSet<(i64, String, String)>,
18371 ) -> Result<(), SectionError> {
18372 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18373 return Ok(()); };
18375 if status != SubStatus::Active && sub_end.is_none() {
18376 return Err(SectionError::Abort(
18377 "sync.invalid_request".into(),
18378 "subscription section without SUB_END".into(),
18379 ));
18380 }
18381 match status {
18382 SubStatus::Revoked => {
18383 self.begin_observation("syncular_revocation")
18384 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18385 let previous = self.subs[sub_index].clone();
18386 let was_dirty = self.overlay_dirty.snapshot();
18387 let previous_outbox = self.outbox.clone();
18388 let previous_failed = self.failed_commits.clone();
18389 let previous_rejections = self.rejections.clone();
18390 let previous_conflicts = self.conflicts.clone();
18391 let revoked_count = report.revoked.len();
18392 let failed_count = report.failed.len();
18393 let result = (|| {
18394 let mut batch = ChangeAccumulator::default();
18395 let registered = self.window_unit_by_sub_id(id);
18396 let (table, effective) = {
18398 let sub = &self.subs[sub_index];
18399 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18400 };
18401 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18402 SectionError::Abort(
18403 "sync.unknown_table".to_owned(),
18404 "revoked scope references an unknown table".to_owned(),
18405 )
18406 })?;
18407 let dropped = self
18408 .drop_failed_commits(|name, values| {
18409 name == table
18410 && !effective.is_empty()
18411 && effective.iter().all(|(variable, allowed)| {
18412 scoped_table
18413 .scope_column(variable)
18414 .and_then(|column| values.get(column))
18415 .and_then(Value::as_str)
18416 .is_some_and(|value| {
18417 allowed.iter().any(|allowed| allowed == value)
18418 })
18419 })
18420 })
18421 .map_err(|message| {
18422 SectionError::Abort("storage.failed".to_owned(), message)
18423 })?;
18424 for commit in dropped {
18425 batch.conflicts = true;
18426 batch.rejections = true;
18427 batch.outcomes = true;
18428 for operation in commit.ops {
18429 batch.table(&operation.table);
18430 }
18431 }
18432 let purged = self.purge_scope_rows(&table, &effective);
18433 match purged {
18434 Ok(()) => {
18435 self.record_scope_map(&mut batch, &table, &effective);
18436 let sub = &mut self.subs[sub_index];
18437 sub.state = SubState::Revoked;
18438 sub.reason_code = Some(if reason_code.is_empty() {
18439 "sync.scope_revoked".to_owned()
18440 } else {
18441 reason_code.to_owned()
18442 });
18443 report.revoked.push(id.to_owned());
18444 let doomed_effective = effective;
18445 let sub_table = table;
18446 self.persist_sub(&self.subs[sub_index].clone())
18447 .map_err(|message| {
18448 SectionError::Abort("storage.failed".into(), message)
18449 })?;
18450 let dropped = self
18451 .drop_doomed_outbox(&sub_table, &doomed_effective)
18452 .map_err(|message| {
18453 SectionError::Abort("storage.failed".to_owned(), message)
18454 })?;
18455 if dropped {
18456 batch.status = true;
18457 batch.rejections = true;
18458 batch.outcomes = true;
18459 }
18460 self.delete_unreferenced_cached_blobs().map_err(|message| {
18463 SectionError::Abort("storage.failed".into(), message)
18464 })?;
18465 }
18466 Err(()) => {
18467 let sub = &mut self.subs[sub_index];
18470 sub.state = SubState::Failed;
18471 sub.reason_code = Some("sync.scope_revoked".to_owned());
18472 report.failed.push(id.to_owned());
18473 self.persist_sub(&self.subs[sub_index].clone())
18474 .map_err(|message| {
18475 SectionError::Abort("storage.failed".into(), message)
18476 })?;
18477 }
18478 }
18479 if let Some((base_key, unit)) = registered {
18480 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18481 }
18482 self.rebuild_overlay_if_dirty()
18483 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18484 self.finish_observation("syncular_revocation", batch)
18485 .map_err(|message| {
18486 SectionError::Abort("storage.failed".to_owned(), message)
18487 })?;
18488 Ok(())
18489 })();
18490 if result.is_err() {
18491 self.rollback_observation("syncular_revocation");
18492 self.subs[sub_index] = previous;
18493 self.overlay_dirty.restore(was_dirty.clone());
18494 self.outbox = previous_outbox;
18495 self.failed_commits = previous_failed;
18496 self.rejections = previous_rejections;
18497 self.conflicts = previous_conflicts;
18498 report.revoked.truncate(revoked_count);
18499 report.failed.truncate(failed_count);
18500 }
18501 result
18502 }
18503 SubStatus::Reset => {
18504 self.begin_observation("syncular_reset")
18505 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18506 let previous = self.subs[sub_index].clone();
18507 let was_dirty = self.overlay_dirty.snapshot();
18508 let reset_count = report.resets.len();
18509 let result = (|| {
18510 let mut batch = ChangeAccumulator::default();
18511 let registered = self.window_unit_by_sub_id(id);
18512 let sub = &mut self.subs[sub_index];
18515 sub.cursor = -1;
18516 sub.bootstrap_state = None;
18517 sub.reason_code = Some(if reason_code.is_empty() {
18518 "sync.cursor_expired".to_owned()
18519 } else {
18520 reason_code.to_owned()
18521 });
18522 report.resets.push(id.to_owned());
18523 self.persist_sub(&self.subs[sub_index].clone())
18524 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18525 if let Some((base_key, unit)) = registered {
18526 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18527 }
18528 self.finish_observation("syncular_reset", batch)
18529 .map_err(|message| {
18530 SectionError::Abort("storage.failed".to_owned(), message)
18531 })?;
18532 Ok(())
18533 })();
18534 if result.is_err() {
18535 self.rollback_observation("syncular_reset");
18536 self.subs[sub_index] = previous;
18537 self.overlay_dirty.restore(was_dirty.clone());
18538 report.resets.truncate(reset_count);
18539 }
18540 result
18541 }
18542 SubStatus::Active => {
18543 let fresh = meta
18544 .fresh
18545 .iter()
18546 .find(|(fid, _)| fid == id)
18547 .map(|(_, fresh)| *fresh)
18548 .unwrap_or(false);
18549 let previous = self.subs[sub_index].clone();
18550 self.subs[sub_index].effective = Some(effective_scopes);
18551 let outcome = self
18552 .apply_section_body(
18553 transport,
18554 sub_index,
18555 body,
18556 fresh,
18557 meta,
18558 report,
18559 coalesced_deletes,
18560 )
18561 .and_then(|()| {
18562 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18563 SectionError::Abort(
18564 "sync.invalid_request".into(),
18565 "subscription section without SUB_END".into(),
18566 )
18567 })?;
18568 self.apply_sub_end(sub_index, cursor, bootstrap_state)
18569 .map_err(|message| {
18570 SectionError::Abort("storage.failed".into(), message)
18571 })
18572 });
18573 match outcome {
18574 Ok(()) => {
18575 if self.subs[sub_index].bootstrap_state.is_some() {
18576 report.bootstrapping.push(id.to_owned());
18577 }
18578 Ok(())
18579 }
18580 Err(SectionError::FailClosed) => {
18581 self.subs[sub_index] = previous.clone();
18582 self.begin_observation("syncular_section_failure")
18583 .map_err(|message| {
18584 SectionError::Abort("storage.failed".into(), message)
18585 })?;
18586 let persisted = (|| {
18587 let mut batch = ChangeAccumulator::default();
18588 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18589 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18590 }
18591 self.subs[sub_index].state = SubState::Failed;
18592 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18593 self.persist_sub(&self.subs[sub_index])?;
18594 self.finish_observation("syncular_section_failure", batch)
18595 })();
18596 if let Err(message) = persisted {
18597 self.rollback_observation("syncular_section_failure");
18598 self.subs[sub_index] = previous;
18599 return Err(SectionError::Abort("storage.failed".into(), message));
18600 }
18601 report.failed.push(id.to_owned());
18602 Ok(())
18603 }
18604 Err(error) => {
18605 self.subs[sub_index] = previous;
18608 Err(error)
18609 }
18610 }
18611 }
18612 }
18613 }
18614
18615 fn apply_sub_end(
18616 &mut self,
18617 sub_index: usize,
18618 cursor: i64,
18619 bootstrap_state: Option<String>,
18620 ) -> Result<(), String> {
18621 #[cfg(feature = "bench-internals")]
18622 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18623 let previous = self.subs[sub_index].clone();
18624 self.begin_observation("syncular_sub_end")?;
18625 let persisted = (|| {
18626 let mut batch = ChangeAccumulator::default();
18627 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18628 && cursor >= 0
18629 && bootstrap_state.is_none();
18630 if completed {
18631 let table = self
18632 .schema
18633 .table(&previous.table)
18634 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18635 let effective = previous.effective.as_deref().unwrap_or(&[]);
18636 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18637 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18638 let rows = statement
18639 .query_map(rusqlite::params![previous.table, cursor], |row| {
18640 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18641 })
18642 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18643 let mut covered = HashSet::new();
18644 for row in rows {
18645 let (id, intent) =
18646 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18647 let values: Map<String, Value> = serde_json::from_str(&intent)
18648 .map_err(|_| "sync.local_corrupt".to_owned())?;
18649 if !effective.is_empty()
18650 && effective.iter().all(|(variable, allowed)| {
18651 table
18652 .scope_column(variable)
18653 .and_then(|column| values.get(column))
18654 .is_some_and(|value| {
18655 allowed.contains(
18656 &value
18657 .as_str()
18658 .map_or_else(|| value.to_string(), str::to_owned),
18659 )
18660 })
18661 })
18662 {
18663 covered.insert(id);
18664 }
18665 }
18666 drop(statement);
18667 for id in covered {
18668 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18669 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18670 self.overlay_dirty.table(&previous.table);
18671 batch.table(&previous.table);
18672 }
18673 self.rebuild_overlay_if_dirty()?;
18674 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18675 batch.window(&base_key, &previous.table, &unit);
18676 }
18677 }
18678 let sub = &mut self.subs[sub_index];
18679 sub.cursor = cursor;
18680 sub.bootstrap_state = bootstrap_state;
18681 sub.reason_code = None;
18684 sub.synced_once = true;
18685 self.persist_sub(&self.subs[sub_index])?;
18686 self.finish_observation("syncular_sub_end", batch)
18687 })();
18688 if persisted.is_err() {
18689 self.rollback_observation("syncular_sub_end");
18690 self.subs[sub_index] = previous;
18691 }
18692 persisted
18693 }
18694
18695 #[allow(clippy::too_many_arguments)]
18696 fn apply_section_body(
18697 &mut self,
18698 transport: &mut dyn Transport,
18699 sub_index: usize,
18700 body: Vec<Frame>,
18701 fresh: bool,
18702 meta: &RequestMeta,
18703 report: &mut SyncReport,
18704 coalesced_deletes: &HashSet<(i64, String, String)>,
18705 ) -> Result<(), SectionError> {
18706 let mut saw_segment = false;
18707 for frame in body {
18708 match frame {
18709 Frame::Commit {
18710 tables,
18711 changes,
18712 commit_seq,
18713 ..
18714 } => {
18715 if self
18716 .apply_commit_frame_coalesced(
18717 &tables,
18718 changes,
18719 commit_seq,
18720 coalesced_deletes,
18721 )
18722 .map_err(|(c, m)| SectionError::Abort(c, m))?
18723 {
18724 report.commits_applied += 1;
18725 }
18726 }
18727 Frame::SegmentInline { payload } => {
18728 let segment = decode_rows_segment(&payload)
18729 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18730 self.progress.update(|p| {
18731 p.phase = ProgressPhase::Import;
18732 p.subscription_id = Some(self.subs[sub_index].id.clone());
18733 p.table = Some(self.subs[sub_index].table.clone());
18734 p.segment_id = None;
18735 p.bytes_received = payload.len() as u64;
18736 p.bytes_total = Some(payload.len() as u64);
18737 p.rows_processed = 0;
18738 p.rows_total =
18739 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18740 });
18741 let first = !saw_segment;
18742 saw_segment = true;
18743 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18744 report.segment_rows_applied += applied;
18745 }
18746 Frame::SegmentRef {
18747 segment_id,
18748 byte_length,
18749 media_type,
18750 table,
18751 row_count,
18752 as_of_commit_seq,
18753 scope_digest,
18754 row_cursor,
18755 next_row_cursor,
18756 url,
18757 url_expires_at_ms,
18758 ..
18759 } => {
18760 let advertised = match media_type {
18763 MediaType::Rows => {
18764 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18765 || meta.accept & ACCEPT_INLINE_ROWS != 0
18766 }
18767 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18768 };
18769 if !advertised {
18770 return Err(SectionError::Abort(
18771 "sync.invalid_request".to_owned(),
18772 format!(
18773 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18774 media_type.name()
18775 ),
18776 ));
18777 }
18778 self.progress.update(|p| {
18779 p.phase = ProgressPhase::Download;
18780 p.subscription_id = Some(self.subs[sub_index].id.clone());
18781 p.table = Some(table.clone());
18782 p.segment_id = Some(segment_id.clone());
18783 p.bytes_received = 0;
18784 p.bytes_total = u64::try_from(byte_length).ok();
18785 p.rows_processed = 0;
18786 p.rows_total = u64::try_from(row_count).ok();
18787 });
18788 let observer = self.progress.clone();
18789 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
18790 let bytes = if let Some(url) = url {
18791 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
18796 return Err(SectionError::Abort(
18797 "sync.invalid_request".to_owned(),
18798 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
18799 .to_owned(),
18800 ));
18801 }
18802 if url_expires_at_ms.is_some_and(|exp| {
18804 exp <= transport
18805 .segment_fetch_started_at(&url)
18806 .unwrap_or_else(|| self.clock_now_ms())
18807 }) {
18808 return Err(SectionError::Abort(
18809 "sync.segment_expired".to_owned(),
18810 format!(
18811 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
18812 ),
18813 ));
18814 }
18815 transport
18816 .fetch_url(&url, &mut on_progress)
18817 .map_err(|e| self.segment_fetch_failed(e))?
18818 } else {
18819 let requested_scopes_json =
18820 canonical_scope_json(&self.subs[sub_index].requested);
18821 transport
18822 .download_segment(
18823 &SegmentRequest {
18824 segment_id: segment_id.clone(),
18825 table,
18826 requested_scopes_json,
18827 },
18828 &mut on_progress,
18829 )
18830 .map_err(|e| self.segment_fetch_failed(e))?
18831 };
18832 on_progress(bytes.len() as u64);
18834 let digest = Sha256::digest(&bytes);
18835 let expected = segment_id
18836 .strip_prefix("sha256:")
18837 .unwrap_or(segment_id.as_str());
18838 if bytes_to_hex(&digest) != expected {
18839 self.schedule_background_retry();
18841 return Err(SectionError::Abort(
18842 "sync.invalid_request".to_owned(),
18843 "segment bytes do not match the content address (§5.1)".to_owned(),
18844 ));
18845 }
18846 self.progress.update(|p| p.phase = ProgressPhase::Import);
18847 if media_type == MediaType::Sqlite {
18848 if row_cursor.is_some() || next_row_cursor.is_some() {
18851 return Err(SectionError::Abort(
18852 "sync.invalid_request".to_owned(),
18853 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
18854 .to_owned(),
18855 ));
18856 }
18857 let first = !saw_segment;
18858 saw_segment = true;
18859 let applied = self.apply_sqlite_segment(
18860 sub_index,
18861 &bytes,
18862 fresh && first,
18863 row_count,
18864 as_of_commit_seq,
18865 &scope_digest,
18866 )?;
18867 report.segment_rows_applied += applied;
18868 } else {
18869 let segment = decode_rows_segment(&bytes).map_err(|e| {
18870 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
18871 })?;
18872 let first = row_cursor.is_none();
18873 saw_segment = true;
18874 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18875 report.segment_rows_applied += applied;
18876 }
18877 }
18878 Frame::Unknown { .. } => {}
18879 Frame::Error { code, message, .. } => {
18880 return Err(SectionError::Abort(code, message))
18881 }
18882 _ => {
18883 return Err(SectionError::Abort(
18884 "sync.invalid_request".to_owned(),
18885 "unexpected frame inside a subscription section".to_owned(),
18886 ));
18887 }
18888 }
18889 }
18890 Ok(())
18891 }
18892
18893 fn apply_commit_frame_coalesced(
18897 &mut self,
18898 tables: &[String],
18899 changes: Vec<ssp2::model::Change>,
18900 commit_seq: i64,
18901 coalesced_deletes: &HashSet<(i64, String, String)>,
18902 ) -> Result<bool, (String, String)> {
18903 let changes: Vec<ssp2::model::Change> = changes
18904 .into_iter()
18905 .filter(|change| {
18906 if change.op != Op::Delete {
18907 return true;
18908 }
18909 let Some(table) = tables.get(change.table_index as usize) else {
18910 return true;
18911 };
18912 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
18913 })
18914 .collect();
18915 if changes.is_empty() {
18916 return Ok(false);
18917 }
18918 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
18919 Ok(true)
18920 }
18921
18922 fn apply_commit_frame(
18924 &mut self,
18925 tables: &[String],
18926 changes: &[ssp2::model::Change],
18927 commit_seq: Option<i64>,
18928 ) -> Result<(), (String, String)> {
18929 #[cfg(feature = "bench-internals")]
18930 let _phase = self.benchmark_phases.start(Phase::CommitApply);
18931 let was_dirty = self.overlay_dirty.snapshot();
18932 self.coalesce_unit(changes.len())
18933 .and_then(|()| self.begin_observation("syncular_commit"))
18934 .map_err(|message| ("storage.failed".into(), message))?;
18935 let applied = (|| {
18936 let mut batch = ChangeAccumulator::default();
18937 self.record_commit_changes(&mut batch, tables, changes);
18938 if let Some(sequence) = commit_seq {
18939 for change in changes {
18940 let table = tables.get(change.table_index as usize).ok_or_else(|| {
18941 (
18942 "sync.invalid_request".to_owned(),
18943 "change tableIndex out of range".to_owned(),
18944 )
18945 })?;
18946 if self
18947 .outbox
18948 .iter()
18949 .flat_map(|commit| &commit.ops)
18950 .any(|op| op.table == *table && op.row_id == change.row_id)
18951 {
18952 self.conn.execute("INSERT INTO _syncular_row_deliveries(tbl,id,commit_seq) VALUES (?1,?2,?3) ON CONFLICT(tbl,id) DO UPDATE SET commit_seq=MAX(commit_seq,excluded.commit_seq)", rusqlite::params![table, change.row_id, sequence])
18953 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18954 }
18955 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![table, change.row_id, sequence])
18956 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
18957 }
18958 }
18959 self.apply_commit_changes(tables, changes)?;
18960 self.rebuild_overlay_if_dirty()
18961 .map_err(|message| ("storage.failed".into(), message))?;
18962 self.finish_observation("syncular_commit", batch)
18963 .map_err(|message| ("storage.failed".into(), message))
18964 })();
18965 if applied.is_err() {
18966 self.rollback_observation("syncular_commit");
18967 self.overlay_dirty.restore(was_dirty.clone());
18968 }
18969 applied
18970 }
18971
18972 fn apply_commit_changes(
18973 &mut self,
18974 tables: &[String],
18975 changes: &[ssp2::model::Change],
18976 ) -> Result<(), (String, String)> {
18977 let mirror_visible = !self.overlay_dirty.get()
18982 && self.failed_commits.is_empty()
18983 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
18984 || changes.iter().all(|change| {
18985 tables
18986 .get(change.table_index as usize)
18987 .and_then(|name| self.schema.table(name))
18988 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
18989 }));
18990 for change in changes {
18991 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
18992 (
18993 "sync.invalid_request".to_owned(),
18994 "change tableIndex out of range".to_owned(),
18995 )
18996 })?;
18997 let table = self.schema.table(table_name).ok_or_else(|| {
18998 (
18999 "sync.schema_mismatch".to_owned(),
19000 format!("change targets unknown table {table_name:?}"),
19001 )
19002 })?;
19003 match change.op {
19004 Op::Upsert => {
19005 let payload = change.row.as_ref().ok_or_else(|| {
19006 (
19007 "sync.invalid_request".to_owned(),
19008 "upsert change without row payload".to_owned(),
19009 )
19010 })?;
19011 let row = {
19013 #[cfg(feature = "bench-internals")]
19014 let _phase = self.benchmark_phases.start(Phase::RowDecode);
19015 decode_row_bytes(table, payload, &self.encryption)
19016 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19017 };
19018 let version = change.row_version.unwrap_or(0);
19019 let table_name = table.name.clone();
19020 self.write_base_row(&table_name, &row, version)
19021 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19022 if mirror_visible {
19023 self.write_row(&visible_table(&table_name), &table_name, &row, version)
19024 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19025 }
19026 }
19027 Op::Delete => {
19028 self.delete_base_row(table_name, &change.row_id)
19029 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19030 if mirror_visible {
19031 let sql = format!(
19032 "DELETE FROM {} WHERE {}",
19033 visible_table(table_name),
19034 row_id_predicate(table)
19035 );
19036 self.conn
19037 .prepare_cached(&sql)
19038 .and_then(|mut statement| {
19039 statement.execute(rusqlite::params![change.row_id])
19040 })
19041 .map_err(|error| {
19042 ("sync.invalid_request".to_owned(), error.to_string())
19043 })?;
19044 }
19045 }
19046 }
19047 }
19048 if mirror_visible {
19049 {
19050 let changed_rows: HashSet<(&str, &str)> = changes
19051 .iter()
19052 .map(|change| {
19053 (
19054 tables[change.table_index as usize].as_str(),
19055 change.row_id.as_str(),
19056 )
19057 })
19058 .collect();
19059 let acknowledged = self
19060 .acknowledged_operations(Some(&changed_rows), None)
19061 .map_err(|error| ("storage.failed".to_owned(), error))?;
19062 self.apply_outbox_ops(&acknowledged)
19063 .map_err(|message| ("storage.failed".into(), message))?;
19064 self.apply_outbox_ops(
19065 self.outbox
19066 .iter()
19067 .flat_map(|commit| &commit.ops)
19068 .filter(|op| {
19069 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19070 }),
19071 )
19072 .map_err(|message| ("storage.failed".into(), message))?;
19073 }
19074 self.overlay_dirty.set(false);
19075 }
19076 Ok(())
19077 }
19078
19079 fn apply_segment(
19084 &mut self,
19085 sub_index: usize,
19086 segment: &RowsSegment,
19087 first_fresh_page: bool,
19088 ) -> Result<u32, SectionError> {
19089 let (sub_table, effective) = {
19090 let sub = &self.subs[sub_index];
19091 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19092 };
19093 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19094 SectionError::Abort(
19095 "sync.schema_mismatch".to_owned(),
19096 format!("subscription table {sub_table:?} is not in the client schema"),
19097 )
19098 })?;
19099 let matches = segment.table == table.name
19104 && segment.schema_version == self.schema.version
19105 && segment.columns.len() == table.wire_columns.len()
19106 && segment
19107 .columns
19108 .iter()
19109 .zip(table.wire_columns.iter())
19110 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19111 if !matches {
19112 return Err(SectionError::Abort(
19113 "sync.schema_mismatch".to_owned(),
19114 "segment column table does not match the generated schema (§5.2)".to_owned(),
19115 ));
19116 }
19117 let mut applied = 0u32;
19118 let empty = Vec::new();
19119 let blocks = if segment.blocks.is_empty() {
19120 std::slice::from_ref(&empty)
19121 } else {
19122 &segment.blocks
19123 };
19124 for (index, block) in blocks.iter().enumerate() {
19125 let clear = first_fresh_page && index == 0;
19126 if !clear && block.is_empty() {
19127 continue;
19128 }
19129 let was_dirty = self.overlay_dirty.snapshot();
19130 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19131 && self
19132 .outbox
19133 .iter()
19134 .flat_map(|commit| &commit.ops)
19135 .any(|op| op.table == table.name);
19136 let mirror_visible = !reconcile_pending
19137 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19138 self.coalesce_unit(block.len())
19139 .and_then(|()| self.begin_observation("syncular_segment_block"))
19140 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19141 let outcome = (|| {
19142 self.rebuild_overlay_if_dirty()
19143 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19144 let mut changed_keys = Vec::new();
19145 let mut batch = ChangeAccumulator::default();
19146 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19147 batch.table(&table.name);
19148 }
19149 if clear {
19150 self.purge_scope_rows(&table.name, &effective)
19151 .map_err(|()| SectionError::FailClosed)?;
19152 }
19153 for (row_index, row) in block.iter().enumerate() {
19154 let decrypted;
19155 let values = if table.has_encrypted_columns() {
19156 let mut values = row.values.clone();
19157 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19158 .map_err(|message| {
19159 SectionError::Abort("client.decrypt_failed".into(), message)
19160 })?;
19161 decrypted = values;
19162 &decrypted
19163 } else {
19164 &row.values
19165 };
19166 self.write_base_row(&table.name, values, row.server_version)
19167 .map_err(|message| {
19168 SectionError::Abort("sync.invalid_request".into(), message)
19169 })?;
19170 if reconcile_pending {
19171 changed_keys.push(
19172 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19173 |message| SectionError::Abort("storage.failed".into(), message),
19174 )?,
19175 );
19176 }
19177 if mirror_visible {
19178 self.write_row(
19179 &visible_table(&table.name),
19180 &table.name,
19181 values,
19182 row.server_version,
19183 )
19184 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19185 }
19186 let processed = u64::from(applied) + row_index as u64 + 1;
19187 if processed.is_multiple_of(1024) {
19188 self.progress.update(|p| p.rows_processed = processed);
19189 }
19190 }
19191 if reconcile_pending {
19192 self.reconcile_imported_rows(&table, &mut changed_keys)
19193 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19194 }
19195 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19196 self.apply_outbox_ops(
19197 self.outbox
19198 .iter()
19199 .flat_map(|commit| &commit.ops)
19200 .filter(|op| op.table == table.name),
19201 )
19202 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19203 self.overlay_dirty.set(false);
19204 } else {
19205 self.rebuild_overlay_if_dirty()
19206 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19207 }
19208 self.finish_observation("syncular_segment_block", batch)
19209 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19210 })();
19211 if let Err(error) = outcome {
19212 self.rollback_observation("syncular_segment_block");
19213 self.overlay_dirty.restore(was_dirty.clone());
19214 return Err(error);
19215 }
19216 applied += block.len() as u32;
19217 self.progress
19218 .update(|p| p.rows_processed = u64::from(applied));
19219 std::thread::yield_now();
19220 }
19221 self.progress
19222 .update(|p| p.rows_processed = u64::from(applied));
19223 Ok(applied)
19224 }
19225
19226 fn apply_sqlite_segment(
19234 &mut self,
19235 sub_index: usize,
19236 bytes: &[u8],
19237 first_fresh_page: bool,
19238 row_count: i64,
19239 as_of_commit_seq: i64,
19240 scope_digest: &str,
19241 ) -> Result<u32, SectionError> {
19242 let invalid = |detail: &str| {
19243 SectionError::Abort(
19244 "sync.invalid_request".to_owned(),
19245 format!("sqlite segment rejected: {detail} (§5.3)"),
19246 )
19247 };
19248 let (sub_table, effective) = {
19249 let sub = &self.subs[sub_index];
19250 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19251 };
19252 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19253 SectionError::Abort(
19254 "sync.schema_mismatch".to_owned(),
19255 format!("subscription table {sub_table:?} is not in the client schema"),
19256 )
19257 })?;
19258
19259 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19260 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19261 let img = match rusqlite::Connection::open_with_flags(
19262 &path,
19263 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19264 ) {
19265 Ok(conn) => conn,
19266 Err(_) => {
19267 let _ = std::fs::remove_file(&path);
19268 return Err(invalid("bytes do not open as a SQLite database"));
19269 }
19270 };
19271 let outcome = self.apply_sqlite_image(
19272 &img,
19273 &table,
19274 first_fresh_page,
19275 &effective,
19276 row_count,
19277 as_of_commit_seq,
19278 scope_digest,
19279 );
19280 drop(img);
19281 let _ = std::fs::remove_file(&path);
19282 outcome
19283 }
19284
19285 #[allow(clippy::too_many_arguments)]
19286 fn apply_sqlite_image(
19287 &mut self,
19288 img: &rusqlite::Connection,
19289 table: &crate::schema::TableSchema,
19290 first_fresh_page: bool,
19291 effective: &[(String, Vec<String>)],
19292 row_count: i64,
19293 as_of_commit_seq: i64,
19294 scope_digest: &str,
19295 ) -> Result<u32, SectionError> {
19296 let invalid = |detail: String| {
19297 SectionError::Abort(
19298 "sync.invalid_request".to_owned(),
19299 format!("sqlite segment rejected: {detail} (§5.3)"),
19300 )
19301 };
19302
19303 type MetaRow = (i64, String, i64, i64, String, i64, i64);
19306 let meta: MetaRow = img
19307 .query_row(
19308 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19309 \"scopeDigest\", \"rowCount\",
19310 (SELECT count(*) FROM _syncular_segment)
19311 FROM _syncular_segment",
19312 [],
19313 |row| {
19314 Ok((
19315 row.get(0)?,
19316 row.get(1)?,
19317 row.get(2)?,
19318 row.get(3)?,
19319 row.get(4)?,
19320 row.get(5)?,
19321 row.get(6)?,
19322 ))
19323 },
19324 )
19325 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19326 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19327 if meta_count != 1 {
19328 return Err(invalid(format!(
19329 "_syncular_segment must contain exactly one row, found {meta_count}"
19330 )));
19331 }
19332 if format != 1 {
19333 return Err(invalid(format!("format {format}")));
19334 }
19335 if meta_table != table.name {
19336 return Err(invalid(format!("image table {meta_table:?}")));
19337 }
19338 if schema_version != i64::from(self.schema.version) {
19339 return Err(invalid(format!("schemaVersion {schema_version}")));
19340 }
19341 if pin != as_of_commit_seq {
19342 return Err(invalid(format!("asOfCommitSeq {pin}")));
19343 }
19344 if digest != scope_digest {
19345 return Err(invalid("scopeDigest mismatch".to_owned()));
19346 }
19347 if meta_rows != row_count {
19348 return Err(invalid(format!("rowCount {meta_rows}")));
19349 }
19350
19351 let mut names: Vec<String> = Vec::new();
19353 let mut primary_keys: Vec<String> = Vec::new();
19354 {
19355 let mut stmt = img
19356 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19357 .map_err(|_| invalid("image data table missing".to_owned()))?;
19358 let mut rows = stmt
19359 .query([])
19360 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19361 while let Some(row) = rows
19362 .next()
19363 .map_err(|_| invalid("image data table unreadable".to_owned()))?
19364 {
19365 if row
19366 .get::<_, i64>(5)
19367 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19368 > 0
19369 {
19370 primary_keys.push(row.get(1).map_err(|error| {
19371 invalid(Self::sqlite_failure(&self.storage_failure, error))
19372 })?);
19373 }
19374 names.push(
19375 row.get::<_, String>(1)
19376 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19377 );
19378 }
19379 }
19380 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19381 expected.push("_syncular_version");
19382 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19383 return Err(SectionError::Abort(
19384 "sync.schema_mismatch".to_owned(),
19385 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19386 ));
19387 }
19388
19389 if primary_keys != vec![table.primary_key.clone()] {
19390 return Err(invalid(
19391 "image primary key does not match generated schema".into(),
19392 ));
19393 }
19394 let actual_count: i64 = img
19395 .query_row(
19396 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19397 [],
19398 |row| row.get(0),
19399 )
19400 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19401 if actual_count != row_count {
19402 return Err(invalid("image row count does not match descriptor".into()));
19403 }
19404 let insert = self.insert_row_sql(&base_table(&table.name), table);
19405 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19406 let column_list = names
19407 .iter()
19408 .map(|n| quote_ident(n))
19409 .collect::<Vec<_>>()
19410 .join(", ");
19411 let mut stmt = img
19412 .prepare(&format!(
19413 "SELECT {column_list} FROM {} ORDER BY {}",
19414 quote_ident(&table.name),
19415 quote_ident(&table.primary_key)
19416 ))
19417 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19418 let mut rows = stmt
19419 .query([])
19420 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19421 let mut applied = 0u32;
19422 loop {
19423 let clear = applied == 0 && first_fresh_page;
19424 let was_dirty = self.overlay_dirty.snapshot();
19425 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19426 && self
19427 .outbox
19428 .iter()
19429 .flat_map(|commit| &commit.ops)
19430 .any(|op| op.table == table.name);
19431 let mirror_visible = !reconcile_pending
19432 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19433 self.coalesce_unit(1024)
19435 .and_then(|()| self.begin_observation("syncular_image_chunk"))
19436 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19437 let result = (|| {
19438 self.rebuild_overlay_if_dirty()
19439 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19440 let mut changed_keys = Vec::new();
19441 let mut batch = ChangeAccumulator::default();
19442 if clear {
19443 if self.scoped_rows_exist(&table.name, effective) {
19444 batch.table(&table.name);
19445 }
19446 self.purge_scope_rows(&table.name, effective)
19447 .map_err(|()| SectionError::FailClosed)?;
19448 }
19449 let mut processed = 0u32;
19450 {
19451 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19452 invalid(Self::sqlite_failure(&self.storage_failure, error))
19453 })?;
19454 let mut mirror = if mirror_visible {
19455 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19456 invalid(Self::sqlite_failure(&self.storage_failure, error))
19457 })?)
19458 } else {
19459 None
19460 };
19461 for _ in 0..1024 {
19462 let Some(row) = rows.next().map_err(|error| {
19463 invalid(Self::sqlite_failure(&self.storage_failure, error))
19464 })?
19465 else {
19466 break;
19467 };
19468 for (i, column) in table.columns.iter().enumerate() {
19469 let cell = row.get_ref(i).map_err(|error| {
19470 invalid(Self::sqlite_failure(&self.storage_failure, error))
19471 })?;
19472 let param = image_cell_param(column, cell).map_err(invalid)?;
19473 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19474 invalid(Self::sqlite_failure(&self.storage_failure, error))
19475 })?;
19476 if let Some(mirror) = &mut mirror {
19477 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19478 invalid(Self::sqlite_failure(&self.storage_failure, error))
19479 })?;
19480 }
19481 }
19482 let version_index = table.columns.len();
19483 let version: i64 = row.get(version_index).map_err(|error| {
19484 invalid(Self::sqlite_failure(&self.storage_failure, error))
19485 })?;
19486 if version < 1 {
19487 return Err(invalid("image row version must be positive".into()));
19488 }
19489 ins.raw_bind_parameter(version_index + 1, version)
19490 .map_err(|error| {
19491 invalid(Self::sqlite_failure(&self.storage_failure, error))
19492 })?;
19493 ins.raw_execute().map_err(|error| {
19494 invalid(Self::sqlite_failure(&self.storage_failure, error))
19495 })?;
19496 if let Some(mirror) = &mut mirror {
19497 mirror
19498 .raw_bind_parameter(version_index + 1, version)
19499 .map_err(|error| {
19500 invalid(Self::sqlite_failure(&self.storage_failure, error))
19501 })?;
19502 mirror.raw_execute().map_err(|error| {
19503 invalid(Self::sqlite_failure(&self.storage_failure, error))
19504 })?;
19505 }
19506 if reconcile_pending {
19507 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19508 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19509 )?);
19510 }
19511 processed += 1;
19512 }
19513 }
19514 if processed > 0 {
19515 batch.table(&table.name);
19516 self.overlay_dirty.table(&table.name);
19517 }
19518 if reconcile_pending {
19519 self.reconcile_imported_rows(table, &mut changed_keys)
19520 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19521 }
19522 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19523 self.apply_outbox_ops(
19524 self.outbox
19525 .iter()
19526 .flat_map(|commit| &commit.ops)
19527 .filter(|op| op.table == table.name),
19528 )
19529 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19530 self.overlay_dirty.set(false);
19531 } else {
19532 self.rebuild_overlay_if_dirty()
19533 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19534 }
19535 self.finish_observation("syncular_image_chunk", batch)
19536 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19537 Ok(processed)
19538 })();
19539 let processed = match result {
19540 Ok(processed) => processed,
19541 Err(error) => {
19542 self.rollback_observation("syncular_image_chunk");
19543 self.overlay_dirty.restore(was_dirty.clone());
19544 return Err(error);
19545 }
19546 };
19547 applied += processed;
19548 self.progress
19549 .update(|p| p.rows_processed = u64::from(applied));
19550 std::thread::yield_now();
19551 if i64::from(applied) == actual_count {
19552 break;
19553 }
19554 }
19555 Ok(applied)
19556 }
19557
19558 fn reconcile_imported_rows(
19562 &self,
19563 table: &TableSchema,
19564 keys: &mut Vec<SqlValue>,
19565 ) -> Result<(), String> {
19566 let visible = visible_table(&table.name);
19567 let base = base_table(&table.name);
19568 let pk = quote_ident(&table.primary_key);
19569 let predicate = row_id_predicate(table);
19570 let mut lookup = self.conn.prepare_cached(&format!(
19571 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19572 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19573 let mut deleted = HashSet::new();
19574 for op in self
19575 .outbox
19576 .iter()
19577 .flat_map(|commit| &commit.ops)
19578 .filter(|op| op.table == table.name)
19579 {
19580 if op.upsert {
19581 if let Some(values) = &op.values {
19582 let key = json_to_column_value(
19583 &table.columns[table.pk_index],
19584 values.get(&table.primary_key),
19585 )?;
19586 keys.push(owned_sql_value(RowParam::Cell(&key))?);
19587 }
19588 } else if deleted.insert(&op.row_id) {
19589 keys.extend(
19590 lookup
19591 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19592 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19593 .collect::<Result<Vec<_>, _>>()
19594 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19595 );
19596 }
19597 }
19598 for chunk in keys.chunks(400) {
19601 let holes = vec!["?"; chunk.len()].join(",");
19602 self.conn
19603 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19604 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19605 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19606 }
19607 let updates = table
19608 .columns
19609 .iter()
19610 .map(|column| column.name.as_str())
19611 .chain(std::iter::once("_syncular_version"))
19612 .filter(|name| *name != table.primary_key)
19613 .map(|name| {
19614 let name = quote_ident(name);
19615 format!("{name}=excluded.{name}")
19616 })
19617 .collect::<Vec<_>>()
19618 .join(",");
19619 for chunk in keys.chunks(400) {
19620 let holes = vec!["?"; chunk.len()].join(",");
19621 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19622 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19623 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19624 }
19625 Ok(())
19626 }
19627
19628 fn compile_local_data_purge(
19631 &self,
19632 input: &LocalDataPurgeInput,
19633 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19634 let invalid = |message: String| format!("sync.invalid_request: {message}");
19635 if input.purge_id.is_empty()
19636 || input.purge_id.len() > 128
19637 || !is_local_operation_code_like(&input.purge_id)
19638 {
19639 return Err(invalid(
19640 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19641 ));
19642 }
19643 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19644 return Err(invalid(format!(
19645 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19646 )));
19647 }
19648
19649 let mut deduplicated: BTreeMap<
19650 String,
19651 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19652 > = BTreeMap::new();
19653 for target in &input.targets {
19654 let table = self.schema.table(&target.table).ok_or_else(|| {
19655 invalid(format!(
19656 "local purge names unknown table {:?}",
19657 target.table
19658 ))
19659 })?;
19660 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19661 return Err(invalid(format!(
19662 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19663 target.table
19664 )));
19665 }
19666 let mut selectors = Vec::with_capacity(target.selectors.len());
19667 let mut canonical_selectors = BTreeMap::new();
19668 for (column_name, raw_values) in &target.selectors {
19669 let Some((column_index, column)) = table
19670 .columns
19671 .iter()
19672 .enumerate()
19673 .find(|(_, column)| column.name == *column_name)
19674 else {
19675 return Err(invalid(format!(
19676 "local purge target {:?} names unknown column {:?}",
19677 target.table, column_name
19678 )));
19679 };
19680 let encrypted = table
19681 .encrypted_columns
19682 .iter()
19683 .any(|candidate| candidate.index == column_index);
19684 if column.ty != ColumnType::String || encrypted {
19685 return Err(invalid(format!(
19686 "local purge selector {:?}.{:?} must be a plaintext string column",
19687 target.table, column_name
19688 )));
19689 }
19690 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19691 return Err(invalid(format!(
19692 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19693 target.table, column_name
19694 )));
19695 }
19696 let mut values = raw_values.clone();
19697 values.sort();
19698 values.dedup();
19699 if values.iter().any(|value| {
19700 value.is_empty()
19701 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19702 || !is_local_operation_code_like(value)
19703 }) {
19704 return Err(invalid(format!(
19705 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19706 )));
19707 }
19708 selectors.push((column_name.clone(), values.clone()));
19709 canonical_selectors.insert(column_name.clone(), values);
19710 }
19711 let canonical = LocalDataPurgeTarget {
19712 table: target.table.clone(),
19713 selectors: canonical_selectors,
19714 };
19715 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19716 deduplicated.insert(
19717 key,
19718 (
19719 CompiledLocalDataPurgeTarget {
19720 table: target.table.clone(),
19721 selectors,
19722 },
19723 canonical,
19724 ),
19725 );
19726 }
19727 let targets = deduplicated
19728 .values()
19729 .map(|(compiled, _)| compiled.clone())
19730 .collect::<Vec<_>>();
19731 let canonical = deduplicated
19732 .values()
19733 .map(|(_, target)| target.clone())
19734 .collect::<Vec<_>>();
19735 let canonical_plan =
19736 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19737 Ok((targets, canonical_plan))
19738 }
19739
19740 fn local_purge_base_row_ids(
19741 &self,
19742 targets: &[CompiledLocalDataPurgeTarget],
19743 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19744 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19745 for target in targets {
19746 let table = self
19747 .schema
19748 .table(&target.table)
19749 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19750 let mut clauses = Vec::with_capacity(target.selectors.len());
19751 let mut params = Vec::new();
19752 for (column, values) in &target.selectors {
19753 clauses.push(format!(
19754 "{} IN ({})",
19755 quote_ident(column),
19756 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19757 ));
19758 params.extend(values.iter().cloned().map(SqlValue::Text));
19759 }
19760 let sql = format!(
19761 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19762 quote_ident(&table.primary_key),
19763 base_table(&target.table),
19764 clauses.join(" AND ")
19765 );
19766 let mut statement = self
19767 .conn
19768 .prepare(&sql)
19769 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19770 let rows = statement
19771 .query_map(rusqlite::params_from_iter(params), |row| {
19772 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19773 render_row_id_json(Some(&value))
19774 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19775 })
19776 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19777 let ids = by_table.entry(target.table.clone()).or_default();
19778 for row in rows {
19779 ids.insert(
19780 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19781 );
19782 }
19783 }
19784 Ok(by_table)
19785 }
19786
19787 fn local_purge_values_match(
19788 target: &CompiledLocalDataPurgeTarget,
19789 values: &Map<String, Value>,
19790 ) -> bool {
19791 target.selectors.iter().all(|(column, allowed)| {
19792 values
19793 .get(column)
19794 .and_then(Value::as_str)
19795 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
19796 })
19797 }
19798
19799 pub fn purge_local_data(
19803 &mut self,
19804 input: &LocalDataPurgeInput,
19805 ) -> Result<LocalDataPurgeResult, String> {
19806 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
19807 let meta_key = format!("localPurge:{}", input.purge_id);
19808 let applied_plan = self
19809 .conn
19810 .query_row(
19811 "SELECT value FROM _syncular_meta WHERE key = ?1",
19812 rusqlite::params![meta_key],
19813 |row| row.get::<_, String>(0),
19814 )
19815 .optional()
19816 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19817 if let Some(applied_plan) = applied_plan {
19818 if applied_plan != canonical_plan {
19819 return Err(format!(
19820 "sync.invalid_request: local purge id {:?} was already used with a different plan",
19821 input.purge_id
19822 ));
19823 }
19824 return Ok(LocalDataPurgeResult {
19825 already_applied: true,
19826 purged_rows: 0,
19827 dropped_commits: 0,
19828 });
19829 }
19830
19831 let _ = self.drop_previous_version();
19835
19836 let prior_outbox = self.outbox.clone();
19837 let prior_failed = self.failed_commits.clone();
19838 let prior_rejections = self.rejections.clone();
19839 let prior_conflicts = self.conflicts.clone();
19840 let prior_overlay_dirty = self.overlay_dirty.snapshot();
19841 self.begin_observation("syncular_local_purge")?;
19842 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
19843 let row_ids = self.local_purge_base_row_ids(&targets)?;
19844 let retained_dropped = self.drop_failed_commits(|table, values| {
19845 targets.iter().any(|target| {
19846 target.table == table && Self::local_purge_values_match(target, values)
19847 })
19848 })?;
19849 let doomed = self
19850 .outbox
19851 .iter()
19852 .filter(|commit| {
19853 commit.ops.iter().any(|operation| {
19854 let matching_targets = targets
19855 .iter()
19856 .filter(|target| target.table == operation.table)
19857 .collect::<Vec<_>>();
19858 if matching_targets.is_empty() {
19859 return false;
19860 }
19861 if row_ids
19862 .get(&operation.table)
19863 .is_some_and(|ids| ids.contains(&operation.row_id))
19864 {
19865 return true;
19866 }
19867 operation.values.as_ref().is_some_and(|values| {
19868 matching_targets
19869 .iter()
19870 .any(|target| Self::local_purge_values_match(target, values))
19871 })
19872 })
19873 })
19874 .cloned()
19875 .collect::<Vec<_>>();
19876 let doomed_ids = doomed
19877 .iter()
19878 .map(|commit| commit.client_commit_id.clone())
19879 .collect::<BTreeSet<_>>();
19880 let mut batch = ChangeAccumulator::default();
19881 for commit in &retained_dropped {
19882 batch.conflicts = true;
19883 batch.rejections = true;
19884 batch.outcomes = true;
19885 for operation in &commit.ops {
19886 batch.table(&operation.table);
19887 }
19888 }
19889 let mut rejections = Vec::new();
19890 for commit in &doomed {
19891 for operation in &commit.ops {
19892 batch.table(&operation.table);
19893 }
19894 let results = commit
19895 .ops
19896 .iter()
19897 .enumerate()
19898 .map(|(op_index, operation)| {
19899 let rejection = RejectionRecord {
19900 client_commit_id: commit.client_commit_id.clone(),
19901 op_index: op_index as i32,
19902 code: "client.local_data_purged".to_owned(),
19903 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
19904 retryable: false,
19905 details: None,
19906 operation: Some(CommitOperation::from(operation)),
19907 };
19908 rejections.push(rejection.clone());
19909 CommitOperationOutcome::Error { rejection }
19910 })
19911 .collect::<Vec<_>>();
19912 self.persist_commit_outcome(
19913 &commit.client_commit_id,
19914 CommitOutcomeStatus::Rejected,
19915 &results,
19916 Some(&commit.ops),
19917 )?;
19918 self.delete_outbox_persisted(&commit.client_commit_id)?;
19919 }
19920 if !doomed.is_empty() {
19921 self.outbox
19922 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
19923 self.rejections.extend(rejections);
19924 self.prune_commit_outcomes()?;
19925 batch.status = true;
19926 batch.rejections = true;
19927 batch.outcomes = true;
19928 }
19929
19930 let mut purged_rows = 0usize;
19931 for (table, ids) in &row_ids {
19932 if ids.is_empty() {
19933 continue;
19934 }
19935 batch.table(table);
19936 for row_id in ids {
19937 self.delete_base_row(table, row_id)?;
19938 purged_rows += 1;
19939 }
19940 }
19941 self.rebuild_overlay_if_dirty()?;
19942 self.delete_unreferenced_cached_blobs()?;
19943 self.conn
19944 .execute(
19945 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
19946 rusqlite::params![meta_key, canonical_plan],
19947 )
19948 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19949 Ok((
19950 batch,
19951 LocalDataPurgeResult {
19952 already_applied: false,
19953 purged_rows,
19954 dropped_commits: doomed.len(),
19955 },
19956 ))
19957 })();
19958 let (batch, result) = match applied {
19959 Ok(value) => value,
19960 Err(error) => {
19961 self.rollback_observation("syncular_local_purge");
19962 self.outbox = prior_outbox;
19963 self.failed_commits = prior_failed;
19964 self.rejections = prior_rejections;
19965 self.conflicts = prior_conflicts;
19966 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19967 return Err(error);
19968 }
19969 };
19970 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
19971 self.rollback_observation("syncular_local_purge");
19972 self.outbox = prior_outbox;
19973 self.failed_commits = prior_failed;
19974 self.rejections = prior_rejections;
19975 self.conflicts = prior_conflicts;
19976 self.overlay_dirty.restore(prior_overlay_dirty.clone());
19977 return Err(error);
19978 }
19979 self.cancel_sync_round();
19980 Ok(result)
19981 }
19982
19983 pub fn rebootstrap_local_data(
19989 &mut self,
19990 input: &LocalDataRebootstrapInput,
19991 ) -> Result<LocalDataRebootstrapResult, String> {
19992 if input.rebootstrap_id.is_empty()
19993 || input.rebootstrap_id.len() > 128
19994 || !is_local_operation_code_like(&input.rebootstrap_id)
19995 {
19996 return Err(
19997 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
19998 .to_owned(),
19999 );
20000 }
20001 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20002 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20003 let (retained_commits, reset_subscriptions) =
20004 decode_local_rebootstrap_receipt(&persisted)?;
20005 return Ok(LocalDataRebootstrapResult {
20006 already_applied: true,
20007 retained_commits,
20008 reset_subscriptions,
20009 });
20010 }
20011 if self.stopped || self.schema_floor.is_some() {
20012 return Err(
20013 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20014 .to_owned(),
20015 );
20016 }
20017
20018 let retained_commits = self.outbox.len();
20019 let reset_subscriptions = self.subs.len();
20020 let prior_subs = self.subs.clone();
20021 let prior_upgrading = self.upgrading;
20022 let prior_stopped = self.stopped;
20023 let prior_schema_floor = self.schema_floor.clone();
20024 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20025 let prior_sync_needed = self.sync_needed;
20026 let prior_sync_intents = self.sync_intent_queue.clone();
20027 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20028
20029 let prior_active_round = self.active_round;
20030 self.begin_observation("syncular_local_rebootstrap")?;
20031 let mut batch = ChangeAccumulator::default();
20032 let applied = (|| -> Result<(), String> {
20033 self.run_schema_reset_observed(&mut batch, false)?;
20034 self.conn
20035 .execute(
20036 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20037 rusqlite::params![meta_key, receipt],
20038 )
20039 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20040 self.sync_needed = true;
20041 self.sync_intent_queue.push_back(SyncIntent::Interactive);
20042 batch.status = true;
20043 Ok(())
20044 })();
20045 if let Err(error) = applied {
20046 self.rollback_observation("syncular_local_rebootstrap");
20047 self.active_round = prior_active_round;
20048 self.subs = prior_subs;
20049 self.upgrading = prior_upgrading;
20050 self.stopped = prior_stopped;
20051 self.schema_floor = prior_schema_floor;
20052 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20053 self.sync_needed = prior_sync_needed;
20054 self.sync_intent_queue = prior_sync_intents;
20055 return Err(error);
20056 }
20057 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20058 self.rollback_observation("syncular_local_rebootstrap");
20059 self.active_round = prior_active_round;
20060 self.subs = prior_subs;
20061 self.upgrading = prior_upgrading;
20062 self.stopped = prior_stopped;
20063 self.schema_floor = prior_schema_floor;
20064 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20065 self.sync_needed = prior_sync_needed;
20066 self.sync_intent_queue = prior_sync_intents;
20067 return Err(error);
20068 }
20069
20070 Ok(LocalDataRebootstrapResult {
20071 already_applied: false,
20072 retained_commits,
20073 reset_subscriptions,
20074 })
20075 }
20076
20077 fn purge_scope_rows(
20082 &mut self,
20083 table_name: &str,
20084 effective: &[(String, Vec<String>)],
20085 ) -> Result<(), ()> {
20086 if effective.is_empty() {
20087 return Ok(());
20088 }
20089 let table = self.schema.table(table_name).ok_or(())?.clone();
20090 let mut clauses = Vec::new();
20091 let mut params: Vec<SqlValue> = Vec::new();
20092 for (variable, values) in effective {
20093 let column = table.scope_column(variable).ok_or(())?;
20094 let placeholders: Vec<String> = values
20095 .iter()
20096 .map(|v| {
20097 params.push(SqlValue::Text(v.clone()));
20098 "?".to_owned()
20099 })
20100 .collect();
20101 clauses.push(format!(
20102 "{} IN ({})",
20103 quote_ident(column),
20104 placeholders.join(", ")
20105 ));
20106 }
20107 let sql = format!(
20108 "DELETE FROM {} WHERE {}",
20109 base_table(table_name),
20110 clauses.join(" AND ")
20111 );
20112 self.overlay_dirty.table(table_name);
20113 self.conn
20114 .execute(&sql, rusqlite::params_from_iter(¶ms))
20115 .map_err(|_| ())?;
20116 self.conn
20117 .execute(
20118 &format!(
20119 "DELETE FROM {} WHERE {}",
20120 visible_table(table_name),
20121 clauses.join(" AND ")
20122 ),
20123 rusqlite::params_from_iter(¶ms),
20124 )
20125 .map_err(|_| ())?;
20126 Ok(())
20127 }
20128
20129 fn drop_doomed_outbox(
20132 &mut self,
20133 table_name: &str,
20134 effective: &[(String, Vec<String>)],
20135 ) -> Result<bool, String> {
20136 if effective.is_empty() {
20137 return Ok(false);
20138 }
20139 let Some(table) = self.schema.table(table_name).cloned() else {
20140 return Ok(false);
20141 };
20142 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20143 for (variable, values) in effective {
20144 match table.scope_column(variable) {
20145 Some(column) => mappings.push((column, values)),
20146 None => return Ok(false), }
20148 }
20149 let doomed: Vec<OutboxCommit> = self
20150 .outbox
20151 .iter()
20152 .filter(|commit| {
20153 commit.ops.iter().any(|op| {
20154 op.upsert
20155 && op.table == table_name
20156 && op.values.as_ref().is_some_and(|values| {
20157 mappings
20158 .iter()
20159 .all(|(column, allowed)| match values.get(*column) {
20160 Some(Value::String(s)) => allowed.contains(s),
20161 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20162 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20163 _ => false,
20164 })
20165 })
20166 })
20167 })
20168 .cloned()
20169 .collect();
20170 if doomed.is_empty() {
20171 return Ok(false);
20172 }
20173 let mut rejections = Vec::new();
20174 for commit in &doomed {
20175 let results = commit
20176 .ops
20177 .iter()
20178 .enumerate()
20179 .map(|(op_index, operation)| {
20180 let rejection = RejectionRecord {
20181 client_commit_id: commit.client_commit_id.clone(),
20182 op_index: op_index as i32,
20183 code: "sync.scope_revoked".to_owned(),
20184 message: "the commit was dropped because its effective scope was revoked"
20185 .to_owned(),
20186 retryable: false,
20187 details: None,
20188 operation: Some(CommitOperation::from(operation)),
20189 };
20190 rejections.push(rejection.clone());
20191 CommitOperationOutcome::Error { rejection }
20192 })
20193 .collect::<Vec<_>>();
20194 self.persist_commit_outcome(
20195 &commit.client_commit_id,
20196 CommitOutcomeStatus::Rejected,
20197 &results,
20198 Some(&commit.ops),
20199 )?;
20200 self.delete_outbox_persisted(&commit.client_commit_id)?;
20201 }
20202 self.prune_commit_outcomes()?;
20203 let doomed_ids = doomed
20204 .iter()
20205 .map(|commit| commit.client_commit_id.as_str())
20206 .collect::<BTreeSet<_>>();
20207 self.outbox
20208 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20209 self.rejections.extend(rejections);
20210 Ok(true)
20211 }
20212
20213 pub fn upload_blob(
20220 &mut self,
20221 bytes: &[u8],
20222 media_type: Option<String>,
20223 name: Option<String>,
20224 ) -> Result<Value, String> {
20225 let blob_id = blob_id_for(bytes);
20226 let now = self.clock_now_ms();
20227 let tx = self
20228 .conn
20229 .transaction()
20230 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20231 tx.execute(
20232 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20233 ON CONFLICT(blob_id) DO NOTHING",
20234 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20235 )
20236 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20237 tx.execute(
20238 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20239 rusqlite::params![blob_id, media_type, now],
20240 )
20241 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20242 tx.commit()
20243 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20244 self.enforce_blob_cache_cap();
20245 let mut obj = Map::new();
20246 obj.insert("blobId".to_owned(), Value::from(blob_id));
20247 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20248 if let Some(mt) = media_type {
20249 obj.insert("mediaType".to_owned(), Value::from(mt));
20250 }
20251 if let Some(n) = name {
20252 obj.insert("name".to_owned(), Value::from(n));
20253 }
20254 Ok(Value::Object(obj))
20255 }
20256
20257 pub fn fetch_blob_bytes(
20261 &mut self,
20262 transport: &mut dyn Transport,
20263 blob_id_or_ref: &str,
20264 ) -> Result<FetchedBlob, (String, String)> {
20265 let simple = |m: String| ("client.failed".to_owned(), m);
20266 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20267 blob_id_or_ref.to_owned()
20268 } else {
20269 let value: Value = serde_json::from_str(blob_id_or_ref)
20270 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20271 value
20272 .get("blobId")
20273 .and_then(Value::as_str)
20274 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20275 .to_owned()
20276 };
20277 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20278 return Ok(cached);
20279 }
20280 if !self.transport_enabled {
20281 return Err(("sync.offline".into(), "transport gate is closed".into()));
20282 }
20283 #[cfg(feature = "bench-internals")]
20289 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20290 let bytes = match transport
20291 .blob_download(&blob_id)
20292 .map_err(|e| (e.code, e.message))?
20293 {
20294 BlobDownload::Bytes(bytes) => bytes,
20295 BlobDownload::Url {
20296 url,
20297 url_expires_at_ms,
20298 } => {
20299 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20301 return Err((
20302 "sync.segment_expired".to_owned(),
20303 format!(
20304 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20305 ),
20306 ));
20307 }
20308 transport
20309 .fetch_blob_url(&url)
20310 .map_err(|e| (e.code, e.message))?
20311 }
20312 };
20313 #[cfg(feature = "bench-internals")]
20314 drop(download_phase);
20315 #[cfg(feature = "bench-internals")]
20316 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20317 if blob_id_for(&bytes) != blob_id {
20319 return Err(simple(format!(
20320 "blob content address mismatch for {blob_id}"
20321 )));
20322 }
20323 #[cfg(feature = "bench-internals")]
20324 drop(validate_phase);
20325 #[cfg(feature = "bench-internals")]
20326 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20327 let now = self.clock_now_ms();
20328 self.conn
20329 .execute(
20330 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20331 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20332 )
20333 .map_err(|e| simple(e.to_string()))?;
20334 self.conn
20335 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20336 .map_err(|e| simple(e.to_string()))?;
20337 self.enforce_blob_cache_cap();
20338 #[cfg(feature = "bench-internals")]
20339 drop(insert_phase);
20340 self.get_cached_blob_bytes(&blob_id)
20341 .map_err(simple)?
20342 .ok_or_else(|| simple("blob cache write failed".to_owned()))
20343 }
20344
20345 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20346 #[cfg(feature = "bench-internals")]
20347 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20348 let mut stmt = self
20349 .conn
20350 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20351 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20352 let mut rows = stmt
20353 .query(rusqlite::params![blob_id])
20354 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20355 if let Some(row) = rows
20356 .next()
20357 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20358 {
20359 let bytes: Vec<u8> = row
20360 .get(0)
20361 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20362 let byte_length: i64 = row
20363 .get(1)
20364 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20365 let media_type: Option<String> = row
20366 .get(2)
20367 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20368 return Ok(Some(FetchedBlob {
20369 blob_id: blob_id.to_owned(),
20370 byte_length,
20371 bytes,
20372 media_type,
20373 }));
20374 }
20375 Ok(None)
20376 }
20377
20378 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20380 let pending: Vec<(String, Option<String>)> = {
20381 let mut stmt = self
20382 .conn
20383 .prepare(
20384 "SELECT blob_id, media_type FROM (
20385 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20386 UNION ALL
20387 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20388 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20389 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20390 ) ORDER BY created_at_ms, blob_id",
20391 )
20392 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20393 let rows = stmt
20394 .query_map([], |row| {
20395 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20396 })
20397 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20398 rows.collect::<Result<_, _>>()
20399 .map_err(|error| match error {
20400 rusqlite::Error::InvalidColumnType(..)
20401 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20402 "sync.local_corrupt",
20403 "Pending blob upload metadata is invalid",
20404 ),
20405 _ => TransportError::new("client.failed", error.to_string()),
20406 })?
20407 };
20408 let mut uploads = Vec::new();
20409 for (blob_id, media_type) in pending {
20410 let bytes = (|| {
20411 let stored: Option<(SqlValue, SqlValue)> = self
20412 .conn
20413 .query_row(
20414 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20415 rusqlite::params![blob_id],
20416 |row| Ok((row.get(0)?, row.get(1)?)),
20417 )
20418 .optional()
20419 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20420 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20421 return Err(TransportError::new(
20422 "sync.local_corrupt",
20423 "Pending blob upload body is missing or corrupt",
20424 ));
20425 };
20426 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20427 return Err(TransportError::new(
20428 "sync.local_corrupt",
20429 "Pending blob upload body is missing or corrupt",
20430 ));
20431 }
20432 Ok(bytes)
20433 })();
20434 let failed = bytes.is_err();
20435 uploads.push(crate::round::PendingUpload {
20436 id: blob_id,
20437 bytes,
20438 media_type,
20439 });
20440 if failed {
20441 break;
20442 }
20443 }
20444 Ok(uploads)
20445 }
20446
20447 fn visible_blob_ids_sql(&self) -> String {
20448 let mut selects = Vec::new();
20449 for table in &self.schema.tables {
20450 for column in table
20451 .columns
20452 .iter()
20453 .filter(|column| column.ty == ColumnType::BlobRef)
20454 {
20455 let identifier = quote_ident(&column.name);
20456 selects.push(format!(
20457 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20458 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20459 AND json_type({identifier}, '$.blobId') = 'text'",
20460 quote_ident(&table.name),
20461 ));
20462 }
20463 }
20464 if selects.is_empty() {
20465 "SELECT NULL AS blob_id WHERE 0".to_owned()
20466 } else {
20467 selects.join(" UNION ")
20468 }
20469 }
20470
20471 fn enforce_blob_cache_cap(&self) {
20474 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20475 return;
20476 };
20477 let mut total: i64 = self
20478 .conn
20479 .query_row(
20480 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20481 [],
20482 |row| row.get(0),
20483 )
20484 .unwrap_or(0);
20485 if total <= max_bytes {
20486 return;
20487 }
20488 let query = format!(
20489 "SELECT blob_id, byte_length FROM _syncular_blobs
20490 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20491 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20492 AND blob_id NOT IN ({})
20493 ORDER BY created_at_ms ASC, blob_id ASC",
20494 self.visible_blob_ids_sql(),
20495 );
20496 let candidates: Vec<(String, i64)> = {
20497 let Ok(mut stmt) = self.conn.prepare(&query) else {
20498 return;
20499 };
20500 let Ok(rows) = stmt.query_map([], |row| {
20501 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20502 }) else {
20503 return;
20504 };
20505 rows.filter_map(Result::ok).collect()
20506 };
20507 for (blob_id, byte_length) in candidates {
20508 if total <= max_bytes {
20509 break;
20510 }
20511 let _ = self.conn.execute(
20512 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20513 rusqlite::params![blob_id],
20514 );
20515 total -= byte_length;
20516 }
20517 }
20518
20519 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20520 #[cfg(feature = "bench-internals")]
20521 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20522 if !self.schema_has_blobs() {
20523 return Ok(());
20524 }
20525 self.rebuild_overlay_if_dirty()?;
20526 self.conn
20527 .execute(
20528 &format!(
20529 "DELETE FROM _syncular_blobs
20530 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20531 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20532 AND blob_id NOT IN ({})",
20533 self.visible_blob_ids_sql(),
20534 ),
20535 [],
20536 )
20537 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20538 Ok(())
20539 }
20540
20541 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20546 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20547 return sql.clone();
20548 }
20549 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20550 columns.push(quote_ident("_syncular_version"));
20551 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20552 let primary_key = quote_ident(&table.primary_key);
20553 let updates = columns
20554 .iter()
20555 .filter(|column| **column != primary_key)
20556 .map(|column| format!("{column}=excluded.{column}"))
20557 .collect::<Vec<_>>()
20558 .join(", ");
20559 let sql = format!(
20560 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20561 columns.join(", "),
20562 placeholders.join(", ")
20563 );
20564 self.insert_sql
20565 .borrow_mut()
20566 .insert(full_table.to_owned(), sql.clone());
20567 sql
20568 }
20569
20570 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20571 self.overlay_dirty.table(table_name);
20572 self.write_row(&base_table(table_name), table_name, row, version)
20573 }
20574
20575 fn write_row(
20576 &self,
20577 full_table: &str,
20578 table_name: &str,
20579 row: &Row,
20580 version: i64,
20581 ) -> Result<(), String> {
20582 #[cfg(feature = "bench-internals")]
20583 let _phase = self.benchmark_phases.start(Phase::RowWrite);
20584 let table = self
20585 .schema
20586 .table(table_name)
20587 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20588 let sql = self.insert_row_sql(full_table, table);
20589 let mut stmt = self
20590 .conn
20591 .prepare_cached(&sql)
20592 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20593 let params = row
20594 .iter()
20595 .map(RowParam::Cell)
20596 .chain(std::iter::once(RowParam::Version(version)));
20597 stmt.execute(rusqlite::params_from_iter(params))
20598 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20599 Ok(())
20600 }
20601
20602 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20603 let table = self
20604 .schema
20605 .table(table_name)
20606 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20607 self.overlay_dirty.table(table_name);
20608 let sql = format!(
20609 "DELETE FROM {} WHERE {}",
20610 base_table(table_name),
20611 row_id_predicate(table)
20612 );
20613 let mut stmt = self
20614 .conn
20615 .prepare_cached(&sql)
20616 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20617 stmt.execute(rusqlite::params![row_id])
20618 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20619 Ok(())
20620 }
20621
20622 fn has_acknowledged_rows(&self) -> bool {
20623 self.conn
20624 .query_row(
20625 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20626 [],
20627 |row| row.get(0),
20628 )
20629 .expect("read acknowledged intent")
20630 }
20631
20632 fn acknowledged_operations(
20633 &self,
20634 rows: Option<&HashSet<(&str, &str)>>,
20635 tables: Option<&BTreeSet<&str>>,
20636 ) -> Result<Vec<OutboxOp>, String> {
20637 let keys = rows.map(|rows| {
20638 serde_json::to_string(
20639 &rows
20640 .iter()
20641 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20642 .collect::<Vec<_>>(),
20643 )
20644 .expect("row keys")
20645 });
20646 let mut statement = self
20647 .conn
20648 .prepare("SELECT op_json,intent_json FROM _syncular_acked_rows WHERE (?1 IS NULL OR json_array(tbl,id) IN(SELECT value FROM json_each(?1))) AND (?2 IS NULL OR tbl IN(SELECT value FROM json_each(?2))) ORDER BY commit_seq,idx")
20649 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20650 let rows = statement
20651 .query_map(
20652 rusqlite::params![
20653 keys,
20654 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20655 ],
20656 |row| {
20657 #[cfg(test)]
20658 self.acknowledged_replay_count
20659 .set(self.acknowledged_replay_count.get() + 1);
20660 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20661 },
20662 )
20663 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20664 rows.map(|row| {
20665 let (op, intent) =
20666 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20667 let op: CommitOperation =
20668 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20669 let table = self
20670 .schema
20671 .table(&op.table)
20672 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20673 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20674 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20675 } else {
20676 op.values
20677 };
20678 Ok(OutboxOp {
20679 table: op.table,
20680 row_id: op.row_id,
20681 upsert: op.op == "upsert",
20682 base_version: op.base_version,
20683 values,
20684 })
20685 })
20686 .collect()
20687 }
20688
20689 fn drop_acknowledged_rows(
20690 &self,
20691 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20692 ) -> Result<Vec<OutboxCommit>, String> {
20693 let mut statement = self
20694 .conn
20695 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20696 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20697 let rows = statement
20698 .query_map([], |row| {
20699 Ok((
20700 row.get::<_, String>(0)?,
20701 row.get::<_, String>(1)?,
20702 row.get::<_, String>(2)?,
20703 row.get::<_, String>(3)?,
20704 ))
20705 })
20706 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20707 let mut doomed = HashSet::new();
20708 for row in rows {
20709 let (id, table, intent, row_id) =
20710 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20711 let values =
20712 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20713 let base_matches = self
20714 .schema
20715 .table(&table)
20716 .map(|schema| self.stored_row(schema, &row_id, true))
20717 .transpose()?
20718 .flatten()
20719 .is_some_and(|(base, _)| {
20720 let schema = self.schema.table(&table).expect("acknowledged table");
20721 let values = schema
20722 .columns
20723 .iter()
20724 .zip(base.iter())
20725 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20726 .collect();
20727 matches(&table, &values)
20728 });
20729 if matches(&table, &values) || base_matches {
20730 doomed.insert(id);
20731 }
20732 }
20733 let mut dropped = Vec::new();
20734 for id in doomed {
20735 let mut statement = self
20736 .conn
20737 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20738 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20739 let operations = statement
20740 .query_map([&id], |row| row.get::<_, String>(0))
20741 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20742 let mut ops = Vec::new();
20743 for operation in operations {
20744 let json = operation
20745 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20746 let op: CommitOperation =
20747 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20748 self.overlay_dirty.table(&op.table);
20749 ops.push(OutboxOp {
20750 table: op.table,
20751 row_id: op.row_id,
20752 upsert: op.op == "upsert",
20753 base_version: op.base_version,
20754 values: op.values,
20755 });
20756 }
20757 dropped.push(OutboxCommit {
20758 client_commit_id: id.clone(),
20759 ops,
20760 });
20761 self.conn
20762 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20763 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20764 }
20765 Ok(dropped)
20766 }
20767
20768 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20771 if self.overlay_dirty.get() {
20772 self.rebuild_overlay()?;
20773 }
20774 Ok(())
20775 }
20776
20777 fn rebuild_overlay(&mut self) -> Result<(), String> {
20781 #[cfg(feature = "bench-internals")]
20782 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20783 #[cfg(test)]
20784 self.overlay_rebuild_count
20785 .set(self.overlay_rebuild_count.get() + 1);
20786 let dirty = self.overlay_dirty.snapshot();
20787 let tables: Vec<_> = self
20788 .schema
20789 .tables
20790 .iter()
20791 .filter(|table| {
20792 dirty
20793 .as_ref()
20794 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
20795 })
20796 .cloned()
20797 .collect();
20798 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
20799 self.begin_observation("syncular_overlay")?;
20800 let rebuilt = (|| {
20801 for table in &tables {
20802 for index in &table.fts_indexes {
20803 self.drop_fts_triggers(index)?;
20804 }
20805 let visible = visible_table(&table.name);
20806 let base = base_table(&table.name);
20807 self.conn
20808 .execute_batch(&format!(
20809 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
20810 ))
20811 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20812 }
20813 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
20814 self.apply_outbox_ops(
20815 acknowledged
20816 .iter()
20817 .filter(|op| names.contains(op.table.as_str())),
20818 )?;
20819 for (commit, initial) in &self.failed_commits {
20820 for operation in &commit.ops {
20821 if !names.contains(operation.table.as_str()) || !operation.upsert {
20822 continue;
20823 }
20824 if let Some(table) = self.schema.table(&operation.table) {
20825 if self.visible_row(table, &operation.row_id)?.is_none()
20826 && operation.values.as_ref().is_some_and(|values| {
20827 table
20828 .columns
20829 .iter()
20830 .all(|column| values.contains_key(&column.name))
20831 })
20832 {
20833 let key = serde_json::to_string(&(
20834 operation.table.clone(),
20835 operation.row_id.clone(),
20836 ))
20837 .expect("row key serialization");
20838 if let Some(Value::Object(values)) = initial.get(&key) {
20839 self.apply_outbox_ops(&[OutboxOp {
20840 values: Some(values.clone()),
20841 ..operation.clone()
20842 }])?;
20843 }
20844 }
20845 }
20846 }
20847 self.apply_outbox_ops(
20848 commit
20849 .ops
20850 .iter()
20851 .filter(|op| names.contains(op.table.as_str())),
20852 )?;
20853 }
20854 for commit in &self.outbox {
20855 self.apply_outbox_ops(
20856 commit
20857 .ops
20858 .iter()
20859 .filter(|op| names.contains(op.table.as_str())),
20860 )?;
20861 }
20862 for table in &tables {
20863 for index in &table.fts_indexes {
20864 self.rebuild_fts_projection(table, index)?;
20865 self.create_fts_triggers(table, index)?;
20866 }
20867 }
20868 self.conn
20869 .execute_batch("RELEASE syncular_overlay")
20870 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20871 Ok(())
20872 })();
20873 if rebuilt.is_err() {
20874 self.conn
20875 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
20876 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20877 } else {
20878 self.overlay_dirty.set(false);
20879 }
20880 rebuilt
20881 }
20882
20883 fn apply_outbox_ops<'a>(
20888 &self,
20889 ops: impl IntoIterator<Item = &'a OutboxOp>,
20890 ) -> Result<(), String> {
20891 #[cfg(feature = "bench-internals")]
20892 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
20893 for op in ops {
20894 #[cfg(feature = "bench-internals")]
20895 if let Some(phase) = &mut phase {
20896 phase.unit();
20897 }
20898 if op.upsert {
20899 if let (Some(table), Some(values)) = (
20900 self.schema
20901 .table(&op.table)
20902 .filter(|table| table.indexes.iter().any(|index| index.unique)),
20903 op.values.as_ref(),
20904 ) {
20905 let mut intended: Map<String, Value> = self
20906 .visible_row(table, &op.row_id)?
20907 .map(|(row, _)| {
20908 table
20909 .columns
20910 .iter()
20911 .zip(&row)
20912 .map(|(column, value)| {
20913 (column.name.clone(), column_value_to_json(value))
20914 })
20915 .collect()
20916 })
20917 .unwrap_or_default();
20918 intended.extend(values.clone());
20919 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
20920 continue;
20921 }
20922 }
20923 }
20924 self.apply_outbox_op(op)?;
20925 }
20926 Ok(())
20927 }
20928
20929 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
20930 let Some(table) = self.schema.table(&op.table) else {
20931 return Ok(());
20932 };
20933 let visible = visible_table(&table.name);
20934 if !op.upsert {
20935 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
20936 self.conn
20937 .prepare_cached(&sql)
20938 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
20939 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20940 return Ok(());
20941 }
20942 let Some(values) = op.values.as_ref() else {
20943 return Ok(());
20944 };
20945 let full = table
20950 .columns
20951 .iter()
20952 .all(|column| values.contains_key(&column.name));
20953 let local = self.visible_row(table, &op.row_id)?;
20954 if local.is_none() && !full {
20955 return Ok(());
20956 }
20957 let mut row: Row = Vec::with_capacity(table.columns.len());
20958 for (index, column) in table.columns.iter().enumerate() {
20959 let incoming = match values.get(&column.name) {
20960 Some(raw) => json_to_column_value(
20961 if raw.get("$bytes").is_some()
20962 && table
20963 .encrypted_columns
20964 .iter()
20965 .any(|encrypted| encrypted.index == index)
20966 {
20967 &table.wire_columns[index]
20968 } else {
20969 column
20970 },
20971 Some(raw),
20972 ),
20973 None => Ok(local
20974 .as_ref()
20975 .and_then(|(values, _)| values.get(index).cloned())
20976 .flatten()),
20977 };
20978 row.push(incoming.map_err(|_| {
20979 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
20980 })?);
20981 }
20982 let version = local.as_ref().map_or(-1, |(_, version)| *version);
20983 self.write_row(&visible, &table.name, &row, version)
20984 .map_err(|error| {
20985 if error.starts_with("UNIQUE constraint failed:") {
20986 "sync.constraint_violation: local write violates a unique constraint".to_owned()
20987 } else {
20988 error
20989 }
20990 })
20991 }
20992
20993 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
20996 self.stored_row(table, row_id, false)
20997 }
20998
20999 fn stored_row(
21000 &self,
21001 table: &TableSchema,
21002 row_id: &str,
21003 base: bool,
21004 ) -> Result<Option<(Row, i64)>, String> {
21005 let sql = format!(
21006 "SELECT * FROM {} WHERE {}",
21007 if base {
21008 base_table(&table.name)
21009 } else {
21010 visible_table(&table.name)
21011 },
21012 row_id_predicate(table)
21013 );
21014 let mut stmt = self
21015 .conn
21016 .prepare_cached(&sql)
21017 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21018 let mut rows = stmt
21019 .query(rusqlite::params![row_id])
21020 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21021 let Some(row) = rows
21022 .next()
21023 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21024 else {
21025 return Ok(None);
21026 };
21027 let mut values = Vec::with_capacity(table.columns.len());
21028 for (index, column) in table.columns.iter().enumerate() {
21029 let raw = row
21030 .get_ref(index)
21031 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21032 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21033 if table
21034 .encrypted_columns
21035 .iter()
21036 .any(|encrypted| encrypted.index == index)
21037 {
21038 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21039 continue;
21040 }
21041 }
21042 let json = sql_ref_to_json(column, raw);
21043 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21044 "sync.local_corrupt: persisted local row value is invalid".to_owned()
21045 })?);
21046 }
21047 let version: i64 = row
21048 .get(table.columns.len())
21049 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21050 Ok(Some((values, version)))
21051 }
21052
21053 #[must_use]
21057 pub fn realtime_policy(&self) -> RealtimePolicy {
21058 self.realtime_policy
21059 }
21060
21061 #[must_use]
21063 pub fn realtime_state(&self) -> RealtimeState {
21064 self.realtime_state
21065 }
21066
21067 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21070 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21071 return Err(
21072 "sync.invalid_request: disconnect realtime before changing the realtime policy"
21073 .to_owned(),
21074 );
21075 }
21076 self.realtime_policy = policy;
21077 match policy {
21078 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21079 RealtimePolicy::Required | RealtimePolicy::Optional
21080 if self.realtime_state == RealtimeState::Disabled =>
21081 {
21082 self.set_realtime_state(RealtimeState::Disconnected, None);
21083 }
21084 RealtimePolicy::Required | RealtimePolicy::Optional => {}
21085 }
21086 Ok(())
21087 }
21088
21089 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21092 self.realtime_state = state;
21093 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21094 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21095 self.realtime_reason_code = None;
21096 self.realtime_retry_delay_ms = None;
21097 }
21098 }
21099
21100 #[must_use]
21101 fn realtime_connected(&self) -> bool {
21102 self.realtime_state == RealtimeState::Connected
21103 }
21104
21105 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21106 if !self.transport_enabled {
21107 return Err("sync.offline: transport gate is closed".into());
21108 }
21109 if self.realtime_policy == RealtimePolicy::Off {
21110 return Err(
21111 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21112 );
21113 }
21114 if self.realtime_connected() {
21115 return Ok(());
21116 }
21117 self.realtime_state = RealtimeState::Connecting;
21118 match transport.realtime_connect_for_client(&self.client_id) {
21119 Ok(()) => {
21120 self.set_realtime_state(RealtimeState::Connected, None);
21121 Ok(())
21122 }
21123 Err(error) => {
21124 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21125 Err(format!("{}: {}", error.code, error.message))
21126 }
21127 }
21128 }
21129
21130 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21131 self.cancel_sync_round();
21132 if self.realtime_state == RealtimeState::Disconnected
21135 || self.realtime_state == RealtimeState::Disabled
21136 {
21137 return;
21138 }
21139 let _ = transport.realtime_close();
21140 self.set_realtime_state(
21141 if self.realtime_policy == RealtimePolicy::Off {
21142 RealtimeState::Disabled
21143 } else {
21144 RealtimeState::Disconnected
21145 },
21146 None,
21147 );
21148 self.presence.clear(); }
21150
21151 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21157 if self.realtime_state != RealtimeState::Connected {
21158 return;
21159 }
21160 self.cancel_sync_round();
21161 let _ = transport.realtime_close();
21162 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21163 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21164 self.presence.clear();
21165 }
21166
21167 pub fn set_presence(
21173 &mut self,
21174 transport: &mut dyn Transport,
21175 scope_key: &str,
21176 doc: Option<&Value>,
21177 ) -> Result<(), String> {
21178 if !self.transport_enabled {
21179 return Err("sync.offline: transport gate is closed".into());
21180 }
21181 if !self.realtime_connected() {
21182 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21183 }
21184 let text = encode_presence_publish(scope_key, doc);
21185 transport
21186 .realtime_send(&text)
21187 .map_err(|e| format!("{}: {}", e.code, e.message))
21188 }
21189
21190 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21192 self.presence
21193 .get(scope_key)
21194 .map(|peers| peers.values().cloned().collect())
21195 .unwrap_or_default()
21196 }
21197
21198 fn apply_presence(
21200 &mut self,
21201 scope_key: String,
21202 kind: Option<PresenceKind>,
21203 actor_id: Option<String>,
21204 client_id: Option<String>,
21205 doc: Option<Value>,
21206 error: Option<String>,
21207 ) {
21208 if error.is_some() {
21211 return;
21212 }
21213 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21214 return;
21215 };
21216 let peer_key = format!("{actor_id} {client_id}");
21217 match kind {
21218 PresenceKind::Leave => {
21219 if let Some(peers) = self.presence.get_mut(&scope_key) {
21220 peers.remove(&peer_key);
21221 if peers.is_empty() {
21222 self.presence.remove(&scope_key);
21223 }
21224 }
21225 }
21226 _ => {
21227 let doc = match doc {
21228 Some(Value::Object(_)) => doc.unwrap(),
21229 _ => return,
21230 };
21231 self.presence.entry(scope_key).or_default().insert(
21232 peer_key,
21233 PresencePeer {
21234 actor_id,
21235 client_id,
21236 doc,
21237 },
21238 );
21239 }
21240 }
21241 }
21242
21243 pub fn on_realtime_text(&mut self, text: &str) {
21245 if self.stopped || !self.transport_enabled || self.security_preflight {
21246 return;
21247 }
21248 match parse_control(text) {
21249 Ok(ControlMessage::Hello { requires_sync, .. }) => {
21250 if requires_sync {
21251 self.set_sync_needed(true, true);
21253 }
21254 }
21255 Ok(ControlMessage::Presence {
21256 scope_key,
21257 kind,
21258 actor_id,
21259 client_id,
21260 doc,
21261 error,
21262 ..
21263 }) => {
21264 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21265 }
21266 Ok(ControlMessage::Wake { .. }) => {
21267 self.set_sync_needed(true, true);
21269 }
21270 _ => {}
21271 }
21272 }
21273
21274 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21278 let mut transport = crate::round::DownloadResults::default();
21279 self.on_realtime_binary(&mut transport, bytes);
21280 transport.controls
21281 }
21282
21283 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21284 if self.stopped || !self.transport_enabled || self.security_preflight {
21285 return;
21286 }
21287 #[cfg(feature = "bench-internals")]
21288 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21289 let message = match decode_message(bytes) {
21290 Ok(m) if m.msg_kind == MsgKind::Response => m,
21291 _ => {
21292 self.set_sync_needed(true, true);
21293 return;
21294 }
21295 };
21296 #[cfg(feature = "bench-internals")]
21297 drop(decode_phase);
21298 let coalesced_deletes =
21299 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21300 if status != SubStatus::Active {
21301 return false;
21302 }
21303 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21304 return false;
21305 };
21306 sub.state == SubState::Active
21307 && sub.bootstrap_state.is_none()
21308 && sub.synced_once
21309 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21310 });
21311 let mut frames = message.frames.into_iter();
21312 let mut applied_cursor: Option<i64> = None;
21313 let mut any_covered = false;
21314 let mut dropped = self.begin_coalesced_apply().is_err();
21315 while let Some(frame) = frames.next() {
21316 let Frame::SubStart {
21317 id,
21318 status,
21319 effective_scopes,
21320 ..
21321 } = frame
21322 else {
21323 continue;
21324 };
21325 let mut body = Vec::new();
21326 let mut next_cursor: Option<i64> = None;
21327 for inner in frames.by_ref() {
21328 match inner {
21329 Frame::SubEnd {
21330 next_cursor: nc, ..
21331 } => {
21332 next_cursor = Some(nc);
21333 break;
21334 }
21335 Frame::Unknown { .. } => {}
21336 other => body.push(other),
21337 }
21338 }
21339 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21340 dropped = true;
21341 continue;
21342 };
21343 let sub = &self.subs[sub_index];
21344 if status != SubStatus::Active
21347 || sub.state != SubState::Active
21348 || sub.bootstrap_state.is_some()
21349 || !sub.synced_once
21350 {
21351 dropped = true;
21352 continue;
21353 }
21354 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21355 any_covered = true;
21357 continue;
21358 }
21359 let previous_effective = self.subs[sub_index].effective.clone();
21360 let previous_cursor = self.subs[sub_index].cursor;
21361 self.subs[sub_index].effective = Some(effective_scopes);
21362 let mut failed = false;
21363 for inner in body {
21364 match inner {
21365 Frame::Commit {
21366 tables,
21367 changes,
21368 commit_seq,
21369 ..
21370 } => {
21371 if self
21372 .apply_commit_frame_coalesced(
21373 &tables,
21374 changes,
21375 commit_seq,
21376 &coalesced_deletes,
21377 )
21378 .is_err()
21379 {
21380 failed = true;
21381 break;
21382 }
21383 }
21384 _ => {
21385 failed = true;
21386 break;
21387 }
21388 }
21389 }
21390 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21391 self.apply_sub_end(sub_index, cursor, None)
21392 .ok()
21393 .map(|()| cursor)
21394 });
21395 let Some(next_cursor) = committed else {
21396 self.subs[sub_index].effective = previous_effective;
21397 self.subs[sub_index].cursor = previous_cursor;
21398 dropped = true;
21399 continue;
21400 };
21401 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21402 }
21403 if self.finish_coalesced_apply().is_err() {
21406 applied_cursor = None;
21407 dropped = true;
21408 }
21409 if let Some(cursor) = applied_cursor {
21410 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21412 let _ = transport.realtime_send(&ack);
21413 } else if !any_covered || dropped {
21414 self.set_sync_needed(true, true);
21416 }
21417 }
21418
21419 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21423 if !self.realtime_connected() {
21424 return;
21425 }
21426 let floor = self
21427 .subs
21428 .iter()
21429 .filter(|s| {
21430 s.state == SubState::Active
21431 && s.bootstrap_state.is_none()
21432 && s.synced_once
21433 && s.cursor >= 0
21434 })
21435 .map(|s| s.cursor)
21436 .min();
21437 if let Some(cursor) = floor {
21438 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21439 let _ = transport.realtime_send(&ack);
21440 }
21441 }
21442}
21443
21444#[cfg(test)]
21445mod previous_version_wiring_tests {
21446 use super::*;
21447 use serde_json::{json, Value};
21448
21449 fn previous_version_schema(version: i32) -> Value {
21450 json!({
21451 "version": version,
21452 "tables": [{
21453 "name": "tasks",
21454 "primaryKey": "id",
21455 "columns": [
21456 { "name": "id", "type": "string", "nullable": false },
21457 { "name": "note", "type": "string", "nullable": true }
21458 ],
21459 "scopes": []
21460 }]
21461 })
21462 }
21463
21464 fn previous_version_limits() -> crate::api::ClientLimits {
21465 crate::api::ClientLimits {
21466 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21467 enabled: true,
21468 ..Default::default()
21469 }),
21470 ..Default::default()
21471 }
21472 }
21473
21474 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21476 previous_version_limits()
21477 .previous_version_context
21478 .expect("configured")
21479 }
21480
21481 fn recapture_previous_version(client: &SyncClient) {
21484 let replica_path = client
21485 .previous_version_replica_path()
21486 .expect("file-backed replica");
21487 let outcome = crate::previous_version::capture_previous_version_from_replica(
21488 &client.conn,
21489 &replica_path,
21490 2,
21491 2,
21492 &previous_version_config(),
21493 1,
21494 )
21495 .expect("capture");
21496 assert!(matches!(
21497 outcome,
21498 crate::previous_version::CaptureOutcome::Captured(_)
21499 ));
21500 }
21501
21502 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21503 Subscription {
21504 id: "wiring-sub".to_owned(),
21505 table: "tasks".to_owned(),
21506 requested: Vec::new(),
21507 params: None,
21508 cursor,
21509 bootstrap_state: bootstrap_state.map(str::to_owned),
21510 state,
21511 reason_code: None,
21512 effective: None,
21513 synced_once: false,
21514 }
21515 }
21516
21517 #[test]
21518 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21519 let path =
21520 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21521 let path_str = path.to_str().expect("temp path");
21522 let schema = previous_version_schema(3);
21523 let mut older = previous_version_schema(2);
21524 older["tables"][0]["columns"] =
21525 json!([{ "name": "id", "type": "string", "nullable": false }]);
21526 let mut client = SyncClient::open_path(
21527 "downgrade".into(),
21528 &previous_version_schema(1),
21529 previous_version_limits(),
21530 path_str,
21531 )
21532 .expect("fresh");
21533 client
21534 .mutate(vec![Mutation::Upsert {
21535 table: "tasks".into(),
21536 values: Map::from_iter([
21537 ("id".into(), json!("accepted")),
21538 ("note".into(), json!("old")),
21539 ]),
21540 base_version: None,
21541 }])
21542 .expect("v1 write");
21543 client
21544 .recreate_with_schema(&schema)
21545 .expect("upgrade captures context");
21546 let commit = client
21547 .mutate(vec![Mutation::Upsert {
21548 table: "tasks".into(),
21549 values: Map::from_iter([
21550 ("id".into(), json!("queued")),
21551 ("note".into(), json!("v3-only")),
21552 ]),
21553 base_version: None,
21554 }])
21555 .expect("queued v3 write");
21556 let changes = client.conn.total_changes();
21557 let outbox = client.pending_commit_ids();
21558 assert!(outbox.contains(&commit));
21559 let descriptor = client.get_meta("localSchemaDescriptor");
21560 let container = format!(
21561 "{path_str}{}",
21562 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21563 );
21564 let context = std::fs::read(&container).expect("captured context");
21565 assert!(client
21566 .recreate_with_schema(&older)
21567 .expect_err("refuse recreate")
21568 .starts_with("client.schema_downgrade:"));
21569 assert_eq!(client.schema.version, 3);
21570 assert_eq!(client.conn.total_changes(), changes);
21571 assert_eq!(client.pending_commit_ids(), outbox);
21572 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21573 assert_eq!(
21574 client
21575 .conn
21576 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21577 row.get::<_, String>(0)
21578 })
21579 .expect("queued row"),
21580 "v3-only"
21581 );
21582 assert_eq!(std::fs::read(&container).expect("context remains"), context);
21583 drop(client);
21584 let before = std::fs::read(&path).expect("replica bytes");
21585 for entry in 0..3 {
21588 let error = match entry {
21589 0 => SyncClient::open_path(
21590 "downgrade".into(),
21591 &older,
21592 previous_version_limits(),
21593 path_str,
21594 ),
21595 1 => SyncClient::open_path_with_identity(
21596 None,
21597 &older,
21598 previous_version_limits(),
21599 path_str,
21600 ),
21601 _ => SyncClient::with_connection(
21602 "downgrade".into(),
21603 &older,
21604 previous_version_limits(),
21605 Connection::open(&path).expect("connection"),
21606 ),
21607 }
21608 .err()
21609 .expect("refuse open");
21610 assert!(error.starts_with("client.schema_downgrade:"));
21611 let conn = Connection::open(&path).expect("inspect");
21612 assert_eq!(
21613 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21614 .expect("journal mode"),
21615 "delete"
21616 );
21617 drop(conn);
21618 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21619 assert_eq!(
21620 std::fs::read(&container).expect("context unchanged"),
21621 context
21622 );
21623 }
21624 let reopened = SyncClient::open_path(
21625 "downgrade".into(),
21626 &schema,
21627 previous_version_limits(),
21628 path_str,
21629 )
21630 .expect("compatible reopen");
21631 assert_eq!(reopened.pending_commit_ids(), outbox);
21632 assert_eq!(
21633 reopened
21634 .conn
21635 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21636 row.get::<_, String>(0)
21637 })
21638 .expect("queued row remains"),
21639 "v3-only"
21640 );
21641 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21642 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21643 drop(reopened);
21644 let legacy = SyncClient::open_path(
21645 "downgrade".into(),
21646 &schema,
21647 previous_version_limits(),
21648 path_str,
21649 )
21650 .expect("legacy no-marker reopen");
21651 assert_eq!(legacy.pending_commit_ids(), outbox);
21652 assert!(!legacy.upgrading());
21653 assert_eq!(
21654 legacy
21655 .conn
21656 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21657 row.get::<_, String>(0)
21658 })
21659 .expect("legacy row remains"),
21660 "v3-only"
21661 );
21662 drop(legacy);
21663 std::fs::remove_file(path).expect("remove replica");
21664 let _ = std::fs::remove_file(container);
21665 }
21666
21667 #[test]
21668 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21669 for marker in [
21670 "''",
21671 "'bad'",
21672 "'0'",
21673 "'-1'",
21674 "'2.5'",
21675 "'03'",
21676 "'3\n'",
21677 "' 3'",
21678 "'+3'",
21679 "'3e0'",
21680 "'2147483648'",
21681 "NULL",
21682 "X'33'",
21683 ] {
21684 let conn = Connection::open_in_memory().expect("connection");
21685 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21686 let error = SyncClient::with_connection(
21687 "marker".into(),
21688 &previous_version_schema(3),
21689 ClientLimits::default(),
21690 conn,
21691 )
21692 .err()
21693 .expect("refuse marker");
21694 assert!(
21695 error.starts_with("sync.local_corrupt:"),
21696 "{marker}: {error}"
21697 );
21698 }
21699 for fault in [
21700 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21701 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21702 ] {
21703 let mut client = SyncClient::new(
21704 "marker".into(),
21705 &previous_version_schema(3),
21706 ClientLimits::default(),
21707 )
21708 .expect("fresh");
21709 let commit = client
21710 .mutate(vec![Mutation::Upsert {
21711 table: "tasks".into(),
21712 values: Map::from_iter([("id".into(), json!("queued"))]),
21713 base_version: None,
21714 }])
21715 .expect("queued write");
21716 client.conn.execute_batch(fault).expect("marker fault");
21717 let changes = client.conn.total_changes();
21718 let error = client
21719 .recreate_with_schema(&previous_version_schema(4))
21720 .expect_err("refuse corrupt marker");
21721 assert!(error.starts_with("sync.local_corrupt:"));
21722 assert_eq!(client.conn.total_changes(), changes);
21723 assert_eq!(client.schema.version, 3);
21724 assert_eq!(client.pending_commit_ids(), vec![commit]);
21725 }
21726 for setup in [
21727 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21728 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21729 ] {
21730 let path =
21731 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21732 let conn = Connection::open(&path).expect("connection");
21733 conn.execute_batch(setup)
21734 .expect("unreadable metadata fixture");
21735 drop(conn);
21736 let before = std::fs::read(&path).expect("before");
21737 let error = SyncClient::open_path_with_identity(
21738 None,
21739 &previous_version_schema(3),
21740 ClientLimits::default(),
21741 path.to_str().expect("path"),
21742 )
21743 .err()
21744 .expect("refuse unreadable marker");
21745 assert!(error.starts_with("sync.local_corrupt:"));
21746 assert_eq!(std::fs::read(&path).expect("after"), before);
21747 std::fs::remove_file(path).expect("remove fixture");
21748 }
21749 }
21750
21751 std::thread_local! {
21752 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21755 const { RefCell::new(None) };
21756 }
21757
21758 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21759 CONCURRENT_UPGRADE.with(|slot| {
21760 if let Some(banner) = slot.borrow_mut().take() {
21761 banner
21762 .execute_batch(
21763 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21764 )
21765 .expect("commit the concurrent upgrade");
21766 }
21767 });
21768 attempts < 10
21769 }
21770
21771 fn journal_mode(conn: &Connection) -> String {
21772 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21773 .expect("journal mode")
21774 }
21775
21776 fn temp_replica(label: &str) -> std::path::PathBuf {
21777 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
21778 }
21779
21780 #[test]
21781 fn file_identity_constructor_requires_the_returned_wal_mode() {
21782 for path in [":memory:", ""] {
21783 let error = SyncClient::open_path_with_identity(
21784 None,
21785 &previous_version_schema(1),
21786 ClientLimits::default(),
21787 path,
21788 )
21789 .err()
21790 .expect("temporary SQLite databases cannot enter WAL mode");
21791 assert_eq!(
21792 error,
21793 "sync.invalid_request: file-backed client requires WAL journal mode"
21794 );
21795 }
21796 let path = temp_replica("confirmed-wal");
21797 let client = SyncClient::open_path_with_identity(
21798 None,
21799 &previous_version_schema(1),
21800 ClientLimits::default(),
21801 path.to_str().unwrap(),
21802 )
21803 .unwrap();
21804 assert_eq!(journal_mode(&client.conn), "wal");
21805 drop(client);
21806 std::fs::remove_file(path).unwrap();
21807 }
21808
21809 #[test]
21810 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
21811 for path in [":memory:", ""] {
21812 let client = SyncClient::open_path_with_identity_journal(
21813 None,
21814 &previous_version_schema(1),
21815 ClientLimits::default(),
21816 path,
21817 false,
21818 )
21819 .expect("a host without WAL can still open the identity constructor");
21820 assert_ne!(journal_mode(&client.conn), "wal");
21821 assert_eq!(
21822 client
21823 .conn
21824 .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
21825 .expect("synchronous"),
21826 2,
21827 "every identity open pins FULL durability"
21828 );
21829 }
21830 }
21831
21832 #[test]
21833 fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
21834 let path = temp_replica("reset");
21835 let path_str = path.to_str().expect("path");
21836 let client = SyncClient::open_path(
21837 "reset-replica".into(),
21838 &previous_version_schema(1),
21839 ClientLimits::default(),
21840 path_str,
21841 )
21842 .expect("replica");
21843 let original_id = client.client_id().to_owned();
21844 drop(client);
21845 for suffix in ["-journal", "-wal", "-shm"] {
21846 std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
21847 }
21848
21849 SyncClient::reset_replica(path_str).expect("reset through the VFS");
21850
21851 for suffix in ["", "-journal", "-wal", "-shm"] {
21852 assert!(
21853 !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
21854 "reset removes the replica file {suffix:?}"
21855 );
21856 }
21857 let reopened = SyncClient::open_path_with_identity(
21858 None,
21859 &previous_version_schema(1),
21860 ClientLimits::default(),
21861 path_str,
21862 )
21863 .expect("fresh replica");
21864 assert_ne!(reopened.client_id(), original_id);
21865 assert!(reopened
21866 .query("SELECT id FROM tasks", &[])
21867 .expect("empty")
21868 .is_empty());
21869 drop(reopened);
21870 assert_eq!(
21871 SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
21872 "sync.invalid_request: a memory replica has nothing to reset"
21873 );
21874 }
21875
21876 #[test]
21877 fn non_positive_generated_schema_versions_are_refused_before_storage() {
21878 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
21879 let mut schema = previous_version_schema(1);
21880 schema["version"] = version.clone();
21881 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21882 .err()
21883 .expect("refuse an out-of-range generated version");
21884 assert_eq!(
21885 error,
21886 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
21887 );
21888 let path = temp_replica("bad-version");
21890 let path_str = path.to_str().expect("path");
21891 for identity in [false, true] {
21892 let error = if identity {
21893 SyncClient::open_path_with_identity(
21894 None,
21895 &schema,
21896 ClientLimits::default(),
21897 path_str,
21898 )
21899 } else {
21900 SyncClient::open_path(
21901 "bad-version".into(),
21902 &schema,
21903 ClientLimits::default(),
21904 path_str,
21905 )
21906 }
21907 .err()
21908 .expect("refuse before storage");
21909 assert!(
21910 error.starts_with("sync.invalid_request:"),
21911 "{version}: {error}"
21912 );
21913 assert!(
21914 !path.exists(),
21915 "{version}: a refused constructor creates no replica file"
21916 );
21917 }
21918 }
21919 let mut schema = previous_version_schema(1);
21921 schema
21922 .as_object_mut()
21923 .expect("schema object")
21924 .remove("version");
21925 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
21926 .err()
21927 .expect("refuse a missing version");
21928 assert!(error.starts_with("sync.invalid_request:"), "{error}");
21929 }
21930
21931 #[test]
21932 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
21933 let path = temp_replica("duplicate-marker");
21934 let path_str = path.to_str().expect("path");
21935 {
21936 let conn = Connection::open(path_str).expect("fixture");
21939 conn.execute_batch(
21940 "CREATE TABLE _syncular_meta(key TEXT, value);
21941 INSERT INTO _syncular_meta VALUES
21942 ('localSchemaVersion', '1'),
21943 ('localSchemaVersion', '2'),
21944 ('clientId', 'duplicate');",
21945 )
21946 .expect("duplicate marker fixture");
21947 }
21948 let error = SyncClient::open_path_with_identity(
21949 None,
21950 &previous_version_schema(3),
21951 ClientLimits::default(),
21952 path_str,
21953 )
21954 .err()
21955 .expect("refuse a duplicated marker");
21956 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
21957 let probe = Connection::open(path_str).expect("probe");
21958 assert_eq!(
21959 probe
21960 .query_row(
21961 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
21962 [],
21963 |row| row.get::<_, i64>(0),
21964 )
21965 .expect("marker rows"),
21966 2,
21967 "the duplicated rows are left for the operator, not collapsed"
21968 );
21969 assert_eq!(
21970 probe
21971 .query_row(
21972 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
21973 [],
21974 |row| row.get::<_, i64>(0),
21975 )
21976 .expect("synced tables"),
21977 0,
21978 "a duplicated marker never drives the reset that recreates v3 tables"
21979 );
21980 assert_eq!(
21981 journal_mode(&probe),
21982 "delete",
21983 "the refusal leaves the journal mode alone"
21984 );
21985 drop(probe);
21986 std::fs::remove_file(path).expect("remove replica");
21987 }
21988
21989 #[test]
21990 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
21991 let path = temp_replica("refused-wal");
21992 let path_str = path.to_str().expect("path");
21993 drop(
21996 SyncClient::open_path(
21997 "owner".into(),
21998 &previous_version_schema(2),
21999 ClientLimits::default(),
22000 path_str,
22001 )
22002 .expect("v2 replica"),
22003 );
22004 let before = std::fs::read(&path).expect("before");
22005 let error = SyncClient::open_path_with_identity(
22006 Some("intruder".into()),
22007 &previous_version_schema(2),
22008 ClientLimits::default(),
22009 path_str,
22010 )
22011 .err()
22012 .expect("refuse the identity mismatch");
22013 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22014 let probe = Connection::open(path_str).expect("probe");
22015 assert_eq!(
22016 journal_mode(&probe),
22017 "delete",
22018 "a refused open must not persist WAL"
22019 );
22020 assert_eq!(std::fs::read(&path).expect("after"), before);
22021 assert_eq!(
22022 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22023 Some("2")
22024 );
22025 drop(probe);
22026 std::fs::remove_file(path).expect("remove replica");
22027 }
22028
22029 #[test]
22030 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22031 let path = temp_replica("marker-race");
22032 let path_str = path.to_str().expect("path");
22033 drop(
22034 SyncClient::open_path_with_identity(
22035 Some("stale".into()),
22036 &previous_version_schema(1),
22037 ClientLimits::default(),
22038 path_str,
22039 )
22040 .expect("v1 replica"),
22041 );
22042 let banner = Connection::open(path_str).expect("banner");
22046 banner
22047 .execute_batch(
22048 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22049 )
22050 .expect("pending upgrade");
22051 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22052 let conn = Connection::open(path_str).expect("stale connection");
22053 conn.busy_handler(Some(commit_concurrent_upgrade))
22054 .expect("busy handler");
22055 let error = SyncClient::with_connection(
22056 "stale".into(),
22057 &previous_version_schema(1),
22058 ClientLimits::default(),
22059 conn,
22060 )
22061 .err()
22062 .expect("a concurrent upgrade must refuse the stale open");
22063 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22064 let probe = Connection::open(path_str).expect("probe");
22065 assert_eq!(
22066 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22067 Some("2"),
22068 "the concurrent upgrade survives"
22069 );
22070 drop(probe);
22071 std::fs::remove_file(path).expect("remove replica");
22072 }
22073
22074 #[test]
22075 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22076 let path = temp_replica("recreate-race");
22077 let path_str = path.to_str().expect("path");
22078 let mut client = SyncClient::open_path_with_identity(
22079 Some("stale".into()),
22080 &previous_version_schema(1),
22081 ClientLimits::default(),
22082 path_str,
22083 )
22084 .expect("v1 replica");
22085 client
22086 .mutate(vec![Mutation::Upsert {
22087 table: "tasks".into(),
22088 values: Map::from_iter([("id".into(), json!("queued"))]),
22089 base_version: None,
22090 }])
22091 .expect("queued write");
22092 let commit = client.pending_commit_ids();
22093 let banner = Connection::open(path_str).expect("banner");
22094 banner
22095 .execute_batch(
22096 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22097 )
22098 .expect("pending upgrade");
22099 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22100 client
22101 .conn
22102 .busy_handler(Some(commit_concurrent_upgrade))
22103 .expect("busy handler");
22104 let error = client
22105 .recreate_with_schema(&previous_version_schema(1))
22106 .expect_err("a concurrent upgrade must refuse the stale recreate");
22107 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22108 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22109 assert_eq!(
22110 client.pending_commit_ids(),
22111 commit,
22112 "the outbox is preserved"
22113 );
22114 let probe = Connection::open(path_str).expect("probe");
22115 assert_eq!(
22116 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22117 Some("2")
22118 );
22119 drop(probe);
22120 drop(client);
22121 std::fs::remove_file(path).expect("remove replica");
22122 }
22123
22124 #[test]
22125 fn failed_schema_record_writes_roll_back_recreation() {
22126 for key in [
22127 LOCAL_SCHEMA_VERSION_KEY,
22128 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22129 ] {
22130 let mut client = SyncClient::new(
22131 "schema-record".into(),
22132 &previous_version_schema(1),
22133 ClientLimits::default(),
22134 )
22135 .expect("v1");
22136 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22137 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22138 client.conn.execute_batch(&format!("CREATE TRIGGER refuse_schema_record BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{key}' BEGIN SELECT RAISE(ABORT, 'refuse schema record'); END")).expect("fault");
22139 client
22140 .recreate_with_schema(&previous_version_schema(2))
22141 .expect_err("both record writes must propagate failure");
22142 assert_eq!(client.schema.version, 1);
22143 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22144 assert_eq!(
22145 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22146 descriptor
22147 );
22148 client
22149 .conn
22150 .execute_batch("DROP TRIGGER refuse_schema_record")
22151 .expect("remove fault");
22152 client
22153 .recreate_with_schema(&previous_version_schema(2))
22154 .expect("retry");
22155 assert_eq!(client.schema.version, 2);
22156 }
22157 }
22158
22159 #[test]
22160 fn a_failed_outer_commit_restores_every_reset_side_effect() {
22161 let path = temp_replica("outer-commit");
22162 let path_str = path.to_str().expect("path");
22163 let conn = Connection::open(path_str).expect("replica connection");
22166 let mut client = SyncClient::with_connection(
22167 "outer-commit".into(),
22168 &previous_version_schema(1),
22169 previous_version_limits(),
22170 conn,
22171 )
22172 .expect("v1 replica");
22173 assert_eq!(journal_mode(&client.conn), "delete");
22174 let commit = client
22175 .mutate(vec![Mutation::Upsert {
22176 table: "tasks".into(),
22177 values: Map::from_iter([
22178 ("id".into(), json!("queued")),
22179 ("note".into(), json!("dropped-by-v2")),
22180 ]),
22181 base_version: None,
22182 }])
22183 .expect("queued write");
22184 client.drain_change_batches();
22185 let mut dropped = previous_version_schema(2);
22187 dropped["tables"][0]["columns"] =
22188 json!([{ "name": "id", "type": "string", "nullable": false }]);
22189 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22192 let revision = client.local_revision();
22193 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22194 let columns: i64 = client
22195 .conn
22196 .query_row(
22197 "SELECT count(*) FROM pragma_table_info('tasks')",
22198 [],
22199 |row| row.get(0),
22200 )
22201 .expect("column count");
22202 let rebuilds = client.overlay_rebuild_count.get();
22203 let prior_subs = client.subs.len();
22204 let prior_changes = client.change_queue.len();
22205 let prior_intents = client.sync_intent_queue.len();
22206 let prior_last_change = client.last_change.clone();
22207 let prior_overlay_dirty = client.overlay_dirty.get();
22208 let prior_sync_needed = client.sync_needed;
22209 let prior_upgrading = client.upgrading;
22210 let prior_stopped = client.stopped;
22211 let prior_schema_floor = client.schema_floor.clone();
22212 let prior_active_round = client.active_round;
22213 let reader = Connection::open(path_str).expect("reader");
22216 reader
22217 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22218 .expect("reader snapshot");
22219 client
22220 .conn
22221 .busy_timeout(std::time::Duration::ZERO)
22222 .expect("busy timeout");
22223 let error = client
22224 .recreate_with_schema(&dropped)
22225 .expect_err("a blocked COMMIT must refuse the recreation");
22226 assert!(error.starts_with("client.storage_busy:"), "{error}");
22227 assert!(
22228 client.overlay_rebuild_count.get() > rebuilds,
22229 "the reset ran to its overlay rebuild inside the transaction"
22230 );
22231 assert!(client.conn.is_autocommit(), "the transaction is released");
22232 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22234 assert_eq!(client.local_revision(), revision);
22235 assert_eq!(
22236 client.query("SELECT * FROM tasks", &[]).expect("rows"),
22237 rows
22238 );
22239 assert_eq!(
22240 client
22241 .conn
22242 .query_row(
22243 "SELECT count(*) FROM pragma_table_info('tasks')",
22244 [],
22245 |row| { row.get::<_, i64>(0) }
22246 )
22247 .expect("column count"),
22248 columns,
22249 "the previous table layout survives the rollback"
22250 );
22251 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22252 assert_eq!(client.schema.version, 1, "compiled schema");
22255 assert_eq!(client.subs.len(), prior_subs);
22256 assert_eq!(client.change_queue.len(), prior_changes);
22257 assert_eq!(client.sync_intent_queue.len(), prior_intents);
22258 assert_eq!(client.last_change, prior_last_change);
22259 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22260 assert_eq!(client.sync_needed, prior_sync_needed);
22261 assert_eq!(client.upgrading, prior_upgrading);
22262 assert_eq!(client.stopped, prior_stopped);
22263 assert_eq!(client.schema_floor, prior_schema_floor);
22264 assert_eq!(client.active_round, prior_active_round);
22265 assert!(
22266 client.rejections.is_empty(),
22267 "the dropped-column rejection is rolled back too"
22268 );
22269 let container = crate::previous_version::previous_version_container_path(path_str);
22272 assert!(std::path::Path::new(&container).exists());
22273 drop(reader);
22276 let snapshot = client
22277 .previous_version_snapshot(&PreviousVersionReadSpec {
22278 table: "tasks".into(),
22279 ..Default::default()
22280 })
22281 .expect("refuse stale capture");
22282 assert!(!snapshot.available);
22283 assert_eq!(snapshot.current_version, 1);
22284 assert!(!std::path::Path::new(&container).exists());
22285 client
22290 .conn
22291 .busy_timeout(std::time::Duration::from_secs(1))
22292 .expect("busy timeout");
22293 client
22294 .recreate_with_schema(&dropped)
22295 .expect("the recreation succeeds once the reader releases");
22296 assert_eq!(client.schema.version, 2);
22297 assert_eq!(
22298 client.pending_commit_ids(),
22299 vec![commit.clone()],
22300 "the reset kept the incompatible commit pending"
22301 );
22302 assert!(
22303 client.rejections.is_empty(),
22304 "no rejection exists before the send-time prepass"
22305 );
22306 client
22308 .prepare_sync_round(false)
22309 .expect("the send prepass runs during round preparation");
22310 assert!(client.pending_commit_ids().is_empty());
22311 assert!(
22312 client
22313 .rejections
22314 .iter()
22315 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22316 "the dropped-column commit surfaced as a rejection at send time"
22317 );
22318 client.previous_version_discard();
22319 drop(client);
22320 std::fs::remove_file(path).expect("remove replica");
22321 }
22322
22323 #[test]
22324 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22325 let v1 = previous_version_schema(1);
22326 let mut v2 = previous_version_schema(2);
22327 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22329 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22330 let dropped = client
22331 .mutate(vec![Mutation::Upsert {
22332 table: "tasks".into(),
22333 values: Map::from_iter([
22334 ("id".into(), json!("dropped")),
22335 ("note".into(), json!("gone")),
22336 ]),
22337 base_version: None,
22338 }])
22339 .unwrap();
22340 let survivor = client
22344 .mutate(vec![Mutation::Delete {
22345 table: "tasks".into(),
22346 row_id: "gone".into(),
22347 base_version: None,
22348 }])
22349 .unwrap();
22350 client
22351 .recreate_with_schema(&v2)
22352 .expect("the reset preserves the outbox");
22353 assert_eq!(
22354 client.pending_commit_ids(),
22355 vec![dropped.clone(), survivor.clone()],
22356 "the reset preserves both commits"
22357 );
22358 assert!(client.rejections.is_empty(), "the reset classifies nothing");
22359 client.drain_change_batches();
22360 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22361 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22362
22363 for fault in [
22369 "outcome-insert",
22370 "outbox-delete",
22371 "overlay-rebuild",
22372 "publish-revision",
22373 ] {
22374 client.set_sync_needed(false, false);
22375 client.drain_change_batches();
22376 let revision = client.local_revision();
22377 let trigger = match fault {
22378 "outcome-insert" => {
22379 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22380 }
22381 "outbox-delete" => {
22382 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22383 }
22384 "publish-revision" => {
22389 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22390 }
22391 _ => {
22392 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22393 }
22394 };
22395 client.conn.execute_batch(trigger).unwrap();
22396 let outcome = client
22397 .prepare_sync_round(false)
22398 .err()
22399 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22400 assert!(
22401 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22402 "{fault}: {outcome:?}"
22403 );
22404 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22405 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22406 assert_eq!(
22407 client.pending_commit_ids(),
22408 vec![dropped.clone(), survivor.clone()],
22409 "{fault}: the outbox is restored"
22410 );
22411 assert!(
22412 client.rejections.is_empty(),
22413 "{fault}: no in-memory rejection survives"
22414 );
22415 assert!(
22416 client.commit_outcome(&dropped).unwrap().is_none(),
22417 "{fault}: the durable outcome rolled back"
22418 );
22419 assert_eq!(client.local_revision(), revision, "{fault}: revision");
22420 assert_eq!(
22421 client.query("SELECT * FROM tasks", &[]).unwrap(),
22422 rows,
22423 "{fault}: the visible projection is unchanged"
22424 );
22425 assert!(
22426 client.drain_change_batches().is_empty(),
22427 "{fault}: nothing is published"
22428 );
22429 }
22430
22431 client
22434 .conn
22435 .execute_batch(
22436 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22437 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22438 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22439 )
22440 .unwrap();
22441 assert!(client.drop_incompatible_outbox().is_err());
22442 assert!(!client.conn.is_autocommit());
22443 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22444 assert_eq!(
22445 client.pending_commit_ids(),
22446 vec![dropped.clone(), survivor.clone()]
22447 );
22448 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22449 client
22450 .conn
22451 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22452 .unwrap();
22453
22454 client.set_meta(LOG_EPOCH_KEY, "epoch");
22459 client.set_sync_needed(false, false);
22460 client.drain_change_batches();
22461 let revision = client.local_revision();
22462 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22463 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22464 assert_eq!(
22465 prepared.meta.pushed_ids,
22466 vec![survivor.clone()],
22467 "the incompatible commit is classified before any operation is sent"
22468 );
22469 let rejection = client
22470 .rejections
22471 .iter()
22472 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22473 .expect("a sync.outbox_incompatible rejection");
22474 assert_eq!(rejection.client_commit_id, dropped);
22475 assert!(
22476 !rejection.retryable,
22477 "a schema-incompatible commit is final"
22478 );
22479 assert_eq!(
22480 client
22481 .commit_outcome(&dropped)
22482 .unwrap()
22483 .expect("a durable rejected outcome")
22484 .status,
22485 CommitOutcomeStatus::Rejected
22486 );
22487 assert_eq!(client.local_revision(), revision + 1, "one revision");
22488 let batches = client.drain_change_batches();
22489 assert_eq!(batches.len(), 1, "one change batch");
22490 assert!(
22491 batches[0].rejections_changed
22492 && batches[0].outcomes_changed
22493 && batches[0].status.is_some(),
22494 "{:?}",
22495 batches[0]
22496 );
22497 assert!(
22498 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22499 "{:?}",
22500 batches[0]
22501 );
22502 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22503 assert!(
22504 visible.is_empty(),
22505 "the dropped projection is undone: {visible:?}"
22506 );
22507
22508 let path = temp_replica("outbox-drop-commit");
22512 let path_str = path.to_str().expect("path");
22513 let conn = Connection::open(path_str).expect("replica connection");
22514 let mut client =
22515 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22516 .expect("v1 replica");
22517 let dropped = client
22518 .mutate(vec![Mutation::Upsert {
22519 table: "tasks".into(),
22520 values: Map::from_iter([
22521 ("id".into(), json!("dropped")),
22522 ("note".into(), json!("gone")),
22523 ]),
22524 base_version: None,
22525 }])
22526 .unwrap();
22527 client
22528 .recreate_with_schema(&v2)
22529 .expect("the reset preserves the outbox");
22530 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22531 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22532 assert_eq!(rows.len(), 1, "the optimistic row is visible");
22533 client.set_sync_needed(false, false);
22534 client.drain_change_batches();
22535 let revision = client.local_revision();
22536 let reader = Connection::open(path_str).expect("reader");
22537 reader
22538 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22539 .expect("reader snapshot");
22540 client
22541 .conn
22542 .busy_timeout(std::time::Duration::ZERO)
22543 .expect("busy timeout");
22544 let outcome = client
22545 .prepare_sync_round(false)
22546 .err()
22547 .expect("a blocked COMMIT must fail the round");
22548 assert!(
22549 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22550 "{outcome:?}"
22551 );
22552 assert!(client.conn.is_autocommit(), "the savepoint is released");
22553 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22554 assert!(client.rejections.is_empty());
22555 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22556 assert_eq!(client.local_revision(), revision);
22557 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22558 assert!(client.drain_change_batches().is_empty());
22559 drop(reader);
22561 client
22562 .conn
22563 .busy_timeout(std::time::Duration::from_secs(1))
22564 .expect("busy timeout");
22565 client
22566 .prepare_sync_round(false)
22567 .expect("the drop succeeds after the reader releases");
22568 assert!(client.pending_commit_ids().is_empty());
22569 assert!(client
22570 .rejections
22571 .iter()
22572 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22573 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22574 drop(client);
22575 std::fs::remove_file(path).expect("remove replica");
22576 }
22577
22578 #[test]
22579 fn previous_version_read_reasons_come_from_live_client_state() {
22580 use crate::previous_version::{
22581 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22582 };
22583 let path = std::env::temp_dir()
22584 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22585 .to_string_lossy()
22586 .into_owned();
22587 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22588 {
22589 let mut setup = SyncClient::open_path(
22590 "wiring".to_owned(),
22591 &previous_version_schema(1),
22592 previous_version_limits(),
22593 &path,
22594 )
22595 .expect("v1 install");
22596 setup
22597 .mutate(vec![Mutation::Upsert {
22598 table: "tasks".to_owned(),
22599 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22600 base_version: None,
22601 }])
22602 .expect("seed a row");
22603 }
22604 let mut client = SyncClient::open_path(
22605 "wiring".to_owned(),
22606 &previous_version_schema(2),
22607 previous_version_limits(),
22608 &path,
22609 )
22610 .expect("v2 bump captures");
22611 assert!(std::path::Path::new(&container).exists());
22612 let spec = PreviousVersionReadSpec {
22613 table: "tasks".to_owned(),
22614 ..Default::default()
22615 };
22616
22617 recapture_previous_version(&client);
22619 client.lease_state = Some(LeaseState {
22620 lease_id: None,
22621 expires_at_ms: None,
22622 error_code: Some("lease.expired".to_owned()),
22623 });
22624 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22625 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22626 assert!(!snapshot.available);
22627 assert!(!std::path::Path::new(&container).exists());
22628
22629 recapture_previous_version(&client);
22631 client.set_now_ms(1_000);
22632 client.lease_state = Some(LeaseState {
22633 lease_id: Some("lease".to_owned()),
22634 expires_at_ms: Some(1_000),
22635 error_code: None,
22636 });
22637 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22638 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22639 assert!(!std::path::Path::new(&container).exists());
22640 client.lease_state = None;
22641
22642 recapture_previous_version(&client);
22644 client.subs.push(subscription(SubState::Revoked, 0, None));
22645 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22646 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22647 assert!(!std::path::Path::new(&container).exists());
22648 client.subs.clear();
22649
22650 recapture_previous_version(&client);
22653 client
22654 .subs
22655 .push(subscription(SubState::Active, 0, Some("resume")));
22656 assert!(!client.previous_version_coverage_complete());
22657 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22658 assert!(snapshot.available);
22659 assert_eq!(snapshot.previous_version, Some(2));
22660 assert_eq!(snapshot.rows.len(), 1);
22661
22662 recapture_previous_version(&client);
22663 client.subs[0].bootstrap_state = None;
22664 assert!(client.previous_version_coverage_complete());
22665 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22666 assert_eq!(
22667 snapshot.reason,
22668 Some(PreviousVersionReason::CoverageComplete)
22669 );
22670 assert!(!std::path::Path::new(&container).exists());
22671 client.subs.clear();
22672
22673 assert!(!client.previous_version_coverage_complete());
22675
22676 drop(client);
22677 let _ = std::fs::remove_file(&path);
22678 }
22679
22680 #[test]
22687 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22688 let path = std::env::temp_dir()
22689 .join(format!(
22690 "syncular-prev-lifetime-{}.db",
22691 uuid::Uuid::new_v4()
22692 ))
22693 .to_string_lossy()
22694 .into_owned();
22695 let container = format!(
22696 "{path}{}",
22697 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22698 );
22699 let exists = || std::path::Path::new(&container).exists();
22700 {
22701 let mut setup = SyncClient::open_path(
22702 "lifetime".to_owned(),
22703 &previous_version_schema(1),
22704 previous_version_limits(),
22705 &path,
22706 )
22707 .expect("v1 install");
22708 setup
22709 .mutate(vec![Mutation::Upsert {
22710 table: "tasks".to_owned(),
22711 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22712 base_version: None,
22713 }])
22714 .expect("seed a row");
22715 }
22716 let mut client = SyncClient::open_path(
22717 "lifetime".to_owned(),
22718 &previous_version_schema(2),
22719 previous_version_limits(),
22720 &path,
22721 )
22722 .expect("v2 bump captures");
22723 client.set_now_ms(1_000);
22726 assert!(exists());
22727
22728 client.lease_state = Some(LeaseState {
22730 lease_id: None,
22731 expires_at_ms: None,
22732 error_code: Some("lease.expired".to_owned()),
22733 });
22734 client.previous_version_lifetime_check();
22735 assert!(!exists());
22736 client.lease_state = None;
22737
22738 recapture_previous_version(&client);
22741 client.subs.push(subscription(SubState::Active, 0, None));
22742 client.previous_version_lifetime_check();
22743 assert!(!exists());
22744 client.subs.clear();
22745
22746 recapture_previous_version(&client);
22748 client.subs.push(subscription(SubState::Revoked, 0, None));
22749 client.previous_version_lifetime_check();
22750 assert!(!exists());
22751 client.subs.clear();
22752
22753 recapture_previous_version(&client);
22756 client
22757 .subs
22758 .push(subscription(SubState::Active, 0, Some("resume")));
22759 client.previous_version_lifetime_check();
22760 assert!(exists());
22761 client.subs.clear();
22762
22763 drop(client);
22764 let _ = std::fs::remove_file(&path);
22765 let _ = std::fs::remove_file(&container);
22766 let _ = std::fs::remove_file(format!("{container}-wal"));
22767 let _ = std::fs::remove_file(format!("{container}-shm"));
22768 }
22769}
22770
22771#[cfg(test)]
22772mod scope_move_delivery_tests {
22773 use super::*;
22774 use crate::native_transport::HostTransport;
22775 use crate::values::encode_row_json;
22776 use serde_json::json;
22777
22778 fn client() -> SyncClient {
22779 SyncClient::new(
22780 "scope-move".to_owned(),
22781 &json!({
22782 "version": 1,
22783 "tables": [{
22784 "name": "tasks",
22785 "primaryKey": "id",
22786 "columns": [
22787 { "name": "id", "type": "string", "nullable": false },
22788 { "name": "project_id", "type": "string", "nullable": false }
22789 ],
22790 "scopes": [{ "pattern": "project:{project_id}" }]
22791 }]
22792 }),
22793 ClientLimits::default(),
22794 )
22795 .expect("test client")
22796 }
22797
22798 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
22799 ssp2::model::Change {
22800 table_index: 0,
22801 row_id: "t1".to_owned(),
22802 op: Op::Upsert,
22803 row_version: Some(1),
22804 scopes: vec![("project_id".to_owned(), project.to_owned())],
22805 row: Some(
22806 encode_row_json(
22807 client.schema.table("tasks").unwrap(),
22808 "t1",
22809 &Map::from_iter([
22810 ("id".to_owned(), Value::from("t1")),
22811 ("project_id".to_owned(), Value::from(project)),
22812 ]),
22813 &client.encryption,
22814 )
22815 .unwrap(),
22816 ),
22817 }
22818 }
22819
22820 fn delete(project: &str) -> ssp2::model::Change {
22821 ssp2::model::Change {
22822 table_index: 0,
22823 row_id: "t1".to_owned(),
22824 op: Op::Delete,
22825 row_version: None,
22826 scopes: vec![("project_id".to_owned(), project.to_owned())],
22827 row: None,
22828 }
22829 }
22830
22831 #[test]
22832 fn a_scope_move_keeps_the_row_in_either_section_order() {
22833 for order in [["old", "new"], ["new", "old"]] {
22834 let mut client = client();
22835 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22836 client
22837 .subscribe(
22838 "old".into(),
22839 "tasks".into(),
22840 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22841 None,
22842 )
22843 .unwrap();
22844 client
22845 .subscribe(
22846 "new".into(),
22847 "tasks".into(),
22848 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
22849 None,
22850 )
22851 .unwrap();
22852 let seed = upsert(&client, "p1");
22853 client
22854 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
22855 .unwrap();
22856 assert_eq!(
22857 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
22858 json!("p1")
22859 );
22860
22861 let mut frames = vec![Frame::RespHeader {
22862 required_schema_version: None,
22863 latest_schema_version: None,
22864 log_epoch: Some("epoch-1".to_owned()),
22865 reset_required: Some(false),
22866 }];
22867 for id in order {
22868 let project = if id == "old" { "p1" } else { "p2" };
22869 frames.push(Frame::SubStart {
22870 id: id.to_owned(),
22871 status: SubStatus::Active,
22872 reason_code: String::new(),
22873 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
22874 bootstrap: false,
22875 });
22876 frames.push(Frame::Commit {
22877 commit_seq: 2,
22878 created_at_ms: 0,
22879 actor_id: "actor".to_owned(),
22880 tables: vec!["tasks".to_owned()],
22881 changes: vec![if id == "old" {
22882 delete("p1")
22883 } else {
22884 upsert(&client, "p2")
22885 }],
22886 });
22887 frames.push(Frame::SubEnd {
22888 next_cursor: 2,
22889 bootstrap_state: None,
22890 });
22891 }
22892 let (_, meta) = client.build_request(false).unwrap();
22893 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22894 let outcome = client.process_response(
22895 &mut transport,
22896 Message {
22897 wire_version: WIRE_VERSION,
22898 msg_kind: MsgKind::Response,
22899 frames,
22900 },
22901 &meta,
22902 );
22903 assert!(
22904 matches!(outcome, SyncOutcome::Ok(_)),
22905 "{order:?}: {outcome:?}"
22906 );
22907 let rows = client
22908 .query("SELECT id, project_id FROM tasks", &[])
22909 .unwrap();
22910 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
22911 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
22912 }
22913 }
22914
22915 struct RefusalTransport;
22916
22917 impl Transport for RefusalTransport {
22918 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
22919 let mut error = TransportError::new(
22920 "sync.invalid_client_id",
22921 "clientId is bound to a different actor in this partition",
22922 );
22923 error.details = Some(json!({
22924 "causeKind": "status",
22925 "httpStatus": 400,
22926 "category": "invalid-request",
22927 "retryable": false,
22928 "recommendedAction": "resetClientId"
22929 }));
22930 Err(error)
22931 }
22932
22933 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
22934 self.sync(request)
22935 }
22936
22937 fn download_segment(
22938 &mut self,
22939 _request: &SegmentRequest,
22940 _on_progress: &mut dyn FnMut(u64),
22941 ) -> Result<Vec<u8>, TransportError> {
22942 Err(TransportError::new("sync.transport_failed", "offline"))
22943 }
22944
22945 fn realtime_connect(&mut self) -> Result<(), TransportError> {
22946 Ok(())
22947 }
22948
22949 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
22950 Ok(())
22951 }
22952
22953 fn realtime_close(&mut self) -> Result<(), TransportError> {
22954 Ok(())
22955 }
22956 }
22957
22958 #[test]
22959 fn an_http_refusal_carries_its_code_and_recommended_action() {
22960 let mut client = client();
22961 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22962 let outcome = client.sync(&mut RefusalTransport);
22963 let SyncOutcome::Failed {
22964 error_code,
22965 recommended_action,
22966 details,
22967 ..
22968 } = outcome
22969 else {
22970 panic!("expected a failed round");
22971 };
22972 assert_eq!(error_code, "sync.invalid_client_id");
22973 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
22974 assert_eq!(
22975 details
22976 .as_ref()
22977 .and_then(|details| details.get("httpStatus")),
22978 Some(&json!(400))
22979 );
22980 }
22981
22982 #[test]
22983 fn reset_client_id_persists_and_changes_the_request_identity() {
22984 let mut client = client();
22985 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
22986 let previous = client.client_id().to_owned();
22987 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
22988 let next = client.reset_client_id(&mut transport);
22989 assert_ne!(next, previous);
22990 assert_eq!(client.client_id(), next);
22991 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
22992
22993 client
22994 .subscribe(
22995 "tasks".into(),
22996 "tasks".into(),
22997 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
22998 None,
22999 )
23000 .unwrap();
23001 let (message, _) = client.build_request(false).unwrap();
23002 assert!(matches!(
23003 message.frames.first(),
23004 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23005 ));
23006 }
23007}