1use crate::{ProgressObserver, ProgressPhase, ProgressState};
11use ssp2::encode_message;
12use std::borrow::Cow;
13#[cfg(test)]
14use std::cell::Cell;
15use std::cell::RefCell;
16use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque};
17
18use rusqlite::types::{ToSqlOutput, Value as SqlValue, ValueRef};
19use rusqlite::{Connection, OpenFlags, OptionalExtension};
20use serde::{Deserialize, Serialize};
21use serde_json::{json, Map, Value};
22use sha2::{Digest, Sha256};
23use ssp2::decode::WIRE_VERSION;
24use ssp2::model::{Frame, MediaType, Message, MsgKind, Op, OpResult, PushStatus, SubStatus};
25use ssp2::primitives::RawJson;
26use ssp2::segment::{decode_rows_segment, Column, ColumnType, ColumnValue, Row, RowsSegment};
27use ssp2::{decode_message, encode_presence_publish, parse_control, ControlMessage, PresenceKind};
28
29use crate::api::{
30 ClientChangeBatch, ClientDiagnosticsHost, ClientDiagnosticsLease, ClientDiagnosticsReplica,
31 ClientDiagnosticsRequest, ClientDiagnosticsSchema, ClientDiagnosticsSnapshot,
32 ClientDiagnosticsStorage, ClientError, ClientLimits, CommandEffects, CommitDelivery,
33 CommitOperation, CommitOperationOutcome, CommitOutcome, CommitOutcomeQuery,
34 CommitOutcomeResolution, CommitOutcomeStatus, ConflictRecord, CoverageSnapshot,
35 DiagnosticLastChange, DiagnosticLastRound, DiagnosticQueryFailure, DiagnosticRoundCounters,
36 DiagnosticSubscription, FetchedBlob, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult,
37 LocalDataPurgeTarget, LocalDataRebootstrapInput, LocalDataRebootstrapResult, Mutation,
38 PresencePeer, PreviousVersionStatus, QueryOwner, QueryReadFailure, QueryRow, QuerySnapshot,
39 QueryValue, RejectionDetails, RejectionRecord, ResolveCommitOutcomeInput, RetainedCommitRow,
40 RetainedUniqueConflict, RowState, SchemaFloor, SnapshotRead, SubscriptionCatchup,
41 SubscriptionRequest, SubscriptionStateView, SyncIntent, SyncOutcome, SyncReport,
42 SyncStatusSnapshot, TableChange, WindowBase, WindowChange, WindowCoverage, WindowState,
43 WindowUnitRef, CLIENT_DIAGNOSTICS_VERSION, MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS,
44 MAX_DIAGNOSTIC_QUERY_FAILURES,
45};
46use crate::api::{RealtimePolicy, RealtimeState, REALTIME_UNAVAILABLE_CODE};
47#[cfg(feature = "bench-internals")]
48use crate::bench::{Phase, Recorder};
49use crate::previous_version::{
50 capture_previous_version_from_replica, first_incompatibility,
51 reconcile_previous_version_at_boot, set_local_schema_descriptor,
52 sweep_previous_version_container, PendingCommitAudit, PreviousVersionContextConfig,
53 PreviousVersionLifecycle, PreviousVersionReadSpec, PreviousVersionSnapshot,
54};
55use crate::schema::{parse_schema_json, ClientSchema, FtsIndexSchema, TableSchema};
56use crate::transport::{BlobDownload, SegmentRequest, Transport, TransportError};
57use crate::values::{
58 bytes_to_hex, canonical_scope_json, column_value_to_json, decode_row_bytes,
59 encode_sparse_row_json, full_row_values, is_canonical_bytes_envelope, json_to_column_value,
60 json_to_scope_map, normalize_values_casing, render_row_id_json, scope_map_to_json,
61 sort_scope_map,
62};
63
64const DEFAULT_ACCEPT: u8 = 0b0111;
68const ACCEPT_INLINE_ROWS: u8 = 1 << 0;
69const ACCEPT_EXTERNAL_ROWS: u8 = 1 << 1;
70const ACCEPT_SQLITE: u8 = 1 << 2;
71const ACCEPT_SIGNED_URLS: u8 = 1 << 3;
72const MAX_DIAGNOSTIC_DOMAINS: usize = 256;
73
74fn recommended_action_of(details: &Option<Value>) -> Option<String> {
76 details
77 .as_ref()?
78 .get("recommendedAction")?
79 .as_str()
80 .map(str::to_owned)
81}
82
83fn coalesced_delete_keys(
91 frames: &[Frame],
92 mut applies: impl FnMut(&str, SubStatus, Option<i64>) -> bool,
93) -> HashSet<(i64, String, String)> {
94 let mut sections = Vec::new();
95 let mut open: Option<(usize, String, SubStatus)> = None;
96 for (index, frame) in frames.iter().enumerate() {
97 match frame {
98 Frame::SubStart { id, status, .. } => {
99 open = Some((index, id.clone(), *status));
100 }
101 Frame::SubEnd { next_cursor, .. } => {
102 if let Some((start, id, status)) = open.take() {
103 sections.push((start, index, id, status, Some(*next_cursor)));
104 }
105 }
106 _ => {}
107 }
108 }
109 type Key = (i64, String, String);
110 let mut ops: HashMap<Key, (HashSet<usize>, HashSet<usize>)> = HashMap::new();
111 let mut both_in_frame: HashSet<Key> = HashSet::new();
112 let mut frame_ordinal = 0usize;
113 for (start, end, id, status, next_cursor) in sections {
114 if !applies(&id, status, next_cursor) {
115 continue;
116 }
117 for frame in &frames[start + 1..end] {
118 let Frame::Commit {
119 commit_seq,
120 tables,
121 changes,
122 ..
123 } = frame
124 else {
125 continue;
126 };
127 frame_ordinal += 1;
128 let mut local: HashMap<(String, String), (bool, bool)> = HashMap::new();
129 for change in changes {
130 let Some(table) = tables.get(change.table_index as usize) else {
131 continue;
132 };
133 let key = (*commit_seq, table.clone(), change.row_id.clone());
134 let entry = ops.entry(key).or_default();
135 let local_entry = local
136 .entry((table.clone(), change.row_id.clone()))
137 .or_default();
138 match change.op {
139 Op::Upsert => {
140 entry.0.insert(frame_ordinal);
141 local_entry.0 = true;
142 }
143 Op::Delete => {
144 entry.1.insert(frame_ordinal);
145 local_entry.1 = true;
146 }
147 }
148 }
149 for ((table, row_id), (has_upsert, has_delete)) in local {
150 if has_upsert && has_delete {
151 both_in_frame.insert((*commit_seq, table, row_id));
152 }
153 }
154 }
155 }
156 ops.into_iter()
157 .filter_map(|(key, (upserts, deletes))| {
158 (!both_in_frame.contains(&key) && !upserts.is_empty() && !deletes.is_empty())
159 .then_some(key)
160 })
161 .collect()
162}
163
164pub(crate) const LOCAL_SCHEMA_VERSION_KEY: &str = "localSchemaVersion";
166const LOCAL_REVISION_KEY: &str = "localRevision";
167const CLIENT_ID_KEY: &str = "clientId";
168const LEASE_STATE_KEY: &str = "leaseState";
169const SCHEMA_FLOOR_KEY: &str = "schemaFloor";
170pub(crate) const LOG_EPOCH_KEY: &str = "logEpoch";
171const SECURITY_PREFLIGHT_PENDING_KEY: &str = "securityPreflightPending";
176const LOCAL_REBOOTSTRAP_RECEIPT_VERSION: u8 = 2;
177const MAX_JS_SAFE_INTEGER: u64 = 9_007_199_254_740_991;
178const OUTBOX_INCOMPATIBLE_CODE: &str = "sync.outbox_incompatible";
181const ENCRYPT_FAILED_CODE: &str = "client.encrypt_failed";
184const INVALID_REQUEST_CODE: &str = "sync.invalid_request:";
185const LEGACY_VALUE_REFUSAL_MESSAGE: &str =
187 "the persisted commit carries values the current codec refuses";
188const INVALID_STORED_VALUES_REASON: &str = "invalid_stored_values";
190
191#[derive(Debug, Serialize, Deserialize)]
192#[serde(rename_all = "camelCase", deny_unknown_fields)]
193struct PersistedLocalDataRebootstrapReceipt {
194 version: u8,
195 retained_commits: u64,
196 reset_subscriptions: u64,
197}
198
199fn invalid_local_rebootstrap_receipt() -> String {
200 "sync.local_corrupt: persisted local rebootstrap receipt is invalid".to_owned()
201}
202
203fn encode_local_rebootstrap_receipt(
204 retained_commits: usize,
205 reset_subscriptions: usize,
206) -> Result<String, String> {
207 let retained_commits =
208 u64::try_from(retained_commits).map_err(|_| invalid_local_rebootstrap_receipt())?;
209 let reset_subscriptions =
210 u64::try_from(reset_subscriptions).map_err(|_| invalid_local_rebootstrap_receipt())?;
211 if retained_commits > MAX_JS_SAFE_INTEGER || reset_subscriptions > MAX_JS_SAFE_INTEGER {
212 return Err(invalid_local_rebootstrap_receipt());
213 }
214 serde_json::to_string(&PersistedLocalDataRebootstrapReceipt {
215 version: LOCAL_REBOOTSTRAP_RECEIPT_VERSION,
216 retained_commits,
217 reset_subscriptions,
218 })
219 .map_err(|_| invalid_local_rebootstrap_receipt())
220}
221
222fn decode_local_rebootstrap_receipt(value: &str) -> Result<(usize, usize), String> {
223 if value == "v1" {
225 return Ok((0, 0));
226 }
227 let receipt: PersistedLocalDataRebootstrapReceipt =
228 serde_json::from_str(value).map_err(|_| invalid_local_rebootstrap_receipt())?;
229 if receipt.version != LOCAL_REBOOTSTRAP_RECEIPT_VERSION
230 || receipt.retained_commits > MAX_JS_SAFE_INTEGER
231 || receipt.reset_subscriptions > MAX_JS_SAFE_INTEGER
232 {
233 return Err(invalid_local_rebootstrap_receipt());
234 }
235 Ok((
236 usize::try_from(receipt.retained_commits)
237 .map_err(|_| invalid_local_rebootstrap_receipt())?,
238 usize::try_from(receipt.reset_subscriptions)
239 .map_err(|_| invalid_local_rebootstrap_receipt())?,
240 ))
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
244enum SubState {
245 Active,
246 Revoked,
247 Failed,
248}
249
250#[cfg(test)]
251mod observation_tests {
252 use super::*;
253 use crate::native_transport::HostTransport;
254 use crate::values::encode_row_json;
255 use crate::BlobUploadGrant;
256 use serde_json::json;
257
258 #[test]
259 fn authority_policy_refuses_encrypted_and_undeclared_columns_before_open() {
260 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
261 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "secret", "type": "bytes", "encrypted": true, "declaredType": "string", "nullable": true}
262 ]}]});
263 for name in [
264 "secret",
265 "missing",
266 "*",
267 "id FROM authority; DELETE FROM authority",
268 "_syncular_version",
269 ] {
270 let limits = ClientLimits {
271 authority_reads: vec![crate::api::AuthorityReadDeclaration {
272 table: "authority".into(),
273 columns: vec!["id".into(), "actor_id".into(), name.into()],
274 scopes: [("actor_id".into(), vec!["a".into()])].into(),
275 }],
276 ..Default::default()
277 };
278 let error = SyncClient::new("authority".into(), &schema, limits)
279 .err()
280 .expect("forbidden policy");
281 assert!(
282 error.starts_with("client.authority_read_forbidden:"),
283 "{error}"
284 );
285 }
286 }
287
288 #[test]
289 fn authority_snapshot_observes_one_file_transaction_during_concurrent_writes() {
290 let path = std::env::temp_dir().join(format!(
291 "syncular-authority-snapshot-{}.db",
292 uuid::Uuid::new_v4()
293 ));
294 let schema = json!({"version": 1, "tables": [{"name": "authority", "primaryKey": "id", "scopes": [{"pattern": "actor:{actor_id}"}], "columns": [
295 {"name": "id", "type": "string", "nullable": false}, {"name": "actor_id", "type": "string", "nullable": false}, {"name": "revision", "type": "integer", "nullable": false}
296 ]}]});
297 let limits = ClientLimits {
298 authority_reads: vec![crate::api::AuthorityReadDeclaration {
299 table: "authority".into(),
300 columns: vec!["id".into(), "actor_id".into(), "revision".into()],
301 scopes: [("actor_id".into(), vec!["a".into()])].into(),
302 }],
303 ..Default::default()
304 };
305 let mut client =
306 SyncClient::open_path("authority".into(), &schema, limits, path.to_str().unwrap())
307 .unwrap();
308 client.begin_security_preflight();
309 let update = |conn: &Connection, revision: i64| {
310 conn.execute_batch("BEGIN IMMEDIATE").unwrap();
311 conn.execute(
312 "INSERT OR REPLACE INTO _syncular_base_authority VALUES('one','a',?1,1)",
313 [revision],
314 )
315 .unwrap();
316 conn.execute(
317 "UPDATE _syncular_meta SET value=?1 WHERE key='localRevision'",
318 [revision.to_string()],
319 )
320 .unwrap();
321 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();
322 conn.execute_batch("COMMIT").unwrap();
323 };
324 update(&client.conn, 1);
325 let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
326 let writer_barrier = barrier.clone();
327 let writer_path = path.clone();
328 let writer = std::thread::spawn(move || {
329 let conn = Connection::open(writer_path).unwrap();
330 conn.busy_timeout(std::time::Duration::from_secs(5))
331 .unwrap();
332 writer_barrier.wait();
333 for revision in 2..=100 {
334 update(&conn, revision);
335 }
336 });
337 barrier.wait();
338 for _ in 0..100 {
339 let snapshot = client.authority_snapshot().unwrap();
340 let revision = snapshot["revision"]
341 .as_str()
342 .unwrap()
343 .parse::<i64>()
344 .unwrap();
345 assert_eq!(
346 snapshot["tables"][0]["rows"][0]["values"]["revision"],
347 revision
348 );
349 assert_eq!(snapshot["tables"][0]["persisted"][0]["cursor"], revision);
350 assert_eq!(snapshot["complete"], true);
351 }
352 writer.join().unwrap();
353 assert_eq!(client.authority_snapshot().unwrap()["revision"], "100");
354 drop(client);
355 let _ = std::fs::remove_file(path);
356 }
357
358 fn client() -> SyncClient {
359 SyncClient::new(
360 "retry-test".to_owned(),
361 &json!({
362 "version": 1,
363 "tables": [{
364 "name": "tasks",
365 "primaryKey": "id",
366 "columns": [
367 { "name": "id", "type": "string", "nullable": false },
368 { "name": "project_id", "type": "string", "nullable": false }
369 ],
370 "scopes": [{ "pattern": "project:{project_id}" }]
371 }]
372 }),
373 ClientLimits::default(),
374 )
375 .expect("test client")
376 }
377
378 fn round_error(outcome: SyncOutcome) -> TransportError {
379 match outcome {
380 SyncOutcome::Failed {
381 error_code,
382 message,
383 details,
384 ..
385 } => TransportError {
386 code: error_code,
387 message,
388 details,
389 },
390 other => TransportError::new(
391 "client.failed",
392 format!("unexpected round outcome: {other:?}"),
393 ),
394 }
395 }
396
397 fn flush_blob_uploads(
400 client: &mut SyncClient,
401 transport: &mut dyn Transport,
402 ) -> Result<(), TransportError> {
403 let mut prepared = match client.prepare_sync_round(false) {
404 Ok(prepared) => prepared,
405 Err(outcome) => return Err(round_error(*outcome)),
406 };
407 loop {
408 if prepared.uploads.is_empty() {
409 client.cancel_sync_round();
410 return Ok(());
411 }
412 let completed = crate::round::test_support::drive_round(prepared, transport);
413 prepared = match client.apply_sync_round(completed) {
414 crate::AppliedSyncRound::Continue(prepared) => prepared,
415 crate::AppliedSyncRound::Complete { outcome, .. } => {
416 return match outcome {
417 SyncOutcome::Ok(_) => Ok(()),
418 other => Err(round_error(other)),
419 };
420 }
421 };
422 }
423 }
424
425 #[test]
426 fn subscribe_many_registers_all_or_nothing() {
427 let request = |id: &str, project: &str| SubscriptionRequest {
428 id: id.to_owned(),
429 table: "tasks".to_owned(),
430 scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
431 params: None,
432 };
433 let mut client = client();
434 client
435 .subscribe_many(vec![request("a", "p1"), request("b", "p2")])
436 .unwrap();
437 let persisted = |client: &SyncClient| -> i64 {
438 client
439 .conn
440 .query_row("SELECT count(*) FROM _syncular_subscriptions", [], |row| {
441 row.get(0)
442 })
443 .unwrap()
444 };
445 assert_eq!(persisted(&client), 2);
446 let error = client
448 .subscribe_many(vec![request("c", "p3"), request("b", "p9")])
449 .unwrap_err();
450 assert!(error.starts_with("client.subscription_intent_mismatch:"));
451 assert!(client.subscription_state("c").is_none());
452 assert_eq!(persisted(&client), 2);
453 assert!(client.conn.is_autocommit());
454 }
455
456 #[cfg(feature = "e2ee")]
457 #[test]
458 fn an_unresolvable_key_id_becomes_a_durable_rejection_at_the_push_seam() {
459 let schema = json!({
460 "version": 1,
461 "tables": [{
462 "name": "secrets",
463 "primaryKey": "id",
464 "columns": [
465 { "name": "id", "type": "string", "nullable": false },
466 { "name": "encryption_key_id", "type": "string", "nullable": true },
467 { "name": "note", "type": "bytes", "nullable": true,
468 "encrypted": true, "declaredType": "string" }
469 ],
470 "scopes": []
471 }]
472 });
473 let mut client = SyncClient::new("encrypt-reject".into(), &schema, ClientLimits::default())
474 .expect("test client");
475 let mut config = crate::values::EncryptionConfig::default();
476 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
477 config
478 .key_id_columns
479 .insert("secrets".to_owned(), "encryption_key_id".to_owned());
480 client.set_encryption(config);
481 client
484 .write_base_row(
485 "secrets",
486 &vec![Some(ColumnValue::String("ghost".into())), None, None],
487 1,
488 )
489 .unwrap();
490 client.rebuild_overlay().expect("rebuild overlay");
491 let commit_id = client
492 .patch(
493 "secrets",
494 "ghost",
495 Map::from_iter([("note".to_owned(), json!("x"))]),
496 None,
497 )
498 .expect("patch records without an author-time encrypt failure");
499 assert_eq!(client.pending_commit_ids(), vec![commit_id.clone()]);
500 assert!(client.drop_unencodable_outbox(true, None).unwrap());
502 assert!(client.pending_commit_ids().is_empty());
503 let rejections = client.rejections();
504 assert_eq!(rejections.len(), 1);
505 assert_eq!(rejections[0].code, "client.encrypt_failed");
506 assert_eq!(rejections[0].client_commit_id, commit_id);
507 assert_eq!(
508 client
509 .commit_outcome(&commit_id)
510 .unwrap()
511 .map(|outcome| outcome.status),
512 Some(CommitOutcomeStatus::Rejected)
513 );
514 }
515
516 #[test]
517 fn patch_scope_read_preserves_sqlite_failure() {
518 let mut client = client();
519 client
520 .conn
521 .execute_batch(
522 "DROP TABLE tasks;
523 CREATE VIEW tasks AS SELECT 't1' AS id, abs(-9223372036854775808) AS project_id;",
524 )
525 .unwrap();
526 let error = client
527 .patch(
528 "tasks",
529 "t1",
530 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
531 None,
532 )
533 .expect_err("the scope read must fail");
534 assert_ne!(error.code, "sync.invalid_request");
535 assert_eq!(error.code, "client.failed");
536 assert!(error.details.as_ref().unwrap()["legacyCause"]
537 .as_str()
538 .unwrap()
539 .contains("integer overflow"));
540 assert!(client.pending_commit_ids().is_empty());
541 assert!(client.conn.is_autocommit());
542 }
543
544 #[test]
545 fn patch_with_a_scope_column_matches_the_stored_local_row() {
546 let mut client = client();
547 client.create_synced_tables().unwrap();
548 client
549 .conn
550 .execute(
551 "INSERT INTO tasks (id, project_id, _syncular_version) VALUES ('t1', 'p1', 1)",
552 [],
553 )
554 .unwrap();
555
556 client
559 .patch(
560 "tasks",
561 "t1",
562 Map::from_iter([
563 ("project_id".to_owned(), json!("p1")),
564 ("id".to_owned(), json!("t1")),
565 ]),
566 None,
567 )
568 .expect("an equal scope column is a no-op");
569 let values = client.outbox[0].ops[0]
570 .values
571 .clone()
572 .expect("the patch records values");
573 assert_eq!(values.get("id"), Some(&json!("t1")));
574 assert!(!values.contains_key("project_id"));
575
576 let differing = client.patch(
578 "tasks",
579 "t1",
580 Map::from_iter([("project_id".to_owned(), json!("p2"))]),
581 None,
582 );
583 let differing = differing.expect_err("a differing scope column is rejected");
584 assert_eq!(differing.code, "sync.invalid_request");
585 assert_eq!(differing.message, "the authoring request is invalid");
586 assert!(
587 differing
588 .details
589 .as_ref()
590 .and_then(|details| details["legacyCause"].as_str())
591 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
592 "{differing:?}"
593 );
594
595 let absent = client.patch(
598 "tasks",
599 "ghost",
600 Map::from_iter([("project_id".to_owned(), json!("p1"))]),
601 None,
602 );
603 let absent = absent.expect_err("an absent local row is rejected");
604 assert_eq!(absent.code, "sync.row_missing");
605 assert!(absent.message.contains("requires a local row"));
606 assert_eq!(client.pending_commit_ids().len(), 1);
607 }
608
609 #[test]
610 fn patch_scope_column_parity_for_the_primary_key_and_coerced_values() {
611 let schema = json!({
612 "version": 1,
613 "tables": [
614 {
615 "name": "tenants",
616 "primaryKey": "tenant_id",
617 "columns": [
618 { "name": "tenant_id", "type": "string", "nullable": false },
619 { "name": "body", "type": "string", "nullable": false }
620 ],
621 "scopes": [{ "pattern": "tenant:{tenant_id}" }]
622 },
623 {
624 "name": "buckets",
625 "primaryKey": "id",
626 "columns": [
627 { "name": "id", "type": "string", "nullable": false },
628 { "name": "bucket", "type": "float", "nullable": false },
629 { "name": "body", "type": "string", "nullable": false }
630 ],
631 "scopes": [{ "pattern": "bucket:{bucket}" }]
632 }
633 ]
634 });
635 let mut client = SyncClient::new("scope-parity".into(), &schema, ClientLimits::default())
636 .expect("test client");
637 client.create_synced_tables().unwrap();
638
639 client
641 .conn
642 .execute(
643 "INSERT INTO tenants (tenant_id, body, _syncular_version) VALUES ('t1', 'seed', 1)",
644 [],
645 )
646 .unwrap();
647 client
648 .patch(
649 "tenants",
650 "t1",
651 Map::from_iter([
652 ("tenant_id".to_owned(), json!("t1")),
653 ("body".to_owned(), json!("created")),
654 ]),
655 None,
656 )
657 .expect("the primary-key scope column is proven equal by construction");
658
659 client
661 .conn
662 .execute(
663 "INSERT INTO buckets (id, bucket, body, _syncular_version) VALUES ('b1', 2.0, 'seed', 1)",
664 [],
665 )
666 .unwrap();
667 client
668 .patch(
669 "buckets",
670 "b1",
671 Map::from_iter([
672 ("bucket".to_owned(), json!(2)),
673 ("body".to_owned(), json!("coerced")),
674 ]),
675 None,
676 )
677 .expect("the stored REAL value coerces from a supplied integer");
678 let values = client.outbox[1].ops[0]
679 .values
680 .clone()
681 .expect("the patch records values");
682 assert!(!values.contains_key("bucket"));
683
684 let differing = client.patch(
686 "buckets",
687 "b1",
688 Map::from_iter([("bucket".to_owned(), json!(3))]),
689 None,
690 );
691 let differing = differing.expect_err("a differing scope value is rejected");
692 assert_eq!(differing.code, "sync.invalid_request");
693 assert_eq!(differing.message, "the authoring request is invalid");
694 assert!(
695 differing
696 .details
697 .as_ref()
698 .and_then(|details| details["legacyCause"].as_str())
699 .is_some_and(|cause| cause.contains("patch cannot write scope column")),
700 "{differing:?}"
701 );
702 }
703
704 #[cfg(feature = "e2ee")]
705 #[test]
706 fn stored_key_fallback_resolves_an_integer_primary_key() {
707 let schema = json!({
708 "version": 1,
709 "tables": [{
710 "name": "patients",
711 "primaryKey": "id",
712 "columns": [
713 { "name": "id", "type": "integer", "nullable": false },
714 { "name": "encryption_key_id", "type": "string", "nullable": true },
715 { "name": "note", "type": "bytes", "nullable": true,
716 "encrypted": true, "declaredType": "string" }
717 ],
718 "scopes": []
719 }]
720 });
721 let mut client = SyncClient::new("int-key".into(), &schema, ClientLimits::default())
722 .expect("test client");
723 client.create_synced_tables().unwrap();
724 client
725 .conn
726 .execute(
727 "INSERT INTO patients (id, encryption_key_id, note, _syncular_version)
728 VALUES (5, 'k1', NULL, 1)",
729 [],
730 )
731 .unwrap();
732 let mut config = crate::values::EncryptionConfig::default();
733 config.keys.insert("k1".to_owned(), vec![0x2A; 32]);
734 config
735 .key_id_columns
736 .insert("patients".to_owned(), "encryption_key_id".to_owned());
737 client.set_encryption(config);
738 let table = client.schema.table("patients").unwrap();
739 let fallback = client
740 .stored_key_fallback(table, "5")
741 .expect("the stored integer key resolves the fallback");
742 assert_eq!(fallback[1], Some(ColumnValue::String("k1".to_owned())));
743 }
744
745 #[test]
746 fn patch_types_an_integer_primary_key_from_its_canonical_row_id() {
747 let schema = json!({
748 "version": 1,
749 "tables": [{
750 "name": "counters",
751 "primaryKey": "id",
752 "columns": [
753 { "name": "id", "type": "integer", "nullable": false },
754 { "name": "title", "type": "string", "nullable": false }
755 ],
756 "scopes": []
757 }]
758 });
759 let mut client = SyncClient::new("int-patch".into(), &schema, ClientLimits::default())
760 .expect("test client");
761 client
762 .write_base_row(
763 "counters",
764 &vec![
765 Some(ColumnValue::Integer(42)),
766 Some(ColumnValue::String("before".into())),
767 ],
768 1,
769 )
770 .unwrap();
771 client.rebuild_overlay().expect("rebuild overlay");
772 client
773 .patch(
774 "counters",
775 "42",
776 Map::from_iter([
777 ("id".to_owned(), json!(42)),
778 ("title".to_owned(), json!("after")),
779 ]),
780 None,
781 )
782 .expect("an integer key that renders as the rowId is not a change");
783 let rows = client.query("SELECT id, title FROM counters", &[]).unwrap();
784 assert_eq!(Value::from(rows), json!([{"id": 42, "title": "after"}]));
785 for row_id in ["042", "+42", "42.0"] {
786 let error = client
787 .patch(
788 "counters",
789 row_id,
790 Map::from_iter([("title".to_owned(), json!("x"))]),
791 None,
792 )
793 .expect_err("a non-canonical integer rowId names no row");
794 assert_eq!(error.code, "sync.row_missing", "{row_id}");
795 }
796 }
797
798 #[test]
799 fn blob_staging_failures_preserve_durable_state_and_allow_retry() {
800 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
801 {"name":"id","type":"string","nullable":false},
802 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
803 for state in ["absent", "cached", "pinned"] {
804 let path = std::env::temp_dir().join(format!(
805 "syncular-blob-stage-{}.sqlite",
806 uuid::Uuid::new_v4()
807 ));
808 let mut client = SyncClient::open_path(
809 "stage-test".into(),
810 &schema,
811 Default::default(),
812 path.to_str().unwrap(),
813 )
814 .unwrap();
815 let bytes = b"atomic stage";
816 if state != "absent" {
817 client
818 .upload_blob(bytes, Some("text/plain".into()), None)
819 .unwrap();
820 if state == "cached" {
821 client
822 .conn
823 .execute("DELETE FROM _syncular_blob_uploads", [])
824 .unwrap();
825 }
826 }
827 let bodies = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
828 client
829 .conn
830 .execute_batch(
831 "CREATE TRIGGER fail_stage_insert BEFORE INSERT ON _syncular_blobs
832 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;
833 CREATE TRIGGER fail_stage_update BEFORE UPDATE ON _syncular_blobs
834 BEGIN SELECT RAISE(ABORT, 'injected stage failure'); END;",
835 )
836 .unwrap();
837 let error = client
838 .upload_blob(bytes, Some("application/octet-stream".into()), None)
839 .unwrap_err();
840 assert!(error.contains("injected stage failure"), "{state}: {error}");
841 assert!(client.conn.is_autocommit());
842 let immediate = client.query("SELECT * FROM _syncular_blobs", &[]).unwrap();
843 drop(client);
844 let mut reopened = SyncClient::open_path(
845 "stage-test".into(),
846 &schema,
847 Default::default(),
848 path.to_str().unwrap(),
849 )
850 .unwrap();
851 let durable = reopened
852 .query("SELECT * FROM _syncular_blobs", &[])
853 .unwrap();
854 reopened
855 .conn
856 .execute_batch("DROP TRIGGER fail_stage_insert; DROP TRIGGER fail_stage_update")
857 .unwrap();
858 let retried = reopened.upload_blob(bytes, None, None).unwrap();
859 assert_eq!(reopened.upload_blob(bytes, None, None).unwrap(), retried);
860 assert_eq!(
861 reopened
862 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
863 .unwrap(),
864 vec![Map::from_iter([(
865 "blob_id".into(),
866 Value::from(blob_id_for(bytes)),
867 )])]
868 );
869 drop(reopened);
870 std::fs::remove_file(&path).unwrap();
871 assert_eq!(durable, bodies, "{state}: reopened body state");
872 assert_eq!(immediate, bodies, "{state}: immediate body state");
873 }
874 }
875
876 #[test]
877 fn blob_upload_failures_preserve_pins_and_original_commits_after_file_reopen() {
878 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
879 {"name":"id","type":"string","nullable":false},
880 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
881 for fault in [
882 "missing",
883 "body-type",
884 "length",
885 "hash",
886 "pin-metadata",
887 "read",
888 "pin-delete",
889 ] {
890 let path = std::env::temp_dir().join(format!(
891 "syncular-blob-upload-{}.sqlite",
892 uuid::Uuid::new_v4()
893 ));
894 let mut client = SyncClient::open_path(
895 "upload-test".into(),
896 &schema,
897 Default::default(),
898 path.to_str().unwrap(),
899 )
900 .unwrap();
901 let bytes = b"durable upload";
902 let ref_value = client
903 .upload_blob(bytes, Some("text/plain".into()), None)
904 .unwrap();
905 let commit = client
906 .mutate(vec![Mutation::Upsert {
907 table: "attachments".into(),
908 values: Map::from_iter([
909 ("id".into(), Value::from("pending")),
910 (
911 "file".into(),
912 Value::from(format!(
913 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
914 blob_id_for(bytes),
915 bytes.len()
916 )),
917 ),
918 ]),
919 base_version: None,
920 }])
921 .unwrap();
922 client
923 .conn
924 .execute_batch("CREATE TABLE saved_bodies AS SELECT * FROM _syncular_blobs")
925 .unwrap();
926 client.conn.execute_batch(match fault {
927 "missing" => "DELETE FROM _syncular_blobs",
928 "body-type" => "UPDATE _syncular_blobs SET bytes = 'wrong type'",
929 "length" => "UPDATE _syncular_blobs SET byte_length = byte_length + 1",
930 "hash" => "UPDATE _syncular_blobs SET bytes = zeroblob(byte_length)",
931 "pin-metadata" => "UPDATE _syncular_blob_uploads SET media_type = x'ff'",
932 "read" => "ALTER TABLE _syncular_blobs RENAME TO unavailable_bodies",
933 _ => "CREATE TRIGGER fail_pin_delete BEFORE DELETE ON _syncular_blob_uploads BEGIN SELECT RAISE(ABORT, 'injected pin deletion failure'); END",
934 }).unwrap();
935 let state = if fault == "read" {
936 Vec::new()
937 } else {
938 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap()
939 };
940 client.set_meta(LOG_EPOCH_KEY, "epoch");
941 let mut transport = CountingRealtimeTransport::default();
942 let error = flush_blob_uploads(&mut client, &mut transport).unwrap_err();
943 assert_eq!(
944 error.code,
945 if matches!(fault, "read" | "pin-delete") {
946 "client.failed"
947 } else {
948 "sync.local_corrupt"
949 },
950 "{fault}"
951 );
952 assert_eq!(
953 transport.blob_uploads.len(),
954 usize::from(fault == "pin-delete"),
955 "{fault}: corrupt bytes must never be uploaded"
956 );
957 assert_eq!(client.pending_commit_ids(), std::slice::from_ref(&commit));
958 if fault != "read" {
959 assert_eq!(
960 client.query("SELECT * FROM _syncular_blobs", &[]).unwrap(),
961 state
962 );
963 } else {
964 client
965 .conn
966 .execute_batch("ALTER TABLE unavailable_bodies RENAME TO _syncular_blobs")
967 .unwrap();
968 }
969 drop(client);
970 let mut reopened = SyncClient::open_path(
971 "upload-test".into(),
972 &schema,
973 Default::default(),
974 path.to_str().unwrap(),
975 )
976 .unwrap();
977 assert_eq!(reopened.pending_commit_ids(), std::slice::from_ref(&commit));
978 if fault != "read" {
979 assert_eq!(
980 reopened
981 .query("SELECT * FROM _syncular_blobs", &[])
982 .unwrap(),
983 state
984 );
985 }
986 if fault == "pin-delete" {
987 reopened
988 .conn
989 .execute_batch("DROP TRIGGER fail_pin_delete")
990 .unwrap();
991 }
992 if fault == "pin-metadata" {
993 reopened
994 .conn
995 .execute(
996 "UPDATE _syncular_blob_uploads SET media_type = 'text/plain'",
997 [],
998 )
999 .unwrap();
1000 }
1001 reopened
1002 .conn
1003 .execute_batch(
1004 "DELETE FROM _syncular_blobs;
1005 INSERT INTO _syncular_blobs SELECT * FROM saved_bodies",
1006 )
1007 .unwrap();
1008 transport.blob_uploads.clear();
1009 flush_blob_uploads(&mut reopened, &mut transport).unwrap();
1010 assert_eq!(
1011 transport.blob_uploads,
1012 [(blob_id_for(bytes), bytes.to_vec())]
1013 );
1014 assert_eq!(
1015 reopened
1016 .query("SELECT blob_id FROM _syncular_blob_uploads", &[])
1017 .unwrap(),
1018 Vec::<Map<String, Value>>::new()
1019 );
1020 assert_eq!(reopened.pending_commit_ids(), [commit]);
1021 assert_eq!(
1022 reopened
1023 .get_cached_blob_bytes(ref_value["blobId"].as_str().unwrap())
1024 .unwrap()
1025 .unwrap()
1026 .bytes,
1027 bytes
1028 );
1029 drop(reopened);
1030 std::fs::remove_file(path).unwrap();
1031 }
1032 }
1033
1034 fn coalesced_fixture(sections: usize, commits: usize, rows: usize) -> (SyncClient, Message) {
1038 let mut client = client();
1039 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1040 let mut response = Message {
1041 wire_version: WIRE_VERSION,
1042 msg_kind: MsgKind::Response,
1043 frames: vec![Frame::RespHeader {
1044 required_schema_version: None,
1045 latest_schema_version: None,
1046 log_epoch: Some("epoch-1".into()),
1047 reset_required: Some(false),
1048 }],
1049 };
1050 let mut seq = 0;
1051 for section in 0..sections {
1052 let project = format!("p{section}");
1053 let scopes = vec![("project_id".to_owned(), vec![project.clone()])];
1054 client
1055 .subscribe(format!("s{section}"), "tasks".into(), scopes.clone(), None)
1056 .unwrap();
1057 let sub = client.subs.last_mut().unwrap();
1058 sub.cursor = 0;
1059 sub.synced_once = true;
1060 sub.effective = Some(scopes.clone());
1061 let sub = sub.clone();
1062 client.persist_sub(&sub).unwrap();
1063 response.frames.push(Frame::SubStart {
1064 id: format!("s{section}"),
1065 status: SubStatus::Active,
1066 reason_code: "".into(),
1067 effective_scopes: scopes,
1068 bootstrap: false,
1069 });
1070 for _ in 0..commits {
1071 seq += 1;
1072 let changes = (0..rows)
1073 .map(|row| {
1074 let id = format!("{project}-{seq}-{row}");
1075 let values = Map::from_iter([
1076 ("id".into(), Value::from(id.clone())),
1077 ("project_id".into(), Value::from(project.clone())),
1078 ]);
1079 ssp2::model::Change {
1080 table_index: 0,
1081 row_id: id.clone(),
1082 op: Op::Upsert,
1083 row_version: Some(seq),
1084 scopes: vec![("project_id".into(), project.clone())],
1085 row: Some(
1086 encode_row_json(
1087 client.schema.table("tasks").unwrap(),
1088 &id,
1089 &values,
1090 &client.encryption,
1091 )
1092 .unwrap(),
1093 ),
1094 }
1095 })
1096 .collect();
1097 response.frames.push(Frame::Commit {
1098 commit_seq: seq,
1099 actor_id: "writer".into(),
1100 created_at_ms: 1,
1101 tables: vec!["tasks".into()],
1102 changes,
1103 });
1104 }
1105 response.frames.push(Frame::SubEnd {
1106 next_cursor: seq,
1107 bootstrap_state: None,
1108 });
1109 }
1110 client.drain_change_batches();
1111 (client, response)
1112 }
1113
1114 fn count_commits(client: &SyncClient) -> std::sync::Arc<std::sync::atomic::AtomicUsize> {
1115 let commits = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1116 let counter = commits.clone();
1117 client
1118 .conn
1119 .commit_hook(Some(move || {
1120 counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
1121 false
1122 }))
1123 .expect("commit hook");
1124 commits
1125 }
1126
1127 #[test]
1128 fn a_response_of_many_small_sections_commits_once() {
1129 for realtime in [false, true] {
1130 let (mut client, response) = coalesced_fixture(70, 1, 1);
1131 client.realtime_state = RealtimeState::Connected;
1132 client.realtime_reason_code = None;
1133 let mut transport = CountingRealtimeTransport::default();
1134 let (_, meta) = client.build_request(false).unwrap();
1135 let commits = count_commits(&client);
1136 if realtime {
1137 client.on_realtime_binary(&mut transport, &encode_message(&response));
1138 } else {
1139 assert!(matches!(
1140 client.process_response(&mut transport, response, &meta),
1141 SyncOutcome::Ok(_)
1142 ));
1143 }
1144 assert_eq!(
1145 commits.load(std::sync::atomic::Ordering::SeqCst),
1146 1,
1147 "{realtime}"
1148 );
1149 assert!(client.conn.is_autocommit());
1150 assert!(client.subs.iter().all(|sub| sub.cursor > 0));
1151 let revisions = client
1153 .drain_change_batches()
1154 .into_iter()
1155 .filter(|batch| !batch.tables.is_empty())
1156 .map(|batch| batch.revision)
1157 .collect::<Vec<_>>();
1158 assert_eq!(
1159 revisions,
1160 (1..=70).map(|r: u64| r.to_string()).collect::<Vec<_>>()
1161 );
1162 assert_eq!(client.query("SELECT id FROM tasks", &[]).unwrap().len(), 70);
1163 assert_eq!(
1165 transport
1166 .messages
1167 .iter()
1168 .filter(|m| m.contains("\"ack\""))
1169 .count(),
1170 1,
1171 "{realtime}"
1172 );
1173 }
1174 }
1175
1176 #[test]
1177 fn a_response_past_the_row_bound_commits_per_bound() {
1178 let (mut client, response) = coalesced_fixture(1, 3, 600);
1180 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1181 let (_, meta) = client.build_request(false).unwrap();
1182 let commits = count_commits(&client);
1183 assert!(matches!(
1184 client.process_response(&mut transport, response, &meta),
1185 SyncOutcome::Ok(_)
1186 ));
1187 assert_eq!(commits.load(std::sync::atomic::Ordering::SeqCst), 3);
1188 assert_eq!(
1189 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1190 1800
1191 );
1192 assert_eq!(client.subs[0].cursor, 3);
1193 }
1194
1195 #[test]
1196 fn a_failed_coalesced_commit_restores_memory_and_publishes_nothing() {
1197 let (mut client, response) = coalesced_fixture(2, 2, 600);
1201 client
1202 .conn
1203 .execute_batch(
1204 "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",
1205 )
1206 .unwrap();
1207 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
1208 let (_, meta) = client.build_request(false).unwrap();
1209 let subs = client.subs.clone();
1210 let revision = client.local_revision();
1211 let outcome = client.process_response(&mut transport, response.clone(), &meta);
1212 assert!(
1213 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == "storage.failed"),
1214 "{outcome:?}"
1215 );
1216 assert!(client.conn.is_autocommit());
1217 assert!(client.coalesced.is_none() && client.coalesced_failure.is_none());
1218 assert_eq!(client.local_revision(), revision);
1219 assert!(client
1220 .drain_change_batches()
1221 .iter()
1222 .all(|batch| batch.tables.is_empty()));
1223 assert!(client
1224 .query("SELECT id FROM tasks", &[])
1225 .unwrap()
1226 .is_empty());
1227 assert_eq!(
1228 client.subs.iter().map(|s| s.cursor).collect::<Vec<_>>(),
1229 subs.iter().map(|s| s.cursor).collect::<Vec<_>>()
1230 );
1231 client
1233 .conn
1234 .execute_batch("DROP TRIGGER fail_commit")
1235 .unwrap();
1236 let (_, meta) = client.build_request(false).unwrap();
1237 assert!(matches!(
1238 client.process_response(&mut transport, response, &meta),
1239 SyncOutcome::Ok(_)
1240 ));
1241 assert_eq!(
1242 client.query("SELECT id FROM tasks", &[]).unwrap().len(),
1243 2400
1244 );
1245 }
1246
1247 #[test]
1248 fn incoming_frame_transactions_preserve_prefix_cursor_and_revision_after_reopen() {
1249 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
1250 {"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false}],
1251 "scopes":[{"pattern":"project:{project_id}"}]}]});
1252 for realtime in [false, true] {
1253 for fault in [
1254 "none",
1255 "row",
1256 "revision",
1257 "commit",
1258 "cursor",
1259 "error",
1260 "missing-end",
1261 ] {
1262 if realtime && fault == "missing-end" {
1263 continue;
1264 }
1265 let path = std::env::temp_dir()
1266 .join(format!("syncular-frame-{}.sqlite", uuid::Uuid::new_v4()));
1267 let mut client = SyncClient::open_path(
1268 "frame-test".into(),
1269 &schema,
1270 Default::default(),
1271 path.to_str().unwrap(),
1272 )
1273 .unwrap();
1274 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1275 client
1276 .subscribe(
1277 "tasks".into(),
1278 "tasks".into(),
1279 vec![("project_id".into(), vec!["p1".into()])],
1280 None,
1281 )
1282 .unwrap();
1283 client.subs[0].cursor = 0;
1284 client.subs[0].synced_once = true;
1285 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1286 client.persist_sub(&client.subs[0]).unwrap();
1287 client.realtime_state = RealtimeState::Connected;
1288 client.realtime_reason_code = None;
1289 client.drain_change_batches();
1290 let mut transport = CountingRealtimeTransport::default();
1291 let (_, meta) = client.build_request(false).unwrap();
1292 let mut response = Message {
1293 wire_version: WIRE_VERSION,
1294 msg_kind: MsgKind::Response,
1295 frames: vec![
1296 Frame::RespHeader {
1297 required_schema_version: None,
1298 latest_schema_version: None,
1299 log_epoch: Some("epoch-1".into()),
1300 reset_required: Some(false),
1301 },
1302 Frame::SubStart {
1303 id: "tasks".into(),
1304 status: SubStatus::Active,
1305 reason_code: "".into(),
1306 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1307 bootstrap: false,
1308 },
1309 ],
1310 };
1311 for (index, id) in ["first", "second"].iter().enumerate() {
1312 let values = Map::from_iter([
1313 ("id".into(), Value::from(*id)),
1314 ("project_id".into(), Value::from("p1")),
1315 ]);
1316 response.frames.push(Frame::Commit {
1317 commit_seq: index as i64 + 1,
1318 actor_id: "writer".into(),
1319 created_at_ms: 1,
1320 tables: vec!["tasks".into()],
1321 changes: vec![ssp2::model::Change {
1322 table_index: 0,
1323 row_id: (*id).into(),
1324 op: Op::Upsert,
1325 row_version: Some(index as i64 + 1),
1326 scopes: vec![("project_id".into(), "p1".into())],
1327 row: Some(
1328 encode_row_json(
1329 client.schema.table("tasks").unwrap(),
1330 id,
1331 &values,
1332 &client.encryption,
1333 )
1334 .unwrap(),
1335 ),
1336 }],
1337 });
1338 }
1339 response.frames.push(Frame::SubEnd {
1340 next_cursor: 2,
1341 bootstrap_state: None,
1342 });
1343 let valid = response.clone();
1344 match fault {
1345 "row" => {
1346 let Frame::Commit {changes, ..} = &mut response.frames[3] else {panic!("second frame")};
1347 let mut malformed = changes[0].clone(); malformed.row_id = "malformed".into(); malformed.row = Some(vec![]); changes.push(malformed);
1348 },
1349 "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(),
1350 "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(),
1351 "cursor" => client.conn.execute_batch("CREATE TRIGGER fail_frame BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected cursor failure'); END").unwrap(),
1352 "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});},
1353 "missing-end" => {response.frames.truncate(3);},
1354 _ => {}
1355 }
1356 if realtime {
1357 client.on_realtime_binary(&mut transport, &encode_message(&response));
1358 } else {
1359 assert_eq!(
1360 matches!(
1361 client.process_response(&mut transport, response, &meta),
1362 SyncOutcome::Ok(_)
1363 ),
1364 fault == "none",
1365 "{fault}"
1366 );
1367 }
1368 assert!(
1369 client.conn.is_autocommit(),
1370 "no open transaction after {realtime}/{fault}"
1371 );
1372 let count = match fault {
1376 "none" | "cursor" => 2,
1377 "commit" => 0,
1378 _ => 1,
1379 };
1380 let expected_revision = count + u64::from(realtime && fault != "none");
1381 assert_eq!(
1382 client.local_revision(),
1383 expected_revision,
1384 "{realtime}/{fault}"
1385 );
1386 let batches = client.drain_change_batches();
1387 assert_eq!(
1388 batches.len(),
1389 expected_revision as usize,
1390 "{realtime}/{fault}"
1391 );
1392 let row_batches: Vec<_> = batches
1393 .iter()
1394 .filter(|batch| !batch.tables.is_empty())
1395 .collect();
1396 assert_eq!(row_batches.len(), count as usize);
1397 assert_eq!(
1398 row_batches
1399 .iter()
1400 .map(|b| b.revision.clone())
1401 .collect::<Vec<_>>(),
1402 (1..=count).map(|r| r.to_string()).collect::<Vec<_>>()
1403 );
1404 for table in ["tasks", "_syncular_base_tasks"] {
1405 assert_eq!(
1406 client
1407 .conn
1408 .query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |row| row
1409 .get::<_, u64>(0))
1410 .unwrap(),
1411 count,
1412 "{realtime}/{fault}/{table}"
1413 );
1414 }
1415 assert_eq!(client.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1416 assert_eq!(
1417 transport
1418 .messages
1419 .iter()
1420 .filter(|m| m.contains("\"ack\""))
1421 .count(),
1422 usize::from(fault == "none")
1423 );
1424 if matches!(fault, "revision" | "commit" | "cursor") {
1425 client
1426 .conn
1427 .execute_batch("DROP TRIGGER fail_frame")
1428 .unwrap();
1429 }
1430 drop(client);
1431 let mut reopened = SyncClient::open_path(
1432 "frame-test".into(),
1433 &schema,
1434 Default::default(),
1435 path.to_str().unwrap(),
1436 )
1437 .unwrap();
1438 assert_eq!(reopened.local_revision(), expected_revision);
1439 assert_eq!(
1440 reopened
1441 .query("SELECT id FROM tasks ORDER BY id", &[])
1442 .unwrap()
1443 .len(),
1444 count as usize
1445 );
1446 assert_eq!(reopened.subs[0].cursor, if fault == "none" { 2 } else { 0 });
1447 let (_, meta) = reopened.build_request(false).unwrap();
1448 assert!(matches!(
1449 reopened.process_response(&mut transport, valid, &meta),
1450 SyncOutcome::Ok(_)
1451 ));
1452 assert_eq!(
1453 reopened
1454 .query("SELECT id FROM tasks ORDER BY id", &[])
1455 .unwrap()
1456 .len(),
1457 2
1458 );
1459 assert_eq!(reopened.subs[0].cursor, 2);
1460 drop(reopened);
1461 std::fs::remove_file(path).unwrap();
1462 }
1463 }
1464 }
1465
1466 #[test]
1467 fn failed_subscription_control_persistence_rolls_back_rows_outbox_and_memory() {
1468 for status in [SubStatus::Reset, SubStatus::Revoked] {
1469 for fault in ["subscription", "revision"] {
1470 let mut client = client();
1471 client
1472 .subscribe(
1473 "tasks".into(),
1474 "tasks".into(),
1475 vec![("project_id".into(), vec!["p1".into()])],
1476 None,
1477 )
1478 .unwrap();
1479 client.subs[0].cursor = 41;
1480 client.subs[0].synced_once = true;
1481 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1482 client.persist_sub(&client.subs[0]).unwrap();
1483 client
1484 .mutate(vec![Mutation::Upsert {
1485 table: "tasks".into(),
1486 values: Map::from_iter([
1487 ("id".into(), Value::from("pending")),
1488 ("project_id".into(), Value::from("p1")),
1489 ]),
1490 base_version: None,
1491 }])
1492 .unwrap();
1493 let ids = client.pending_commit_ids();
1494 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
1495 let revision = client.local_revision();
1496 client.drain_change_batches();
1497 let (_, meta) = client.build_request(false).unwrap();
1498 client.conn.execute_batch(if fault == "subscription" {
1499 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_subscriptions BEGIN SELECT RAISE(FAIL, 'injected subscription failure'); END"
1500 } else {
1501 "CREATE TRIGGER fail_control BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END"
1502 }).unwrap();
1503 let mut transport = CountingRealtimeTransport::default();
1504 let mut report = SyncReport::default();
1505 if status == SubStatus::Reset {
1507 client.conn.execute("INSERT INTO _syncular_windows(base,unit,sub_id) VALUES ('base','p1','tasks')", []).unwrap();
1508 }
1509 let result = client.process_section(
1510 &mut transport,
1511 "tasks",
1512 status,
1513 "sync.scope_revoked",
1514 vec![],
1515 vec![],
1516 Some((42, None)),
1517 &meta,
1518 &mut report,
1519 &HashSet::new(),
1520 );
1521 assert!(result.is_err(), "{status:?}/{fault}");
1522 assert!(client.conn.is_autocommit());
1523 assert_eq!(client.subs[0].cursor, 41);
1524 assert_eq!(client.subs[0].state, SubState::Active);
1525 assert_eq!(client.pending_commit_ids(), ids);
1526 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
1527 assert!(client.rejections.is_empty());
1528 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
1529 assert_eq!(client.local_revision(), revision);
1530 assert!(client.drain_change_batches().is_empty());
1531 assert!(report.revoked.is_empty() && report.resets.is_empty());
1532 }
1533 }
1534 }
1535
1536 #[test]
1537 fn rows_segment_blocks_commit_independently_and_clear_with_the_first_block() {
1538 let mut client = client();
1539 client
1540 .subscribe(
1541 "tasks".into(),
1542 "tasks".into(),
1543 vec![("project_id".into(), vec!["p1".into()])],
1544 None,
1545 )
1546 .unwrap();
1547 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1548 client.conn.execute_batch("INSERT INTO _syncular_base_tasks VALUES ('stale','p1',1); INSERT INTO tasks VALUES ('stale','p1',1);
1549 CREATE TRIGGER fail_block BEFORE INSERT ON _syncular_base_tasks WHEN NEW.id = 'bad' BEGIN SELECT RAISE(FAIL,'injected later block failure'); END").unwrap();
1550 let row = |id: &str| ssp2::segment::SegmentRow {
1551 server_version: 1,
1552 values: vec![
1553 Some(ColumnValue::String(id.into())),
1554 Some(ColumnValue::String("p1".into())),
1555 ],
1556 };
1557 let segment = RowsSegment {
1558 table: "tasks".into(),
1559 schema_version: 1,
1560 columns: client.schema.table("tasks").unwrap().wire_columns.clone(),
1561 blocks: vec![vec![row("first")], vec![row("second"), row("bad")]],
1562 };
1563 assert!(client.apply_segment(0, &segment, true).is_err());
1564 assert!(client.conn.is_autocommit());
1565 assert_eq!(
1566 client
1567 .query("SELECT id FROM tasks ORDER BY id", &[])
1568 .unwrap(),
1569 vec![Map::from_iter([("id".into(), Value::from("first"))])]
1570 );
1571 assert_eq!(client.local_revision(), 1);
1572 assert_eq!(client.drain_change_batches().len(), 1);
1573 assert_eq!(client.subs[0].cursor, -1);
1574 client
1575 .conn
1576 .execute_batch("DROP TRIGGER fail_block")
1577 .unwrap();
1578 assert!(matches!(client.apply_segment(0, &segment, true), Ok(3)));
1579 assert_eq!(client.local_revision(), 3);
1580 assert_eq!(client.drain_change_batches().len(), 2);
1581 }
1582
1583 struct SegmentBatchTransport {
1587 response: Message,
1588 bodies: BTreeMap<String, Vec<u8>>,
1589 batches: Vec<Vec<String>>,
1590 single_fetches: usize,
1591 }
1592
1593 impl Transport for SegmentBatchTransport {
1594 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1595 Ok(encode_message(&self.response))
1596 }
1597
1598 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1599 self.sync(request)
1600 }
1601
1602 fn download_segment(
1603 &mut self,
1604 _request: &SegmentRequest,
1605 _on_progress: &mut dyn FnMut(u64),
1606 ) -> Result<Vec<u8>, TransportError> {
1607 self.single_fetches += 1;
1608 Err(TransportError::new(
1609 "sync.transport_failed",
1610 "per-descriptor fetch",
1611 ))
1612 }
1613
1614 fn download_segments(
1615 &mut self,
1616 fetches: &[crate::SegmentFetch],
1617 on_progress: &mut (dyn FnMut(usize, u64) + Send),
1618 ) -> Vec<Result<Vec<u8>, TransportError>> {
1619 self.batches.push(
1620 fetches
1621 .iter()
1622 .map(|fetch| match fetch {
1623 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
1624 crate::SegmentFetch::Url(url) => url.clone(),
1625 })
1626 .collect(),
1627 );
1628 fetches
1629 .iter()
1630 .enumerate()
1631 .map(|(index, fetch)| {
1632 let crate::SegmentFetch::Direct(request) = fetch else {
1633 panic!("the descriptors are direct-endpoint requests")
1634 };
1635 let bytes = self.bodies[&request.segment_id].clone();
1636 on_progress(index, bytes.len() as u64);
1637 Ok(bytes)
1638 })
1639 .collect()
1640 }
1641
1642 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1643 Ok(())
1644 }
1645
1646 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1647 Ok(())
1648 }
1649
1650 fn realtime_close(&mut self) -> Result<(), TransportError> {
1651 Ok(())
1652 }
1653 }
1654
1655 #[test]
1656 fn a_response_fetches_all_its_segment_pages_in_one_ordered_batch() {
1657 let mut client = client();
1658 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1659 client
1660 .subscribe(
1661 "tasks".into(),
1662 "tasks".into(),
1663 vec![("project_id".into(), vec!["p1".into()])],
1664 None,
1665 )
1666 .unwrap();
1667 client.subs[0].cursor = 0;
1668 client.subs[0].synced_once = true;
1669 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
1670 client.persist_sub(&client.subs[0]).unwrap();
1671 client.drain_change_batches();
1672 let columns = client.schema.table("tasks").unwrap().wire_columns.clone();
1673
1674 let pages = ["one", "two", "three", "four"].map(|id| {
1678 let bytes = ssp2::segment::encode_rows_segment(&RowsSegment {
1679 table: "tasks".into(),
1680 schema_version: 1,
1681 columns: columns.clone(),
1682 blocks: vec![vec![ssp2::segment::SegmentRow {
1683 server_version: 1,
1684 values: vec![
1685 Some(ColumnValue::String(id.into())),
1686 Some(ColumnValue::String("p1".into())),
1687 ],
1688 }]],
1689 });
1690 (
1691 format!("sha256:{}", bytes_to_hex(&Sha256::digest(&bytes))),
1692 bytes,
1693 )
1694 });
1695
1696 let mut frames = vec![
1697 Frame::RespHeader {
1698 required_schema_version: None,
1699 latest_schema_version: None,
1700 log_epoch: Some("epoch-1".into()),
1701 reset_required: Some(false),
1702 },
1703 Frame::SubStart {
1704 id: "tasks".into(),
1705 status: SubStatus::Active,
1706 reason_code: String::new(),
1707 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
1708 bootstrap: false,
1709 },
1710 ];
1711 for (segment_id, bytes) in &pages {
1712 frames.push(Frame::SegmentRef {
1713 segment_id: segment_id.clone(),
1714 media_type: MediaType::Rows,
1715 table: "tasks".into(),
1716 byte_length: bytes.len() as i64,
1717 row_count: 1,
1718 as_of_commit_seq: 1,
1719 scope_digest: "digest".into(),
1720 row_cursor: None,
1721 next_row_cursor: None,
1722 url: None,
1723 url_expires_at_ms: None,
1724 });
1725 }
1726 frames.push(Frame::SubEnd {
1727 next_cursor: 1,
1728 bootstrap_state: None,
1729 });
1730 let mut transport = SegmentBatchTransport {
1731 response: Message {
1732 wire_version: WIRE_VERSION,
1733 msg_kind: MsgKind::Response,
1734 frames,
1735 },
1736 bodies: pages
1737 .iter()
1738 .map(|(segment_id, bytes)| (segment_id.clone(), bytes.clone()))
1739 .collect(),
1740 batches: Vec::new(),
1741 single_fetches: 0,
1742 };
1743
1744 let outcome = client.sync(&mut transport);
1745 assert!(matches!(outcome, SyncOutcome::Ok(_)), "{outcome:?}");
1746 assert_eq!(
1747 transport.batches,
1748 vec![pages
1749 .iter()
1750 .map(|(segment_id, _)| segment_id.clone())
1751 .collect::<Vec<_>>()],
1752 "one batch per response, in wire order"
1753 );
1754 assert_eq!(
1755 transport.single_fetches, 0,
1756 "the batch seam replaces the per-descriptor calls"
1757 );
1758 assert_eq!(
1759 client
1760 .query("SELECT id FROM tasks ORDER BY id", &[])
1761 .unwrap(),
1762 ["four", "one", "three", "two"]
1763 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
1764 .to_vec()
1765 );
1766 assert_eq!(client.subs[0].cursor, 1);
1767 }
1768
1769 #[test]
1770 fn diagnostic_storage_reads_fresh_aggregates_after_writes_rollback_and_schema_changes() {
1771 assert_eq!(
1772 client().diagnostics_storage().blob_cache_bytes_approx,
1773 Some(0)
1774 );
1775 let client = SyncClient::new(
1776 "diagnostic-storage".to_owned(),
1777 &json!({
1778 "version": 1, "tables": [{"name": "attachments", "primaryKey": "id",
1779 "columns": [{"name": "id", "type": "string", "nullable": false},
1780 {"name": "body", "type": "blob_ref", "nullable": false}], "scopes": []}]
1781 }),
1782 ClientLimits {
1783 blob_cache_max_bytes: Some(5),
1784 ..Default::default()
1785 },
1786 )
1787 .unwrap();
1788 let empty = client.diagnostics_storage();
1789 assert_eq!(empty.status, "healthy");
1790 assert_eq!(empty.pending_outbox_bytes_approx, Some(0));
1791 assert_eq!(empty.retained_outcome_entries, Some(0));
1792 assert_eq!(empty.retained_outcome_bytes_approx, Some(0));
1793 assert_eq!(empty.blob_cache_bytes_approx, Some(0));
1794 client.conn.execute_batch("INSERT INTO _syncular_outbox(commit_id, ops_json) VALUES ('one', '[]');
1795 INSERT INTO _syncular_commit_outcomes(client_commit_id,status,recorded_at_ms,results_json,operations_json)
1796 VALUES ('one','applied',0,'[]',NULL),('two','rejected',0,'[]','[]');
1797 INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,media_type,created_at_ms)
1798 VALUES ('body',zeroblob(8),8,NULL,0);").unwrap();
1799 let written = client.diagnostics_storage();
1800 assert_eq!(written.status, "pressure");
1801 assert_eq!(written.pending_outbox_bytes_approx, Some(2));
1802 assert_eq!(written.retained_outcome_entries, Some(2));
1803 assert_eq!(written.retained_outcome_bytes_approx, Some(6));
1804 assert_eq!(written.blob_cache_bytes_approx, Some(8));
1805 assert_eq!(
1806 written.pressure_reason_code.as_deref(),
1807 Some("client.blob_cache_over_limit")
1808 );
1809 client
1810 .conn
1811 .execute_batch(
1812 "BEGIN; UPDATE _syncular_outbox SET ops_json = '[1,2]';
1813 DELETE FROM _syncular_commit_outcomes; DELETE FROM _syncular_blobs;",
1814 )
1815 .unwrap();
1816 let staged = client.diagnostics_storage();
1817 assert_eq!(staged.pending_outbox_bytes_approx, Some(5));
1818 assert_eq!(staged.retained_outcome_entries, Some(0));
1819 assert_eq!(staged.blob_cache_bytes_approx, Some(0));
1820 assert_eq!(staged.status, "healthy");
1821 client.conn.execute_batch("ROLLBACK").unwrap();
1822 assert_eq!(client.diagnostics_storage(), written);
1823 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN results_json TO hidden_results").unwrap();
1824 let unreadable = client.diagnostics_storage();
1825 assert_eq!(unreadable.status, "unreadable");
1826 assert!(unreadable.database_bytes_approx.is_none());
1827 assert!(unreadable.pending_outbox_bytes_approx.is_none());
1828 assert!(unreadable.retained_outcome_entries.is_none());
1829 assert!(unreadable.retained_outcome_bytes_approx.is_none());
1830 assert!(unreadable.blob_cache_bytes_approx.is_none());
1831 client.conn.execute_batch("ALTER TABLE _syncular_commit_outcomes RENAME COLUMN hidden_results TO results_json").unwrap();
1832 let restored = client.diagnostics_storage();
1833 assert_eq!(restored.status, written.status);
1834 assert_eq!(
1835 restored.retained_outcome_bytes_approx,
1836 written.retained_outcome_bytes_approx
1837 );
1838 client.conn.set_prepared_statement_cache_capacity(0);
1839 assert_eq!(client.diagnostics_storage(), restored);
1840 }
1841
1842 struct ServerErrorTransport {
1844 code: &'static str,
1845 retryable: bool,
1846 }
1847
1848 impl Transport for ServerErrorTransport {
1849 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1850 Ok(encode_message(&Message {
1851 wire_version: WIRE_VERSION,
1852 msg_kind: MsgKind::Response,
1853 frames: vec![
1854 Frame::RespHeader {
1855 required_schema_version: None,
1856 latest_schema_version: None,
1857 log_epoch: Some("epoch-1".to_owned()),
1858 reset_required: Some(false),
1859 },
1860 Frame::Error {
1861 code: self.code.to_owned(),
1862 message: "server error".to_owned(),
1863 category: "internal".to_owned(),
1864 retryable: self.retryable,
1865 recommended_action: "retryLater".to_owned(),
1866 details: None,
1867 },
1868 ],
1869 }))
1870 }
1871
1872 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
1873 self.sync(request)
1874 }
1875
1876 fn download_segment(
1877 &mut self,
1878 _request: &SegmentRequest,
1879 _on_progress: &mut dyn FnMut(u64),
1880 ) -> Result<Vec<u8>, TransportError> {
1881 Err(TransportError::new("sync.transport_failed", "offline"))
1882 }
1883
1884 fn realtime_connect(&mut self) -> Result<(), TransportError> {
1885 Ok(())
1886 }
1887
1888 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
1889 Ok(())
1890 }
1891
1892 fn realtime_close(&mut self) -> Result<(), TransportError> {
1893 Ok(())
1894 }
1895 }
1896
1897 #[test]
1898 fn a_retryable_in_band_failure_keeps_the_socket_connected_and_still_asks_for_a_retry() {
1899 let mut client = client();
1900 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1901 let mut transport = ServerErrorTransport {
1902 code: "sync.schema_not_ready",
1903 retryable: true,
1904 };
1905 client.connect_realtime(&mut transport).expect("connect");
1906 assert!(matches!(
1907 client.sync(&mut transport),
1908 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.schema_not_ready"
1909 ));
1910 assert_eq!(
1911 client.realtime_state(),
1912 RealtimeState::Connected,
1913 "the socket delivered the answer, so it is not lost"
1914 );
1915 assert!(
1916 matches!(
1917 client.drain_sync_intents().as_slice(),
1918 [SyncIntent::Background { delay_ms: 250 }]
1919 ),
1920 "the retry deadline is a sync round and no reconnect satisfies it"
1921 );
1922 assert_eq!(
1923 client.progress().snapshot().unwrap().retry_delay_ms,
1924 Some(250)
1925 );
1926 }
1927
1928 #[test]
1929 fn retryable_server_error_schedules_a_background_retry() {
1930 let mut client = client();
1931 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1932 let mut transport = ServerErrorTransport {
1933 code: "sync.internal_error",
1934 retryable: true,
1935 };
1936 assert!(matches!(
1937 client.sync(&mut transport),
1938 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
1939 ));
1940 assert!(matches!(
1941 client.drain_sync_intents().as_slice(),
1942 [SyncIntent::Background { delay_ms: 250 }]
1943 ));
1944 assert_eq!(
1945 client.progress().snapshot().unwrap().retry_delay_ms,
1946 Some(250)
1947 );
1948 let mut transport = ServerErrorTransport {
1949 code: "sync.invalid_request",
1950 retryable: false,
1951 };
1952 assert!(matches!(
1953 client.sync(&mut transport),
1954 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"
1955 ));
1956 assert!(client.drain_sync_intents().is_empty());
1957 let progress = client.progress().snapshot().unwrap();
1958 assert_eq!(progress.state, ProgressState::Failed);
1959 assert_eq!(progress.retry_delay_ms, None);
1960 }
1961
1962 #[test]
1963 fn sync_until_idle_reports_budget_exhaustion_or_failure() {
1964 let exhausted = SyncOutcome::BudgetExhausted(SyncReport::default()).to_json();
1967 assert_eq!(exhausted["ok"], json!(true));
1968 assert_eq!(exhausted["budgetExhausted"], json!(true));
1969 assert!(exhausted["report"].is_object());
1970
1971 let mut client = client();
1972 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
1973 let mut idle_transport = HostTransport::new_from_config(&json!({})).unwrap();
1975 let zero = client.sync_until_idle(&mut idle_transport, Some(0));
1976 assert!(
1977 matches!(zero, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
1978 "{zero:?}"
1979 );
1980 let mut erroring = ServerErrorTransport {
1982 code: "sync.internal_error",
1983 retryable: false,
1984 };
1985 let failed = client.sync_until_idle(&mut erroring, Some(3));
1986 assert!(
1987 matches!(failed, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"),
1988 "{failed:?}"
1989 );
1990 }
1991
1992 struct CauseTransport(&'static str);
1994
1995 impl Transport for CauseTransport {
1996 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
1997 let mut error = TransportError::new("transport.failed", "no route");
1998 error.details = Some(serde_json::json!({ "causeKind": self.0 }));
1999 Err(error)
2000 }
2001
2002 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2003 self.sync(request)
2004 }
2005
2006 fn download_segment(
2007 &mut self,
2008 _request: &SegmentRequest,
2009 _on_progress: &mut dyn FnMut(u64),
2010 ) -> Result<Vec<u8>, TransportError> {
2011 Err(TransportError::new("sync.transport_failed", "offline"))
2012 }
2013
2014 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2015 Ok(())
2016 }
2017
2018 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2019 Ok(())
2020 }
2021
2022 fn realtime_close(&mut self) -> Result<(), TransportError> {
2023 Ok(())
2024 }
2025 }
2026
2027 #[test]
2028 fn an_unreachable_transport_schedules_no_retry() {
2029 for cause in ["connect", "network"] {
2032 let mut client = client();
2033 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2034 let mut transport = CauseTransport(cause);
2035 assert!(matches!(
2036 client.sync(&mut transport),
2037 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2038 ));
2039 assert!(
2040 client.drain_sync_intents().is_empty(),
2041 "{cause} scheduled a retry"
2042 );
2043 }
2044
2045 let mut client = client();
2047 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2048 let mut transport = CauseTransport("timeout");
2049 assert!(matches!(
2050 client.sync(&mut transport),
2051 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.failed"
2052 ));
2053 assert!(matches!(
2054 client.drain_sync_intents().as_slice(),
2055 [SyncIntent::Background { delay_ms: 250 }]
2056 ));
2057 }
2058
2059 #[test]
2060 fn sync_until_idle_reports_its_total_deadline() {
2061 assert_eq!(SyncClient::SYNC_IDLE_TIMEOUT_MS, 60_000);
2062 let mut client = client();
2063 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2064 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
2065
2066 let invalid = client.sync_until_idle_with_deadline(&mut transport, None, Some(0));
2068 assert!(
2069 matches!(invalid, SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.invalid_request"),
2070 "{invalid:?}"
2071 );
2072
2073 let now = client.clock_now_ms();
2076 let outcome = client.sync_until_idle_bounded(&mut transport, None, now, Some(now));
2077 let SyncOutcome::Failed {
2078 error_code,
2079 details,
2080 recommended_action,
2081 ..
2082 } = outcome
2083 else {
2084 panic!("expected the idle deadline, got {outcome:?}");
2085 };
2086 assert_eq!(error_code, "sync.idle_timeout");
2087 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2088 let details = details.expect("deadline details");
2089 assert_eq!(details["timeoutMs"], json!(0));
2090 assert_eq!(details["rounds"], json!(0));
2091 }
2092
2093 struct ScriptedSyncTransport {
2096 responses: std::collections::VecDeque<Message>,
2097 calls: usize,
2098 }
2099
2100 impl Transport for ScriptedSyncTransport {
2101 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2102 self.calls += 1;
2103 let response = self.responses.pop_front().expect("scripted response");
2104 Ok(encode_message(&response))
2105 }
2106
2107 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2108 self.sync(request)
2109 }
2110
2111 fn download_segment(
2112 &mut self,
2113 _request: &SegmentRequest,
2114 _on_progress: &mut dyn FnMut(u64),
2115 ) -> Result<Vec<u8>, TransportError> {
2116 Err(TransportError::new("sync.transport_failed", "offline"))
2117 }
2118
2119 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2120 Ok(())
2121 }
2122
2123 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2124 Ok(())
2125 }
2126
2127 fn realtime_close(&mut self) -> Result<(), TransportError> {
2128 Ok(())
2129 }
2130 }
2131
2132 #[test]
2133 fn sync_until_idle_success_then_failure_keeps_earlier_work() {
2134 let path = std::env::temp_dir().join(format!(
2135 "syncular-budget-script-{}.sqlite",
2136 uuid::Uuid::new_v4()
2137 ));
2138 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
2139 {"name":"id","type":"string","nullable":false},
2140 {"name":"project_id","type":"string","nullable":false}],
2141 "scopes":[{"pattern":"project:{project_id}"}]}]});
2142 let mut client = SyncClient::open_path(
2143 "budget-script".into(),
2144 &schema,
2145 ClientLimits::default(),
2146 path.to_str().unwrap(),
2147 )
2148 .expect("open client");
2149 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2150 client
2151 .subscribe(
2152 "tasks".into(),
2153 "tasks".into(),
2154 vec![("project_id".into(), vec!["p1".into()])],
2155 None,
2156 )
2157 .unwrap();
2158 client.subs[0].cursor = 0;
2159 client.subs[0].synced_once = true;
2160 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2161 client.persist_sub(&client.subs[0]).unwrap();
2162 client.drain_change_batches();
2163
2164 let row = encode_row_json(
2165 client.schema.table("tasks").unwrap(),
2166 "kept",
2167 &Map::from_iter([
2168 ("id".into(), json!("kept")),
2169 ("project_id".into(), json!("p1")),
2170 ]),
2171 &client.encryption,
2172 )
2173 .unwrap();
2174 let success = Message {
2175 wire_version: WIRE_VERSION,
2176 msg_kind: MsgKind::Response,
2177 frames: vec![
2178 Frame::RespHeader {
2179 required_schema_version: None,
2180 latest_schema_version: None,
2181 log_epoch: Some("epoch-1".into()),
2182 reset_required: Some(false),
2183 },
2184 Frame::SubStart {
2185 id: "tasks".into(),
2186 status: SubStatus::Active,
2187 reason_code: String::new(),
2188 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2189 bootstrap: false,
2190 },
2191 Frame::Commit {
2192 commit_seq: 1,
2193 created_at_ms: 1,
2194 actor_id: "writer".into(),
2195 tables: vec!["tasks".into()],
2196 changes: vec![ssp2::model::Change {
2197 table_index: 0,
2198 row_id: "kept".into(),
2199 op: Op::Upsert,
2200 row_version: Some(1),
2201 scopes: vec![("project_id".into(), "p1".into())],
2202 row: Some(row),
2203 }],
2204 },
2205 Frame::SubEnd {
2206 next_cursor: 1,
2207 bootstrap_state: None,
2208 },
2209 ],
2210 };
2211 let failure = Message {
2212 wire_version: WIRE_VERSION,
2213 msg_kind: MsgKind::Response,
2214 frames: vec![
2215 Frame::RespHeader {
2216 required_schema_version: None,
2217 latest_schema_version: None,
2218 log_epoch: Some("epoch-1".into()),
2219 reset_required: Some(false),
2220 },
2221 Frame::Error {
2222 code: "sync.internal_error".into(),
2223 message: "second round failed".into(),
2224 category: "internal".into(),
2225 retryable: false,
2226 recommended_action: "retryLater".into(),
2227 details: None,
2228 },
2229 ],
2230 };
2231 let mut transport = ScriptedSyncTransport {
2232 responses: std::collections::VecDeque::from([success, failure]),
2233 calls: 0,
2234 };
2235 let outcome = client.sync_until_idle(&mut transport, Some(2));
2236 let SyncOutcome::Failed {
2237 error_code,
2238 recommended_action,
2239 ..
2240 } = outcome
2241 else {
2242 panic!("expected the in-band failure");
2243 };
2244 assert_eq!(error_code, "sync.internal_error");
2245 assert_eq!(recommended_action.as_deref(), Some("retryLater"));
2246 assert_eq!(
2247 transport.calls, 2,
2248 "one successful round then the failing round"
2249 );
2250 assert_eq!(
2251 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
2252 json!("kept"),
2253 "the earlier round's durable work survives the later failure"
2254 );
2255 drop(client);
2256 let _ = std::fs::remove_file(&path);
2257 }
2258
2259 #[test]
2260 fn sync_until_idle_stops_on_a_schema_floor() {
2261 let mut client = client();
2262 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2263 let floor = Message {
2264 wire_version: WIRE_VERSION,
2265 msg_kind: MsgKind::Response,
2266 frames: vec![Frame::RespHeader {
2267 required_schema_version: Some(2),
2268 latest_schema_version: Some(2),
2269 log_epoch: Some("epoch-1".into()),
2270 reset_required: Some(false),
2271 }],
2272 };
2273 let mut transport = ScriptedSyncTransport {
2274 responses: std::collections::VecDeque::from([floor.clone(), floor]),
2275 calls: 0,
2276 };
2277 let outcome = client.sync_until_idle(&mut transport, Some(3));
2278 match outcome {
2279 SyncOutcome::Ok(report) => assert_eq!(
2280 report
2281 .schema_floor
2282 .as_ref()
2283 .and_then(|floor| floor.required_schema_version),
2284 Some(2),
2285 "the stop retains the latest schema floor"
2286 ),
2287 other => panic!("expected a schema-floor stop, got {other:?}"),
2288 }
2289 assert_eq!(
2290 transport.calls, 1,
2291 "a schema floor stops the budget loop after one round"
2292 );
2293 }
2294
2295 struct FailureScriptTransport {
2298 rounds: std::collections::VecDeque<Result<Vec<u8>, TransportError>>,
2299 requests: Vec<Message>,
2300 }
2301
2302 impl Transport for FailureScriptTransport {
2303 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2304 self.requests
2305 .push(decode_message(request).expect("the client encodes a legal request"));
2306 self.rounds.pop_front().expect("scripted round")
2307 }
2308
2309 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
2310 self.sync(request)
2311 }
2312
2313 fn download_segment(
2314 &mut self,
2315 _request: &SegmentRequest,
2316 _on_progress: &mut dyn FnMut(u64),
2317 ) -> Result<Vec<u8>, TransportError> {
2318 Err(TransportError::new("transport.timeout", "segment stalled"))
2319 }
2320
2321 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2322 Ok(())
2323 }
2324
2325 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
2326 Ok(())
2327 }
2328
2329 fn realtime_close(&mut self) -> Result<(), TransportError> {
2330 Ok(())
2331 }
2332 }
2333
2334 fn completed_response(frames: Vec<Frame>) -> Vec<u8> {
2336 let mut all = vec![Frame::RespHeader {
2337 required_schema_version: None,
2338 latest_schema_version: None,
2339 log_epoch: Some("epoch-1".into()),
2340 reset_required: Some(false),
2341 }];
2342 all.extend(frames);
2343 encode_message(&Message {
2344 wire_version: WIRE_VERSION,
2345 msg_kind: MsgKind::Response,
2346 frames: all,
2347 })
2348 }
2349
2350 fn carried_round_failures(request: &Message) -> Vec<String> {
2351 request
2352 .frames
2353 .iter()
2354 .filter_map(|frame| match frame {
2355 Frame::RoundFailure { code } => Some(code.clone()),
2356 _ => None,
2357 })
2358 .collect()
2359 }
2360
2361 fn failure_transport(
2362 rounds: impl IntoIterator<Item = Result<Vec<u8>, TransportError>>,
2363 ) -> FailureScriptTransport {
2364 FailureScriptTransport {
2365 rounds: rounds.into_iter().collect(),
2366 requests: Vec::new(),
2367 }
2368 }
2369
2370 #[test]
2371 fn a_round_without_a_complete_response_reports_its_code_on_every_following_request() {
2372 let mut client = client();
2373 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2374 let mut transport = failure_transport([
2375 Err(TransportError::new("transport.timeout", "round timed out")),
2376 Ok(completed_response(Vec::new())),
2377 Ok(completed_response(Vec::new())),
2378 ]);
2379 for _ in 0..3 {
2380 client.sync(&mut transport);
2381 }
2382 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2383 assert_eq!(
2384 carried_round_failures(&transport.requests[1]),
2385 vec!["transport.timeout"]
2386 );
2387 assert!(
2388 matches!(transport.requests[1].frames[0], Frame::ReqHeader { .. })
2389 && matches!(transport.requests[1].frames[1], Frame::RoundFailure { .. }),
2390 "the report sits immediately after REQ_HEADER"
2391 );
2392 assert!(
2393 carried_round_failures(&transport.requests[2]).is_empty(),
2394 "a complete response ends the report"
2395 );
2396 }
2397
2398 #[test]
2399 fn a_newer_round_failure_replaces_the_kept_code() {
2400 let mut client = client();
2401 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2402 let mut transport = failure_transport([
2403 Err(TransportError::new("transport.timeout", "round timed out")),
2404 Err(TransportError::new("transport.failed", "socket closed")),
2405 Ok(completed_response(Vec::new())),
2406 Ok(completed_response(Vec::new())),
2407 ]);
2408 for _ in 0..4 {
2409 client.sync(&mut transport);
2410 }
2411 assert_eq!(
2412 carried_round_failures(&transport.requests[1]),
2413 vec!["transport.timeout"]
2414 );
2415 assert_eq!(
2416 carried_round_failures(&transport.requests[2]),
2417 vec!["transport.failed"]
2418 );
2419 assert!(carried_round_failures(&transport.requests[3]).is_empty());
2420 }
2421
2422 #[test]
2423 fn an_in_band_error_completes_the_round_and_is_never_reported() {
2424 let mut client = client();
2425 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2426 let mut transport = failure_transport([
2427 Err(TransportError::new("transport.timeout", "round timed out")),
2428 Ok(completed_response(vec![Frame::Error {
2429 code: "sync.internal_error".into(),
2430 message: "server error".into(),
2431 category: "internal".into(),
2432 retryable: false,
2433 recommended_action: "retryLater".into(),
2434 details: None,
2435 }])),
2436 Ok(completed_response(Vec::new())),
2437 ]);
2438 client.sync(&mut transport);
2439 assert!(matches!(
2440 client.sync(&mut transport),
2441 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.internal_error"
2442 ));
2443 client.sync(&mut transport);
2444 assert_eq!(
2445 carried_round_failures(&transport.requests[1]),
2446 vec!["transport.timeout"]
2447 );
2448 assert!(
2449 carried_round_failures(&transport.requests[2]).is_empty(),
2450 "the server already recorded an in-band ERROR"
2451 );
2452 }
2453
2454 #[test]
2455 fn a_silent_segment_fetch_is_retryable_and_reported_on_the_next_request() {
2456 let mut client = client();
2457 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2458 client
2459 .subscribe(
2460 "tasks".into(),
2461 "tasks".into(),
2462 vec![("project_id".into(), vec!["p1".into()])],
2463 None,
2464 )
2465 .unwrap();
2466 client.subs[0].cursor = 0;
2467 client.subs[0].synced_once = true;
2468 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
2469 client.persist_sub(&client.subs[0]).unwrap();
2470 client.drain_change_batches();
2471 client.drain_sync_intents();
2472 let mut transport = failure_transport([
2473 Ok(completed_response(vec![
2474 Frame::SubStart {
2475 id: "tasks".into(),
2476 status: SubStatus::Active,
2477 reason_code: String::new(),
2478 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
2479 bootstrap: false,
2480 },
2481 Frame::SegmentRef {
2482 segment_id: "sha256:stalled".into(),
2483 media_type: MediaType::Rows,
2484 table: "tasks".into(),
2485 byte_length: 1,
2486 row_count: 1,
2487 as_of_commit_seq: 1,
2488 scope_digest: "digest".into(),
2489 row_cursor: None,
2490 next_row_cursor: None,
2491 url: None,
2492 url_expires_at_ms: None,
2493 },
2494 Frame::SubEnd {
2495 next_cursor: 1,
2496 bootstrap_state: None,
2497 },
2498 ])),
2499 Ok(completed_response(Vec::new())),
2500 Ok(completed_response(Vec::new())),
2501 ]);
2502
2503 assert!(matches!(
2504 client.sync(&mut transport),
2505 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
2506 ));
2507 assert!(
2508 matches!(
2509 client.drain_sync_intents().as_slice(),
2510 [SyncIntent::Background { delay_ms: 250 }]
2511 ),
2512 "a silent transfer asks the host for a Background retry"
2513 );
2514 client.sync(&mut transport);
2515 client.sync(&mut transport);
2516 assert!(carried_round_failures(&transport.requests[0]).is_empty());
2517 assert_eq!(
2518 carried_round_failures(&transport.requests[1]),
2519 vec!["transport.timeout"],
2520 "the round's own response was complete, so the fetch failure rides the next request"
2521 );
2522 assert!(
2523 carried_round_failures(&transport.requests[2]).is_empty(),
2524 "a complete response ends the report"
2525 );
2526 }
2527
2528 #[test]
2529 fn a_code_the_frame_grammar_rejects_is_not_attached() {
2530 let mut client = client();
2531 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2532 let mut transport = failure_transport([
2533 Err(TransportError::new("offline", "no dotted code")),
2534 Ok(completed_response(Vec::new())),
2535 ]);
2536 client.sync(&mut transport);
2537 client.sync(&mut transport);
2538 assert!(carried_round_failures(&transport.requests[1]).is_empty());
2539 }
2540
2541 #[test]
2542 fn sync_report_merge_schema_floor_is_latest_state() {
2543 let mut aggregate = SyncReport {
2544 schema_floor: Some(SchemaFloor {
2545 required_schema_version: Some(2),
2546 latest_schema_version: Some(2),
2547 }),
2548 ..SyncReport::default()
2549 };
2550 aggregate.merge(&SyncReport::default());
2551 assert!(
2552 aggregate.schema_floor.is_none(),
2553 "a later round without a floor clears it"
2554 );
2555 }
2556
2557 #[test]
2558 fn push_capacity_limits_are_hard_and_keep_intent() {
2559 let mut c = client();
2561 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2562 c.limits.max_push_operations_per_request = Some(1);
2563 let id = c
2564 .mutate(vec![
2565 Mutation::Upsert {
2566 table: "tasks".into(),
2567 values: Map::from_iter([
2568 ("id".into(), json!("a")),
2569 ("project_id".into(), json!("p1")),
2570 ]),
2571 base_version: None,
2572 },
2573 Mutation::Upsert {
2574 table: "tasks".into(),
2575 values: Map::from_iter([
2576 ("id".into(), json!("b")),
2577 ("project_id".into(), json!("p1")),
2578 ]),
2579 base_version: None,
2580 },
2581 ])
2582 .unwrap();
2583 let error = c.build_request(false).unwrap_err();
2584 let SyncOutcome::Failed {
2585 error_code,
2586 details,
2587 ..
2588 } = *error
2589 else {
2590 panic!("expected a capacity failure");
2591 };
2592 assert_eq!(error_code, "client.push_request_too_large");
2593 let details = details.expect("capacity details");
2594 assert_eq!(details["kind"], json!("operations"));
2595 assert_eq!(details["limit"], json!(1));
2596 assert_eq!(details["size"], json!(2));
2597 assert_eq!(details["clientCommitId"], json!(id));
2598 assert_eq!(c.pending_commit_ids(), vec![id]);
2599
2600 let mut c = client();
2602 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2603 c.limits.max_push_commits_per_request = Some(1);
2604 let first = c
2605 .mutate(vec![Mutation::Upsert {
2606 table: "tasks".into(),
2607 values: Map::from_iter([
2608 ("id".into(), json!("one")),
2609 ("project_id".into(), json!("p1")),
2610 ]),
2611 base_version: None,
2612 }])
2613 .unwrap();
2614 let second = c
2615 .mutate(vec![Mutation::Upsert {
2616 table: "tasks".into(),
2617 values: Map::from_iter([
2618 ("id".into(), json!("two")),
2619 ("project_id".into(), json!("p1")),
2620 ]),
2621 base_version: None,
2622 }])
2623 .unwrap();
2624 let (_, meta) = c.build_request(false).unwrap();
2625 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2626 assert_eq!(meta.deferred_commits, 1);
2627 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2628 }
2629
2630 #[test]
2631 fn push_request_byte_cap_defers_the_prefix_boundary_and_errors_the_head() {
2632 let mut c = client();
2635 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2636 let first = c
2637 .mutate(vec![Mutation::Upsert {
2638 table: "tasks".into(),
2639 values: Map::from_iter([
2640 ("id".into(), json!("one")),
2641 ("project_id".into(), json!("p1")),
2642 ]),
2643 base_version: None,
2644 }])
2645 .unwrap();
2646 let second = c
2647 .mutate(vec![Mutation::Upsert {
2648 table: "tasks".into(),
2649 values: Map::from_iter([
2650 ("id".into(), json!("two")),
2651 ("project_id".into(), json!("p1")),
2652 ]),
2653 base_version: None,
2654 }])
2655 .unwrap();
2656 let (message, meta) = c.build_request(false).unwrap();
2657 assert_eq!(meta.pushed_ids.len(), 2);
2658 let full = encode_message(&message).len();
2659 c.limits.max_push_request_bytes = Some(full - 1);
2660 let (_, meta) = c.build_request(false).unwrap();
2661 assert_eq!(meta.pushed_ids, vec![first.clone()]);
2662 assert_eq!(meta.deferred_commits, 1);
2663 assert_eq!(c.pending_commit_ids(), vec![first, second]);
2664
2665 let mut c = client();
2668 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2669 let huge = c
2670 .mutate(vec![Mutation::Upsert {
2671 table: "tasks".into(),
2672 values: Map::from_iter([
2673 ("id".into(), json!("x".repeat(20_000))),
2674 ("project_id".into(), json!("p1")),
2675 ]),
2676 base_version: None,
2677 }])
2678 .unwrap();
2679 let (message, meta) = c.build_request(false).unwrap();
2680 assert_eq!(meta.pushed_ids, vec![huge.clone()]);
2681 let with_huge = encode_message(&message).len();
2682 c.limits.max_push_request_bytes = Some(with_huge - 1);
2683 let error = c.build_request(false).unwrap_err();
2684 let SyncOutcome::Failed {
2685 error_code,
2686 details,
2687 ..
2688 } = *error
2689 else {
2690 panic!("expected a capacity failure");
2691 };
2692 assert_eq!(error_code, "client.push_request_too_large");
2693 let details = details.expect("capacity details");
2694 assert_eq!(details["kind"], json!("bytes"));
2695 assert_eq!(details["limit"], json!((with_huge - 1) as u64));
2696 assert_eq!(details["size"], json!(with_huge as u64));
2697 assert_eq!(details["clientCommitId"], json!(huge));
2698 assert_eq!(c.pending_commit_ids(), vec![huge]);
2699
2700 let mut c = client();
2702 c.set_meta(LOG_EPOCH_KEY, "epoch-1");
2703 c.limits.max_push_request_bytes = Some(1);
2704 let error = c.build_request(false).unwrap_err();
2705 let SyncOutcome::Failed { details, .. } = *error else {
2706 panic!("expected a capacity failure");
2707 };
2708 let details = details.expect("capacity details");
2709 assert_eq!(details["kind"], json!("bytes"));
2710 assert!(details.get("clientCommitId").is_none(), "{details}");
2711 }
2712
2713 #[test]
2714 fn push_limits_validate_at_construction() {
2715 for limits in [
2716 ClientLimits {
2717 max_push_commits_per_request: Some(0),
2718 ..Default::default()
2719 },
2720 ClientLimits {
2721 max_push_operations_per_request: Some(0),
2722 ..Default::default()
2723 },
2724 ClientLimits {
2725 max_push_request_bytes: Some(0),
2726 ..Default::default()
2727 },
2728 ClientLimits {
2729 max_push_request_bytes: Some(u32::MAX as usize + 1),
2730 ..Default::default()
2731 },
2732 ] {
2733 let schema = json!({"version":1,"tables":[]});
2734 let error = SyncClient::with_connection(
2735 "bad-limits".into(),
2736 &schema,
2737 limits,
2738 Connection::open_in_memory().unwrap(),
2739 )
2740 .err();
2741 assert_eq!(
2742 error.as_deref(),
2743 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2744 );
2745 }
2746 }
2747
2748 #[test]
2749 fn open_path_rejects_invalid_push_limits_before_storage() {
2750 for identity in [false, true] {
2751 let path = std::env::temp_dir().join(format!(
2752 "syncular-bad-limits-{}.sqlite",
2753 uuid::Uuid::new_v4()
2754 ));
2755 let schema = json!({"version":1,"tables":[]});
2756 let limits = ClientLimits {
2757 max_push_request_bytes: Some(0),
2758 ..Default::default()
2759 };
2760 let error = if identity {
2761 SyncClient::open_path_with_identity(None, &schema, limits, path.to_str().unwrap())
2762 } else {
2763 SyncClient::open_path("bad-limits".into(), &schema, limits, path.to_str().unwrap())
2764 }
2765 .err();
2766 assert_eq!(
2767 error.as_deref(),
2768 Some("sync.invalid_request: push limit must be an integer in 1..=4294967295")
2769 );
2770 assert!(
2771 !path.exists(),
2772 "an invalid limit is rejected before the database file is created"
2773 );
2774 }
2775 }
2776
2777 #[test]
2778 fn failed_progress_retry_delay_doubles_to_the_cap() {
2779 let mut client = client();
2780 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2781 let mut transport = ServerErrorTransport {
2782 code: "sync.internal_error",
2783 retryable: true,
2784 };
2785 let delays: Vec<Option<u64>> = (0..9)
2786 .map(|_| {
2787 client.sync(&mut transport);
2788 client.progress().snapshot().unwrap().retry_delay_ms
2789 })
2790 .collect();
2791 assert_eq!(
2792 delays,
2793 [250, 500, 1_000, 2_000, 4_000, 8_000, 16_000, 30_000, 30_000].map(Some)
2794 );
2795 }
2796
2797 #[test]
2798 fn background_retry_deadlines_back_off_and_reset() {
2799 let mut client = client();
2800 client.schedule_background_retry();
2801 assert!(matches!(
2802 client.drain_sync_intents().as_slice(),
2803 [SyncIntent::Background { delay_ms: 250 }]
2804 ));
2805 client.schedule_background_retry();
2806 assert!(matches!(
2807 client.drain_sync_intents().as_slice(),
2808 [SyncIntent::Background { delay_ms: 500 }]
2809 ));
2810 client.reset_background_retry();
2811 client.schedule_background_retry();
2812 assert!(matches!(
2813 client.drain_sync_intents().as_slice(),
2814 [SyncIntent::Background { delay_ms: 250 }]
2815 ));
2816 }
2817
2818 #[test]
2819 fn log_epoch_reset_requests_an_immediate_follow_up_round() {
2820 let mut client = client();
2821 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
2822 client
2823 .subscribe(
2824 "epoch-tasks".to_owned(),
2825 "tasks".to_owned(),
2826 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
2827 None,
2828 )
2829 .expect("subscribe");
2830 client.drain_sync_intents();
2831
2832 client
2833 .run_log_epoch_reset("epoch-2")
2834 .expect("reset partition log epoch");
2835
2836 assert!(matches!(
2837 client.drain_sync_intents().as_slice(),
2838 [SyncIntent::Interactive]
2839 ));
2840 }
2841
2842 #[derive(Default)]
2843 struct CountingRealtimeTransport {
2844 connects: usize,
2845 closes: usize,
2846 messages: Vec<String>,
2847 blob_uploads: Vec<(String, Vec<u8>)>,
2848 blob_download: Option<Vec<u8>>,
2849 sync_calls: usize,
2850 realtime_sync_calls: usize,
2851 refuse_connect: bool,
2852 }
2853
2854 impl Transport for CountingRealtimeTransport {
2855 fn blob_upload(
2856 &mut self,
2857 blob_id: &str,
2858 bytes: &[u8],
2859 _media_type: Option<&str>,
2860 ) -> Result<(), TransportError> {
2861 self.blob_uploads.push((blob_id.to_owned(), bytes.to_vec()));
2862 Ok(())
2863 }
2864
2865 fn blob_download(&mut self, _blob_id: &str) -> Result<BlobDownload, TransportError> {
2866 self.blob_download
2867 .clone()
2868 .map(BlobDownload::Bytes)
2869 .ok_or_else(|| TransportError::new("blob.not_found", "missing blob"))
2870 }
2871
2872 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2873 self.sync_calls += 1;
2874 Err(TransportError::new("sync.transport_failed", "offline"))
2875 }
2876
2877 fn realtime_sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
2878 self.realtime_sync_calls += 1;
2879 Err(TransportError::new("sync.transport_failed", "offline"))
2880 }
2881
2882 fn download_segment(
2883 &mut self,
2884 _request: &SegmentRequest,
2885 _on_progress: &mut dyn FnMut(u64),
2886 ) -> Result<Vec<u8>, TransportError> {
2887 Err(TransportError::new("sync.transport_failed", "offline"))
2888 }
2889
2890 fn realtime_connect(&mut self) -> Result<(), TransportError> {
2891 self.connects += 1;
2892 if self.refuse_connect {
2893 return Err(TransportError::new("transport.failed", "refused"));
2894 }
2895 Ok(())
2896 }
2897
2898 fn realtime_send(&mut self, text: &str) -> Result<(), TransportError> {
2899 self.messages.push(text.to_owned());
2900 Ok(())
2901 }
2902
2903 fn realtime_close(&mut self) -> Result<(), TransportError> {
2904 self.closes += 1;
2905 Ok(())
2906 }
2907 }
2908
2909 #[test]
2910 fn blob_results_own_downloaded_bytes() {
2911 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
2912 {"name":"id","type":"string","nullable":false},
2913 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
2914 let bytes = b"owned typed result".to_vec();
2915 let blob_id = blob_id_for(&bytes);
2916 let mut transport = CountingRealtimeTransport {
2917 blob_download: Some(bytes.clone()),
2918 ..Default::default()
2919 };
2920 let result = {
2921 let mut client =
2922 SyncClient::new("owned-result".to_owned(), &schema, ClientLimits::default())
2923 .unwrap();
2924 let result = client.fetch_blob_bytes(&mut transport, &blob_id).unwrap();
2925 assert_eq!(result.blob_id, blob_id);
2926 assert_eq!(result.byte_length, bytes.len() as i64);
2927 assert_eq!(result.bytes, bytes);
2928 assert_eq!(result.media_type, None);
2929 assert_eq!(
2930 client
2931 .conn
2932 .query_row(
2933 "SELECT count(*) FROM _syncular_blob_uploads WHERE blob_id = ?",
2934 rusqlite::params![blob_id],
2935 |row| row.get::<_, i64>(0),
2936 )
2937 .unwrap(),
2938 0
2939 );
2940 result
2941 };
2942 assert_eq!(result.bytes, bytes);
2943
2944 let mut client =
2945 SyncClient::new("blob-errors".to_owned(), &schema, ClientLimits::default()).unwrap();
2946 assert_eq!(
2947 client
2948 .fetch_blob_bytes(&mut transport, "not json")
2949 .unwrap_err(),
2950 (
2951 "client.failed".to_owned(),
2952 "blob ref is not JSON".to_owned()
2953 )
2954 );
2955 }
2956
2957 #[test]
2958 fn visible_blob_references_survive_cache_trimming() {
2959 let schema = json!({"version":1,"tables":[
2960 {"name":"attachments","primaryKey":"id","columns":[
2961 {"name":"id","type":"string","nullable":false},
2962 {"name":"body","type":"blob_ref","nullable":true},
2963 {"name":"preview","type":"blob_ref","nullable":true}],"scopes":[]},
2964 {"name":"avatars","primaryKey":"id","columns":[
2965 {"name":"id","type":"string","nullable":false},
2966 {"name":"photo","type":"blob_ref","nullable":true}],"scopes":[]}
2967 ]});
2968 let bytes = b"referenced above cap".to_vec();
2969 let blob_id = blob_id_for(&bytes);
2970 let reference = json!({"blobId": blob_id, "byteLength": bytes.len()}).to_string();
2971 let mut client = SyncClient::new(
2972 "visible-blob-refs".to_owned(),
2973 &schema,
2974 ClientLimits {
2975 blob_cache_max_bytes: Some(1),
2976 ..Default::default()
2977 },
2978 )
2979 .unwrap();
2980 client.conn.execute(
2981 "INSERT INTO attachments VALUES ('one', ?, ?, 0), ('two', ?, 'malformed', 0), ('three', 12, ?, 0)",
2982 rusqlite::params![reference, reference, reference, reference],
2983 ).unwrap();
2984 client
2985 .conn
2986 .execute(
2987 "INSERT INTO avatars VALUES ('one', ?, 0), ('two', '{\"blobId\":12}', 0)",
2988 rusqlite::params![reference],
2989 )
2990 .unwrap();
2991 client.conn.execute(
2992 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES ('sha256:other', X'02', 1, NULL, 0)",
2993 [],
2994 ).unwrap();
2995 let mut transport = CountingRealtimeTransport {
2996 blob_download: Some(bytes.clone()),
2997 ..Default::default()
2998 };
2999
3000 assert_eq!(
3001 client
3002 .fetch_blob_bytes(&mut transport, &blob_id)
3003 .unwrap()
3004 .bytes,
3005 bytes
3006 );
3007 let rows = client
3008 .conn
3009 .prepare("SELECT blob_id FROM _syncular_blobs ORDER BY blob_id")
3010 .unwrap()
3011 .query_map([], |row| row.get::<_, String>(0))
3012 .unwrap()
3013 .collect::<Result<Vec<_>, _>>()
3014 .unwrap();
3015 assert_eq!(rows, vec![blob_id]);
3016 }
3017
3018 #[test]
3019 fn closed_transport_never_invokes_network_and_reopen_is_host_owned() {
3020 let mut transport = CountingRealtimeTransport::default();
3021 let path = std::env::temp_dir().join(format!(
3022 "syncular-transport-gate-{}.db",
3023 uuid::Uuid::new_v4()
3024 ));
3025 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
3026 {"name":"id","type":"string","nullable":false},
3027 {"name":"project_id","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
3028 let mut client = SyncClient::open_path(
3029 "gate".into(),
3030 &schema,
3031 ClientLimits::default(),
3032 path.to_str().unwrap(),
3033 )
3034 .unwrap();
3035 client.connect_realtime(&mut transport).unwrap();
3036 client.set_transport_enabled(&mut transport, false);
3037 assert_eq!(transport.closes, 1);
3038 client.begin_security_preflight();
3039 client.activate_security(Default::default()).unwrap();
3040 client
3041 .subscribe(
3042 "tasks".into(),
3043 "tasks".into(),
3044 vec![("project_id".into(), vec!["p1".into()])],
3045 None,
3046 )
3047 .unwrap();
3048 for id in ["one", "two"] {
3049 client
3050 .mutate(vec![Mutation::Upsert {
3051 table: "tasks".into(),
3052 values: Map::from_iter([
3053 ("id".into(), json!(id)),
3054 ("project_id".into(), json!("p1")),
3055 ]),
3056 base_version: None,
3057 }])
3058 .unwrap();
3059 }
3060 assert_eq!(
3061 client
3062 .query("SELECT id FROM tasks ORDER BY id", &[])
3063 .unwrap()
3064 .len(),
3065 2
3066 );
3067 assert!(
3068 matches!(client.sync(&mut transport), SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.offline")
3069 );
3070 assert!(client
3071 .connect_realtime(&mut transport)
3072 .unwrap_err()
3073 .starts_with("sync.offline:"));
3074 assert!(client
3075 .set_presence(&mut transport, "project:p1", None)
3076 .unwrap_err()
3077 .starts_with("sync.offline:"));
3078 assert_eq!(transport.sync_calls, 0);
3079 assert_eq!(transport.realtime_sync_calls, 0);
3080 assert_eq!(transport.connects, 1);
3081 assert!(transport.messages.is_empty());
3082 assert!(matches!(
3083 client.drain_sync_intents().as_slice(),
3084 [SyncIntent::None]
3085 ));
3086 let pending = client.pending_commit_ids();
3087 drop(client);
3088 let mut reopened = SyncClient::open_path(
3089 "gate".into(),
3090 &schema,
3091 ClientLimits::default(),
3092 path.to_str().unwrap(),
3093 )
3094 .unwrap();
3095 assert!(reopened.transport_enabled());
3096 reopened.set_transport_enabled(&mut transport, false);
3097 assert_eq!(reopened.pending_commit_ids(), pending);
3098 assert_eq!(
3099 reopened.query("SELECT id FROM tasks", &[]).unwrap().len(),
3100 2
3101 );
3102 reopened.set_transport_enabled(&mut transport, true);
3103 assert!(matches!(
3104 reopened.drain_sync_intents().as_slice(),
3105 [SyncIntent::Interactive]
3106 ));
3107 reopened.set_transport_enabled(&mut transport, true);
3108 assert!(reopened.drain_sync_intents().is_empty());
3109 drop(reopened);
3110 std::fs::remove_file(path).unwrap();
3111 }
3112
3113 #[test]
3114 fn realtime_connection_ownership_is_idempotent() {
3115 let mut client = client();
3116 let mut transport = CountingRealtimeTransport {
3117 connects: 0,
3118 closes: 0,
3119 ..Default::default()
3120 };
3121 client
3122 .connect_realtime(&mut transport)
3123 .expect("first connect");
3124 client
3125 .connect_realtime(&mut transport)
3126 .expect("idempotent connect");
3127 assert_eq!(transport.connects, 1);
3128 client.disconnect_realtime(&mut transport);
3129 client.disconnect_realtime(&mut transport);
3130 assert_eq!(transport.closes, 1);
3131 client
3132 .connect_realtime(&mut transport)
3133 .expect("deliberate reconnect");
3134 assert_eq!(transport.connects, 2);
3135 }
3136
3137 #[test]
3138 fn required_policy_refuses_a_round_without_a_socket_and_never_uses_http() {
3139 let mut client = client();
3140 client
3141 .set_realtime_policy(RealtimePolicy::Required)
3142 .expect("set policy");
3143 let mut transport = CountingRealtimeTransport::default();
3144 match client.sync(&mut transport) {
3145 SyncOutcome::RealtimeUnavailable {
3146 state,
3147 reason_code,
3148 retry_delay_ms,
3149 } => {
3150 assert_eq!(state, RealtimeState::Disconnected);
3151 assert_eq!(reason_code, None);
3152 assert_eq!(retry_delay_ms, 250);
3153 }
3154 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3155 }
3156 assert_eq!(transport.sync_calls, 0);
3157 assert!(matches!(
3158 client.drain_sync_intents().as_slice(),
3159 [SyncIntent::Background { delay_ms: 250 }]
3160 ));
3161 let snapshot = client
3162 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3163 .expect("diagnostics");
3164 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3165 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Required);
3166 assert_eq!(snapshot.host.realtime_reason_code, None);
3167 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3168 assert_eq!(
3169 snapshot
3170 .last_round
3171 .as_ref()
3172 .and_then(|round| round.error_code.clone()),
3173 Some(REALTIME_UNAVAILABLE_CODE.to_owned())
3174 );
3175 }
3176
3177 #[test]
3178 fn refused_handshake_is_a_visible_state_and_a_required_round_does_not_fall_back() {
3179 let mut client = client();
3180 client
3181 .set_realtime_policy(RealtimePolicy::Required)
3182 .expect("set policy");
3183 let mut transport = CountingRealtimeTransport {
3184 refuse_connect: true,
3185 ..Default::default()
3186 };
3187 assert!(client.connect_realtime(&mut transport).is_err());
3188 assert_eq!(client.realtime_state(), RealtimeState::Refused);
3189 let snapshot = client
3190 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3191 .expect("diagnostics");
3192 assert_eq!(
3193 snapshot.host.realtime_reason_code.as_deref(),
3194 Some("transport.failed")
3195 );
3196 match client.sync(&mut transport) {
3197 SyncOutcome::RealtimeUnavailable {
3198 state, reason_code, ..
3199 } => {
3200 assert_eq!(state, RealtimeState::Refused);
3201 assert_eq!(reason_code.as_deref(), Some("transport.failed"));
3202 }
3203 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3204 }
3205 assert_eq!(transport.sync_calls, 0);
3206 assert_eq!(transport.realtime_sync_calls, 0);
3207 }
3208
3209 #[test]
3210 fn an_unsolicited_socket_end_is_lost_with_the_transport_code_and_a_retry_intent() {
3211 let mut client = client();
3212 let mut transport = CountingRealtimeTransport::default();
3213 let ended = TransportError::new("transport.failed", "realtime connection closed");
3214 client.realtime_lost(&mut transport, &ended);
3215 assert_eq!(
3216 client.realtime_state(),
3217 RealtimeState::Disconnected,
3218 "a socket that was never connected has nothing to lose"
3219 );
3220 assert!(client.drain_sync_intents().is_empty());
3221
3222 client.connect_realtime(&mut transport).expect("connect");
3223 client.realtime_lost(&mut transport, &ended);
3224 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3225 assert_eq!(transport.closes, 1);
3226 assert!(matches!(
3227 client.drain_sync_intents().as_slice(),
3228 [SyncIntent::Background { delay_ms: 250 }]
3229 ));
3230 let snapshot = client
3231 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3232 .expect("diagnostics");
3233 assert_eq!(snapshot.host.realtime, RealtimeState::Lost);
3234 assert_eq!(
3235 snapshot.host.realtime_reason_code.as_deref(),
3236 Some("transport.failed")
3237 );
3238 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3239
3240 client.realtime_lost(&mut transport, &ended);
3241 assert!(
3242 client.drain_sync_intents().is_empty(),
3243 "a second report for the same loss schedules nothing"
3244 );
3245 client.connect_realtime(&mut transport).expect("reconnect");
3246 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3247 }
3248
3249 #[test]
3250 fn a_failed_socket_round_is_lost_and_the_next_required_round_refuses_http() {
3251 let mut client = client();
3252 client
3253 .set_realtime_policy(RealtimePolicy::Required)
3254 .expect("set policy");
3255 let mut transport = CountingRealtimeTransport::default();
3256 client.connect_realtime(&mut transport).expect("connect");
3257 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3258 assert!(
3259 client.set_realtime_policy(RealtimePolicy::Off).is_err(),
3260 "a policy change cannot silently re-route a connected socket"
3261 );
3262 assert!(matches!(
3263 client.sync(&mut transport),
3264 SyncOutcome::Failed { ref error_code, .. } if error_code == "sync.transport_failed"
3265 ));
3266 assert_eq!(client.realtime_state(), RealtimeState::Lost);
3267 assert_eq!(transport.realtime_sync_calls, 1);
3268 assert_eq!(transport.sync_calls, 0);
3269 let snapshot = client
3270 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3271 .expect("diagnostics");
3272 assert_eq!(
3273 snapshot.host.realtime_reason_code.as_deref(),
3274 Some("sync.transport_failed")
3275 );
3276 assert_eq!(snapshot.host.realtime_retry_delay_ms, Some(250));
3277 match client.sync(&mut transport) {
3278 SyncOutcome::RealtimeUnavailable {
3279 state, reason_code, ..
3280 } => {
3281 assert_eq!(state, RealtimeState::Lost);
3282 assert_eq!(reason_code.as_deref(), Some("sync.transport_failed"));
3283 }
3284 other => panic!("expected RealtimeUnavailable, got {other:?}"),
3285 }
3286 assert_eq!(transport.sync_calls, 0);
3287 client.disconnect_realtime(&mut transport);
3288 assert_eq!(client.realtime_state(), RealtimeState::Disconnected);
3289 assert_eq!(transport.closes, 1);
3290 client.connect_realtime(&mut transport).expect("reconnect");
3291 assert_eq!(client.realtime_state(), RealtimeState::Connected);
3292 assert_eq!(transport.connects, 2);
3293 }
3294
3295 #[test]
3296 fn off_policy_uses_http_and_refuses_an_explicit_connect() {
3297 let mut client = client();
3298 client
3299 .set_realtime_policy(RealtimePolicy::Off)
3300 .expect("set policy");
3301 let mut transport = CountingRealtimeTransport::default();
3302 assert!(client.connect_realtime(&mut transport).is_err());
3303 assert_eq!(client.realtime_state(), RealtimeState::Disabled);
3304 assert!(matches!(
3305 client.sync(&mut transport),
3306 SyncOutcome::Failed { .. }
3307 ));
3308 assert_eq!(transport.sync_calls, 1);
3309 assert_eq!(transport.realtime_sync_calls, 0);
3310 let snapshot = client
3311 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3312 .expect("diagnostics");
3313 assert_eq!(snapshot.host.realtime, RealtimeState::Disabled);
3314 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Off);
3315 }
3316
3317 #[test]
3318 fn optional_policy_keeps_the_http_round() {
3319 let mut client = client();
3320 let mut transport = CountingRealtimeTransport::default();
3321 assert!(matches!(
3322 client.sync(&mut transport),
3323 SyncOutcome::Failed { .. }
3324 ));
3325 assert_eq!(transport.sync_calls, 1);
3326 assert_eq!(transport.realtime_sync_calls, 0);
3327 let snapshot = client
3328 .diagnostics_snapshot(&ClientDiagnosticsRequest::default())
3329 .expect("diagnostics");
3330 assert_eq!(snapshot.host.realtime, RealtimeState::Disconnected);
3331 assert_eq!(snapshot.host.realtime_policy, RealtimePolicy::Optional);
3332 }
3333
3334 #[test]
3335 fn local_rebootstrap_is_atomic_idempotent_and_preserves_offline_work() {
3336 let mut client = client();
3337 client
3338 .subscribe(
3339 "repair-tasks".to_owned(),
3340 "tasks".to_owned(),
3341 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3342 None,
3343 )
3344 .expect("subscribe");
3345 {
3346 let sub = client
3347 .subs
3348 .iter_mut()
3349 .find(|sub| sub.id == "repair-tasks")
3350 .expect("subscription");
3351 sub.cursor = 42;
3352 sub.synced_once = true;
3353 let persisted = sub.clone();
3354 client.persist_sub(&persisted).unwrap();
3355 }
3356 for table in ["_syncular_base_tasks", "tasks"] {
3357 client
3358 .conn
3359 .execute(
3360 &format!(
3361 "INSERT INTO {table}(id, project_id, _syncular_version) VALUES (?1, ?2, 1)"
3362 ),
3363 rusqlite::params!["server-row", "p1"],
3364 )
3365 .expect("seed server row");
3366 }
3367 let pending = client
3368 .mutate(vec![Mutation::Upsert {
3369 table: "tasks".to_owned(),
3370 values: Map::from_iter([
3371 ("id".to_owned(), Value::from("offline-row")),
3372 ("project_id".to_owned(), Value::from("p1")),
3373 ]),
3374 base_version: None,
3375 }])
3376 .expect("queue offline work");
3377 client.drain_change_batches();
3378 client.drain_sync_intents();
3379
3380 assert_eq!(
3381 client
3382 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3383 rebootstrap_id: "support-case-001".to_owned(),
3384 })
3385 .expect("rebootstrap"),
3386 LocalDataRebootstrapResult {
3387 already_applied: false,
3388 retained_commits: 1,
3389 reset_subscriptions: 1,
3390 }
3391 );
3392 let visible_ids = client
3393 .conn
3394 .prepare("SELECT id FROM tasks ORDER BY id")
3395 .expect("prepare visible ids")
3396 .query_map([], |row| row.get::<_, String>(0))
3397 .expect("query visible ids")
3398 .collect::<Result<Vec<_>, _>>()
3399 .expect("collect visible ids");
3400 assert_eq!(visible_ids, vec!["offline-row"]);
3401 assert_eq!(client.pending_commit_ids(), vec![pending]);
3402 assert_eq!(
3403 client
3404 .subscription_state("repair-tasks")
3405 .expect("subscription")
3406 .cursor,
3407 -1
3408 );
3409 assert!(client.upgrading());
3410 assert!(client.sync_needed());
3411 assert!(matches!(
3412 client.drain_sync_intents().as_slice(),
3413 [SyncIntent::Interactive]
3414 ));
3415 assert_eq!(client.drain_change_batches().len(), 1);
3416
3417 assert_eq!(
3418 client
3419 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3420 rebootstrap_id: "support-case-001".to_owned(),
3421 })
3422 .expect("idempotent retry"),
3423 LocalDataRebootstrapResult {
3424 already_applied: true,
3425 retained_commits: 1,
3426 reset_subscriptions: 1,
3427 }
3428 );
3429 assert!(client.drain_change_batches().is_empty());
3430 }
3431
3432 #[test]
3433 fn local_rebootstrap_receipt_codec_is_bounded_and_legacy_compatible() {
3434 let encoded = encode_local_rebootstrap_receipt(3, 4).expect("encode receipt");
3435 assert_eq!(
3436 decode_local_rebootstrap_receipt(&encoded).expect("decode receipt"),
3437 (3, 4)
3438 );
3439 assert_eq!(
3440 decode_local_rebootstrap_receipt("v1").expect("legacy marker"),
3441 (0, 0)
3442 );
3443 for malformed in [
3444 "",
3445 "{}",
3446 r#"{"version":3,"retainedCommits":1,"resetSubscriptions":1}"#,
3447 r#"{"version":2,"retainedCommits":1,"resetSubscriptions":1,"extra":true}"#,
3448 r#"{"version":2,"retainedCommits":9007199254740992,"resetSubscriptions":1}"#,
3449 ] {
3450 assert_eq!(
3451 decode_local_rebootstrap_receipt(malformed)
3452 .expect_err("malformed receipt must fail"),
3453 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3454 );
3455 }
3456 }
3457
3458 #[test]
3459 fn local_rebootstrap_replays_the_original_receipt_after_reopen() {
3460 let path = std::env::temp_dir().join(format!(
3461 "syncular-rebootstrap-receipt-{}.db",
3462 uuid::Uuid::new_v4()
3463 ));
3464 let schema = json!({
3465 "version": 1,
3466 "tables": [{
3467 "name": "tasks",
3468 "primaryKey": "id",
3469 "columns": [
3470 { "name": "id", "type": "string", "nullable": false },
3471 { "name": "project_id", "type": "string", "nullable": false }
3472 ],
3473 "scopes": [{ "pattern": "project:{project_id}" }]
3474 }]
3475 });
3476 let path_string = path.to_str().expect("UTF-8 temp path");
3477
3478 {
3479 let mut first = SyncClient::open_path(
3480 "repair-restart-client".to_owned(),
3481 &schema,
3482 ClientLimits::default(),
3483 path_string,
3484 )
3485 .expect("first open");
3486 first
3487 .subscribe(
3488 "repair-tasks".to_owned(),
3489 "tasks".to_owned(),
3490 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3491 None,
3492 )
3493 .expect("subscribe");
3494 first
3495 .mutate(vec![Mutation::Upsert {
3496 table: "tasks".to_owned(),
3497 values: Map::from_iter([
3498 ("id".to_owned(), Value::from("offline-row")),
3499 ("project_id".to_owned(), Value::from("p1")),
3500 ]),
3501 base_version: None,
3502 }])
3503 .expect("queue offline work");
3504 assert_eq!(
3505 first
3506 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3507 rebootstrap_id: "restart-receipt".to_owned(),
3508 })
3509 .expect("first rebootstrap"),
3510 LocalDataRebootstrapResult {
3511 already_applied: false,
3512 retained_commits: 1,
3513 reset_subscriptions: 1,
3514 }
3515 );
3516 }
3517
3518 let mut reopened = SyncClient::open_path(
3519 "repair-restart-client".to_owned(),
3520 &schema,
3521 ClientLimits::default(),
3522 path_string,
3523 )
3524 .expect("reopen");
3525 reopened.drain_change_batches();
3526 reopened.drain_sync_intents();
3527 assert_eq!(
3528 reopened
3529 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3530 rebootstrap_id: "restart-receipt".to_owned(),
3531 })
3532 .expect("receipt replay"),
3533 LocalDataRebootstrapResult {
3534 already_applied: true,
3535 retained_commits: 1,
3536 reset_subscriptions: 1,
3537 }
3538 );
3539 assert!(reopened.drain_change_batches().is_empty());
3540 assert!(reopened.drain_sync_intents().is_empty());
3541 drop(reopened);
3542 std::fs::remove_file(path).expect("remove temp database");
3543 }
3544
3545 #[test]
3546 fn local_rebootstrap_fails_closed_on_malformed_or_unreadable_receipts() {
3547 let mut malformed = client();
3548 malformed
3549 .subscribe(
3550 "repair-tasks".to_owned(),
3551 "tasks".to_owned(),
3552 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
3553 None,
3554 )
3555 .expect("subscribe");
3556 malformed.set_meta("localRebootstrap:malformed", "{\"version\":2}");
3557 malformed.drain_change_batches();
3558 malformed.drain_sync_intents();
3559 let malformed_error = malformed
3560 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3561 rebootstrap_id: "malformed".to_owned(),
3562 })
3563 .expect_err("malformed receipt must fail closed");
3564 assert_eq!(
3565 malformed_error,
3566 "sync.local_corrupt: persisted local rebootstrap receipt is invalid"
3567 );
3568 assert!(!malformed.upgrading());
3569 assert_eq!(
3570 malformed
3571 .subscription_state("repair-tasks")
3572 .expect("unchanged subscription")
3573 .cursor,
3574 -1
3575 );
3576 assert!(malformed.drain_change_batches().is_empty());
3577 assert!(malformed.drain_sync_intents().is_empty());
3578
3579 let mut unreadable = client();
3580 unreadable
3581 .conn
3582 .execute(
3583 "INSERT INTO tasks(id, project_id, _syncular_version) VALUES (?1, ?2, 1)",
3584 rusqlite::params!["server-row", "p1"],
3585 )
3586 .expect("seed visible row");
3587 unreadable
3588 .conn
3589 .execute("DROP TABLE _syncular_meta", [])
3590 .expect("break marker storage");
3591 let unreadable_error = unreadable
3592 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3593 rebootstrap_id: "unreadable".to_owned(),
3594 })
3595 .expect_err("unreadable marker storage must fail closed");
3596 assert_eq!(
3597 unreadable_error,
3598 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable"
3599 );
3600 let visible_rows = unreadable
3601 .conn
3602 .query_row("SELECT COUNT(*) FROM tasks", [], |row| row.get::<_, i64>(0))
3603 .expect("visible projection remains");
3604 assert_eq!(visible_rows, 1);
3605 assert!(!unreadable.upgrading());
3606 assert!(unreadable.drain_change_batches().is_empty());
3607 assert!(unreadable.drain_sync_intents().is_empty());
3608 }
3609
3610 #[test]
3611 fn local_rebootstrap_cannot_bypass_schema_floor() {
3612 let mut client = client();
3613 client.set_schema_floor(Some(SchemaFloor {
3614 required_schema_version: Some(2),
3615 latest_schema_version: Some(2),
3616 }));
3617 let error = client
3618 .rebootstrap_local_data(&LocalDataRebootstrapInput {
3619 rebootstrap_id: "blocked-floor".to_owned(),
3620 })
3621 .expect_err("schema floor must block repair");
3622 assert!(error.contains("cannot bypass an active schema-floor stop"));
3623 }
3624
3625 #[test]
3626 fn diego_applied_sparse_moves_stay_visible_until_pull_image_arrives() {
3627 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
3628 "columns":[{"name":"id","type":"string","nullable":false},
3629 {"name":"project_id","type":"string","nullable":false},
3630 {"name":"starts_at_ms","type":"integer","nullable":false},
3631 {"name":"version","type":"integer","nullable":false}],
3632 "scopes":[{"pattern":"project:{project_id}"}]}]});
3633 let mut client =
3634 SyncClient::new("diego-ack-gap".into(), &schema, ClientLimits::default()).unwrap();
3635 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3636 client
3637 .write_base_row(
3638 "tasks",
3639 &vec![
3640 Some(ColumnValue::String("t1".into())),
3641 Some(ColumnValue::String("p1".into())),
3642 Some(ColumnValue::Integer(100)),
3643 Some(ColumnValue::Integer(1)),
3644 ],
3645 1,
3646 )
3647 .unwrap();
3648 client.rebuild_overlay().expect("rebuild overlay");
3649 let first = client
3650 .patch(
3651 "tasks",
3652 "t1",
3653 Map::from_iter([
3654 ("starts_at_ms".into(), json!(200)),
3655 ("version".into(), json!(2)),
3656 ]),
3657 None,
3658 )
3659 .unwrap();
3660 let (_, first_request) = client.build_request(false).unwrap();
3661 let second = client
3663 .patch(
3664 "tasks",
3665 "t1",
3666 Map::from_iter([
3667 ("starts_at_ms".into(), json!(300)),
3668 ("version".into(), json!(3)),
3669 ]),
3670 None,
3671 )
3672 .unwrap();
3673 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3674 for (id, seq, meta) in [
3675 (first, 2, first_request),
3676 (second, 3, client.build_request(false).unwrap().1),
3677 ] {
3678 let response = Message {
3679 wire_version: WIRE_VERSION,
3680 msg_kind: MsgKind::Response,
3681 frames: vec![
3682 Frame::RespHeader {
3683 required_schema_version: None,
3684 latest_schema_version: None,
3685 log_epoch: Some("epoch-1".into()),
3686 reset_required: Some(false),
3687 },
3688 Frame::PushResult {
3689 client_commit_id: id,
3690 status: PushStatus::Applied,
3691 commit_seq: Some(seq),
3692 results: vec![OpResult::Applied { op_index: 0 }],
3693 },
3694 ],
3695 };
3696 assert!(matches!(
3697 client.process_response(&mut transport, response, &meta),
3698 SyncOutcome::Ok(_)
3699 ));
3700 let row = &client
3701 .query("SELECT starts_at_ms, version FROM tasks WHERE id='t1'", &[])
3702 .unwrap()[0];
3703 assert_eq!(
3704 row["starts_at_ms"],
3705 json!(300),
3706 "an applied ACK without a pull image must not expose the old base"
3707 );
3708 assert_eq!(
3709 row["version"],
3710 json!(3),
3711 "the queued domain version must remain monotonic"
3712 );
3713 }
3714 }
3715
3716 #[test]
3717 fn first_epoch_acquisition_preserves_tables_subscriptions_and_local_intent() {
3718 for stored in [None, Some("epoch-old")] {
3719 let mut client = client();
3720 client
3721 .subscribe(
3722 "tasks".into(),
3723 "tasks".into(),
3724 vec![("project_id".into(), vec!["p1".into()])],
3725 None,
3726 )
3727 .unwrap();
3728 client
3729 .conn
3730 .execute_batch(
3731 "CREATE TRIGGER epoch_table_identity AFTER INSERT ON tasks BEGIN SELECT 1; END",
3732 )
3733 .unwrap();
3734 let id = client
3735 .mutate(vec![Mutation::Upsert {
3736 table: "tasks".into(),
3737 values: Map::from_iter([
3738 ("id".into(), json!("offline")),
3739 ("project_id".into(), json!("p1")),
3740 ]),
3741 base_version: None,
3742 }])
3743 .unwrap();
3744 if let Some(epoch) = stored {
3745 client.set_meta(LOG_EPOCH_KEY, epoch);
3746 }
3747 let before = serde_json::to_value(client.subscription_state("tasks")).unwrap();
3748 let (_, meta) = client.build_request(false).unwrap();
3749 client.drain_change_batches();
3750 client.drain_sync_intents();
3751 let response = Message {
3752 wire_version: WIRE_VERSION,
3753 msg_kind: MsgKind::Response,
3754 frames: vec![Frame::RespHeader {
3755 required_schema_version: None,
3756 latest_schema_version: None,
3757 log_epoch: Some("epoch-new".into()),
3758 reset_required: Some(true),
3759 }],
3760 };
3761 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3762 let outcome = client.process_response(&mut transport, response, &meta);
3763 let SyncOutcome::Ok(report) = outcome else {
3764 panic!("epoch round failed: {outcome:?}");
3765 };
3766 assert_eq!(
3767 report.resets,
3768 if stored.is_none() {
3769 Vec::<String>::new()
3770 } else {
3771 vec!["tasks".into()]
3772 }
3773 );
3774 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3775 assert_eq!(client.pending_commit_ids(), vec![id]);
3776 assert_eq!(
3777 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
3778 json!("offline")
3779 );
3780 let trigger: i64 = client
3781 .conn
3782 .query_row(
3783 "SELECT count(*) FROM sqlite_master WHERE name='epoch_table_identity'",
3784 [],
3785 |row| row.get(0),
3786 )
3787 .unwrap();
3788 if stored.is_none() {
3789 assert!(!client.upgrading);
3790 assert!(client
3791 .drain_change_batches()
3792 .iter()
3793 .all(|batch| batch.status.as_ref().is_none_or(|status| !status.upgrading)));
3794 assert_eq!(
3795 serde_json::to_value(client.subscription_state("tasks")).unwrap(),
3796 before
3797 );
3798 assert_eq!(trigger, 1, "acquisition must not drop the synced table");
3799 } else {
3800 assert!(client.upgrading);
3801 assert_eq!(trigger, 0, "an epoch change must drop the old table");
3802 }
3803 assert!(matches!(
3804 client.drain_sync_intents().as_slice(),
3805 [SyncIntent::Interactive]
3806 ));
3807 }
3808 }
3809
3810 #[test]
3811 fn epoch_acquisition_storage_failures_preserve_ready_state_and_intent() {
3812 for key in ["logEpoch", "localRevision"] {
3813 let mut client = client();
3814 let id = client
3815 .mutate(vec![Mutation::Upsert {
3816 table: "tasks".into(),
3817 values: Map::from_iter([
3818 ("id".into(), json!("offline")),
3819 ("project_id".into(), json!("p1")),
3820 ]),
3821 base_version: None,
3822 }])
3823 .unwrap();
3824 let revision = client.local_revision();
3825 let needed = client.sync_needed;
3826 client.drain_change_batches();
3827 client.drain_sync_intents();
3828 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();
3829 assert!(client.run_log_epoch_reset("epoch-new").is_err());
3830 assert!(client.get_meta(LOG_EPOCH_KEY).is_none());
3831 assert!(!client.upgrading);
3832 assert_eq!(client.sync_needed, needed);
3833 assert_eq!(client.local_revision(), revision);
3834 assert_eq!(client.pending_commit_ids(), [id]);
3835 assert!(client.drain_change_batches().is_empty());
3836 assert!(client.drain_sync_intents().is_empty());
3837 }
3838 }
3839
3840 #[test]
3841 fn failed_log_epoch_resets_restore_memory_and_durable_state() {
3842 for key in [
3843 "localSchemaVersion",
3844 "localSchemaDescriptor",
3845 "logEpoch",
3846 "localRevision",
3847 ] {
3848 let mut client = client();
3849 client.set_meta(LOG_EPOCH_KEY, "epoch-old");
3850 let commit = client
3851 .mutate(vec![Mutation::Upsert {
3852 table: "tasks".into(),
3853 values: Map::from_iter([
3854 ("id".into(), json!("offline")),
3855 ("project_id".into(), json!("p1")),
3856 ]),
3857 base_version: None,
3858 }])
3859 .unwrap();
3860 client.active_round = Some(uuid::Uuid::new_v4());
3861 client.overlay_dirty.set(false);
3862 let round = client.active_round;
3863 let needed = client.sync_needed;
3864 let revision = client.local_revision();
3865 let rows = serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap();
3866 client.drain_change_batches();
3867 client.drain_sync_intents();
3868 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();
3869 assert!(client.run_log_epoch_reset("epoch-new").is_err(), "{key}");
3870 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-old"));
3871 assert!(!client.upgrading);
3872 assert_eq!(client.active_round, round);
3873 assert!(!client.overlay_dirty.get());
3874 assert_eq!(client.sync_needed, needed);
3875 assert_eq!(client.local_revision(), revision);
3876 assert_eq!(
3877 client.pending_commit_ids().as_slice(),
3878 std::slice::from_ref(&commit)
3879 );
3880 assert_eq!(
3881 serde_json::to_value(client.read_rows("tasks").unwrap()).unwrap(),
3882 rows
3883 );
3884 assert!(client.drain_change_batches().is_empty());
3885 assert!(client.drain_sync_intents().is_empty());
3886 assert!(client.conn.is_autocommit());
3887 client
3888 .conn
3889 .execute_batch("DROP TRIGGER fail_reset")
3890 .unwrap();
3891 client.run_log_epoch_reset("epoch-new").unwrap();
3892 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("epoch-new"));
3893 assert_eq!(client.pending_commit_ids(), [commit]);
3894 }
3895 }
3896
3897 #[test]
3898 fn acknowledged_restore_and_replay_ignore_unrelated_subscription_count() {
3899 for subscriptions in [0, 2, 6, 12] {
3900 let mut client = SyncClient::new("ack-scope".into(), &json!({ "version": 1, "tables": [
3901 { "name": "tasks", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] },
3902 { "name": "docs", "primaryKey": "id", "columns": [{ "name": "id", "type": "string", "nullable": false }, { "name": "project_id", "type": "string", "nullable": false }], "scopes": [{ "pattern": "project:{project_id}" }] }
3903 ] }), ClientLimits::default()).unwrap();
3904 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
3905 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();
3906 for index in 0..32 {
3907 client
3908 .mutate(vec![Mutation::Upsert {
3909 table: "tasks".into(),
3910 values: Map::from_iter([
3911 ("id".into(), json!(format!("task-{index}"))),
3912 ("project_id".into(), json!("p1")),
3913 ]),
3914 base_version: None,
3915 }])
3916 .unwrap();
3917 }
3918 let (_, meta) = client.build_request(false).unwrap();
3919 let mut frames = vec![Frame::RespHeader {
3920 required_schema_version: None,
3921 latest_schema_version: None,
3922 log_epoch: Some("epoch-1".into()),
3923 reset_required: Some(false),
3924 }];
3925 frames.extend(meta.pushed_ids.iter().enumerate().map(|(index, id)| {
3926 Frame::PushResult {
3927 client_commit_id: id.clone(),
3928 status: PushStatus::Applied,
3929 commit_seq: Some(index as i64 + 1),
3930 results: vec![OpResult::Applied { op_index: 0 }],
3931 }
3932 }));
3933 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
3934 client.acknowledged_replay_count.set(0);
3935 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3936 assert!(matches!(
3937 client.process_response(
3938 &mut transport,
3939 Message {
3940 wire_version: WIRE_VERSION,
3941 msg_kind: MsgKind::Response,
3942 frames
3943 },
3944 &meta
3945 ),
3946 SyncOutcome::Ok(_)
3947 ));
3948 assert_eq!(client.acknowledged_replay_count.get(), 32);
3949 assert_eq!(
3950 client
3951 .query(
3952 "SELECT count(*) AS n FROM ack_writes WHERE kind='restore'",
3953 &[]
3954 )
3955 .unwrap()[0]["n"],
3956 json!(32)
3957 );
3958 assert_eq!(
3959 client
3960 .query(
3961 "SELECT count(*) AS n FROM ack_writes WHERE kind='replay'",
3962 &[]
3963 )
3964 .unwrap()[0]["n"],
3965 json!(32)
3966 );
3967 client.conn.execute("DELETE FROM ack_writes", []).unwrap();
3968 client.acknowledged_replay_count.set(0);
3969 for index in 0..subscriptions {
3970 client
3971 .subscribe(
3972 format!("docs-{index}"),
3973 "docs".into(),
3974 vec![("project_id".into(), vec![format!("scope-{index}")])],
3975 None,
3976 )
3977 .unwrap();
3978 let sub = client.subs.len() - 1;
3979 client.subs[sub].effective =
3980 Some(vec![("project_id".into(), vec![format!("scope-{index}")])]);
3981 let segment = RowsSegment {
3982 table: "docs".into(),
3983 schema_version: 1,
3984 columns: client.schema.table("docs").unwrap().wire_columns.clone(),
3985 blocks: if index % 2 == 0 {
3986 vec![vec![ssp2::segment::SegmentRow {
3987 values: vec![
3988 Some(ColumnValue::String(format!("doc-{index}"))),
3989 Some(ColumnValue::String(format!("scope-{index}"))),
3990 ],
3991 server_version: 1,
3992 }]]
3993 } else {
3994 vec![]
3995 },
3996 };
3997 assert!(client.apply_segment(sub, &segment, true).is_ok());
3998 }
3999 assert_eq!(
4000 client
4001 .query("SELECT count(*) AS n FROM ack_writes", &[])
4002 .unwrap()[0]["n"],
4003 json!(0),
4004 "unrelated bootstrap must not restore/replay tasks"
4005 );
4006 assert_eq!(
4007 client.acknowledged_replay_count.get(),
4008 0,
4009 "unrelated bootstrap must not decode task ACKs"
4010 );
4011 assert_eq!(
4012 client
4013 .query("SELECT count(*) AS n FROM tasks", &[])
4014 .unwrap()[0]["n"],
4015 json!(32)
4016 );
4017 assert_eq!(
4018 client
4019 .query("SELECT count(*) AS n FROM _syncular_acked_rows", &[])
4020 .unwrap()[0]["n"],
4021 json!(32)
4022 );
4023 }
4024 }
4025
4026 #[test]
4027 fn batched_push_acknowledgements_rebuild_overlay_once_per_response() {
4028 let mut client = client();
4029 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
4030 const COMMIT_COUNT: usize = 32;
4031
4032 for index in 0..COMMIT_COUNT {
4033 client
4034 .mutate(vec![Mutation::Upsert {
4035 table: "tasks".to_owned(),
4036 values: Map::from_iter([
4037 ("id".to_owned(), Value::from(format!("task-{index}"))),
4038 ("project_id".to_owned(), Value::from("project-1")),
4039 ]),
4040 base_version: None,
4041 }])
4042 .expect("queue commit");
4043 }
4044
4045 let (_, request_meta) = client.build_request(false).unwrap();
4046 assert_eq!(request_meta.pushed_ids.len(), COMMIT_COUNT);
4047 let mut frames = vec![Frame::RespHeader {
4048 required_schema_version: None,
4049 latest_schema_version: None,
4050 log_epoch: Some("epoch-1".to_owned()),
4051 reset_required: Some(false),
4052 }];
4053 frames.extend(request_meta.pushed_ids.iter().enumerate().map(
4054 |(index, client_commit_id)| Frame::PushResult {
4055 client_commit_id: client_commit_id.clone(),
4056 status: PushStatus::Applied,
4057 commit_seq: Some(index as i64 + 1),
4058 results: vec![OpResult::Applied { op_index: 0 }],
4059 },
4060 ));
4061 let response = Message {
4062 wire_version: WIRE_VERSION,
4063 msg_kind: MsgKind::Response,
4064 frames,
4065 };
4066 let mut transport =
4067 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
4068
4069 client.overlay_rebuild_count.set(0);
4070 client.outcome_prune_count.set(0);
4071 let outcome = client.process_response(&mut transport, response, &request_meta);
4072 assert!(matches!(outcome, SyncOutcome::Ok(_)));
4073 assert!(client.pending_commit_ids().is_empty());
4074 assert_eq!(
4075 client.overlay_rebuild_count.get(),
4076 1,
4077 "one response must reconcile its acknowledged commits with one overlay rebuild"
4078 );
4079 assert_eq!(
4080 client.outcome_prune_count.get(),
4081 1,
4082 "one response must enforce outcome retention once"
4083 );
4084 }
4085
4086 #[test]
4087 fn clean_commit_mirroring_matches_full_replay_with_fts_and_unique_indexes() {
4088 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id",
4089 "columns": [
4090 {"name":"id", "type":"string", "nullable":false},
4091 {"name":"project_id", "type":"string", "nullable":false},
4092 {"name":"title", "type":"string", "nullable":false}
4093 ], "scopes": [{"pattern":"project:{project_id}"}],
4094 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
4095 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]
4096 }] });
4097 let mut incremental =
4098 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4099 let mut reference =
4100 SyncClient::new("mirror".to_owned(), &schema, ClientLimits::default()).unwrap();
4101 let tables = vec!["tasks".to_owned()];
4102 for index in 0..32 {
4103 let id = format!("task-{}", index % 8);
4104 let values = Map::from_iter([
4105 ("id".to_owned(), Value::from(id.clone())),
4106 (
4107 "project_id".to_owned(),
4108 Value::from(format!("p{}", index % 2)),
4109 ),
4110 ("title".to_owned(), Value::from(format!("needle{index}"))),
4111 ]);
4112 let payload = encode_row_json(
4113 incremental.schema.table("tasks").unwrap(),
4114 &id,
4115 &values,
4116 &incremental.encryption,
4117 )
4118 .unwrap();
4119 let change = ssp2::model::Change {
4120 table_index: 0,
4121 row_id: id,
4122 op: if index % 5 == 0 {
4123 Op::Delete
4124 } else {
4125 Op::Upsert
4126 },
4127 row_version: Some(index + 1),
4128 scopes: vec![("project_id".to_owned(), format!("p{}", index % 2))],
4129 row: Some(payload),
4130 };
4131 reference.overlay_dirty.set(true);
4132 for instance in [&mut incremental, &mut reference] {
4133 instance.begin_observation("mirror_test").unwrap();
4134 let mut batch = ChangeAccumulator::default();
4135 instance.record_commit_changes(&mut batch, &tables, std::slice::from_ref(&change));
4136 instance
4137 .apply_commit_changes(&tables, std::slice::from_ref(&change))
4138 .unwrap();
4139 instance
4140 .rebuild_overlay_if_dirty()
4141 .expect("rebuild overlay");
4142 instance.finish_observation("mirror_test", batch).unwrap();
4143 }
4144 for sql in [
4145 "SELECT * FROM tasks ORDER BY id",
4146 "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
4147 ] {
4148 assert_eq!(
4149 incremental.query(sql, &[]).unwrap(),
4150 reference.query(sql, &[]).unwrap()
4151 );
4152 }
4153 assert_eq!(incremental.local_revision(), reference.local_revision());
4154 assert_eq!(
4155 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4156 serde_json::to_value(reference.drain_change_batches()).unwrap()
4157 );
4158 }
4159 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
4160 assert_eq!(reference.overlay_rebuild_count.get(), 32);
4161 }
4162
4163 #[test]
4164 fn row_id_lookups_preserve_text_matching_and_seek_lossless_keys() {
4165 let cases = [
4166 (
4167 "string",
4168 vec![
4169 json!("1"),
4170 json!("01"),
4171 json!(""),
4172 json!("nul\u{0}id"),
4173 json!("é"),
4174 ],
4175 ),
4176 (
4177 "integer",
4178 vec![
4179 json!(i64::MIN),
4180 json!(-1),
4181 json!(0),
4182 json!(1),
4183 json!(i64::MAX),
4184 ],
4185 ),
4186 ("boolean", vec![json!(false), json!(true)]),
4187 ("json", vec![json!("null"), json!("1"), json!("{\"a\":1}")]),
4188 ];
4189 for (kind, ids) in cases {
4190 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4191 "columns":[{"name":"id","type":kind,"nullable":false},
4192 {"name":"project_id","type":"string","nullable":false}],
4193 "scopes":[{"pattern":"project:{project_id}"}]}]});
4194 let mut client =
4195 SyncClient::new("row-id-lookup".into(), &schema, ClientLimits::default()).unwrap();
4196 for (index, id) in ids.iter().enumerate() {
4197 let table = client.schema.table("tasks").unwrap();
4198 client
4199 .write_base_row(
4200 "tasks",
4201 &vec![
4202 if kind == "integer" {
4203 Some(ColumnValue::Integer(id.as_i64().unwrap()))
4206 } else {
4207 json_to_column_value(&table.columns[0], Some(id)).unwrap()
4208 },
4209 Some(ColumnValue::String(format!("p{index}"))),
4210 ],
4211 1,
4212 )
4213 .unwrap();
4214 }
4215 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4216 for base in [true, false] {
4217 let full = if base {
4218 base_table("tasks")
4219 } else {
4220 visible_table("tasks")
4221 };
4222 let predicate = row_id_predicate(client.schema.table("tasks").unwrap());
4223 for row_id in [
4224 "1",
4225 "01",
4226 "1.0",
4227 "1.0000000000000002",
4228 "+1",
4229 "1e0",
4230 " 1",
4231 "-1",
4232 "0",
4233 "9223372036854775807",
4234 "-9223372036854775808",
4235 "true",
4236 "false",
4237 "",
4238 "nul\u{0}id",
4239 "é",
4240 "null",
4241 "{\"a\":1}",
4242 ] {
4243 let old_sql =
4244 format!("SELECT project_id FROM {full} WHERE CAST(id AS TEXT) = ?1");
4245 let new_sql = format!("SELECT project_id FROM {full} WHERE {predicate}");
4246 let old = client
4247 .conn
4248 .prepare(&old_sql)
4249 .unwrap()
4250 .query_map([row_id], |row| row.get::<_, String>(0))
4251 .unwrap()
4252 .collect::<Result<Vec<_>, _>>()
4253 .unwrap();
4254 let new = client
4255 .conn
4256 .prepare_cached(&new_sql)
4257 .unwrap()
4258 .query_map([row_id], |row| row.get::<_, String>(0))
4259 .unwrap()
4260 .collect::<Result<Vec<_>, _>>()
4261 .unwrap();
4262 assert_eq!(old, new, "{kind}, base={base}, row_id={row_id:?}");
4263 let mut batch = ChangeAccumulator::default();
4264 assert_eq!(
4265 client.record_row_scopes(&mut batch, "tasks", row_id, base),
4266 !old.is_empty()
4267 );
4268 let expected = old.first().map(|scope| {
4269 BTreeMap::from([(
4270 "tasks".to_owned(),
4271 Some(BTreeSet::from([format!("project:{scope}")])),
4272 )])
4273 });
4274 assert_eq!(batch.tables, expected.unwrap_or_default());
4275 client
4276 .conn
4277 .execute_batch("SAVEPOINT lookup_delete")
4278 .unwrap();
4279 if base {
4280 client.delete_base_row("tasks", row_id).unwrap();
4281 } else {
4282 client
4283 .apply_outbox_ops(&[OutboxOp {
4284 upsert: false,
4285 insert: false,
4286 table: "tasks".into(),
4287 row_id: row_id.into(),
4288 base_version: None,
4289 values: None,
4290 }])
4291 .expect("replay delete");
4292 }
4293 let remaining: i64 = client
4294 .conn
4295 .query_row(&format!("SELECT count(*) FROM {full}"), [], |row| {
4296 row.get(0)
4297 })
4298 .unwrap();
4299 assert_eq!(remaining, ids.len() as i64 - old.len() as i64);
4300 client
4301 .conn
4302 .execute_batch("ROLLBACK TO lookup_delete; RELEASE lookup_delete")
4303 .unwrap();
4304 client.overlay_dirty.set(false);
4305 }
4306 for (predicate, expected) in [
4307 ("CAST(id AS TEXT) = ?1".to_owned(), "SCAN"),
4308 (predicate, "SEARCH"),
4309 ] {
4310 let sql = format!("EXPLAIN QUERY PLAN SELECT project_id FROM {full} WHERE {predicate} LIMIT 1");
4311 let details = client
4312 .conn
4313 .prepare(&sql)
4314 .unwrap()
4315 .query_map(["1"], |row| row.get::<_, String>(3))
4316 .unwrap()
4317 .collect::<Result<Vec<_>, _>>()
4318 .unwrap();
4319 assert!(
4320 details.iter().any(|detail| detail.contains(expected)),
4321 "{kind}: {details:?}"
4322 );
4323 }
4324 }
4325 }
4326 }
4327
4328 #[test]
4329 fn primary_keys_reject_every_ineligible_column_type() {
4330 for ty in ["float", "bytes", "crdt", "blob_ref"] {
4334 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4335 "columns":[{"name":"id","type":ty,"nullable":false},
4336 {"name":"project_id","type":"string","nullable":false}],
4337 "scopes":[{"pattern":"project:{project_id}"}]}]});
4338 let error =
4339 match SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default()) {
4340 Ok(_) => panic!("ineligible primary key {ty} must be rejected"),
4341 Err(error) => error,
4342 };
4343 assert!(
4344 error.contains("has an ineligible column type (§2.4)"),
4345 "{ty}: {error}"
4346 );
4347 }
4348 for ty in ["string", "integer", "boolean", "json"] {
4349 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id",
4350 "columns":[{"name":"id","type":ty,"nullable":false},
4351 {"name":"project_id","type":"string","nullable":false}],
4352 "scopes":[{"pattern":"project:{project_id}"}]}]});
4353 SyncClient::new("pk-eligibility".into(), &schema, ClientLimits::default())
4354 .unwrap_or_else(|error| panic!("{ty}: {error}"));
4355 }
4356 }
4357
4358 #[test]
4359 fn pending_row_reconciliation_matches_full_fifo_replay() {
4360 for unique in [false, true] {
4361 let schema = json!({"version": 1, "tables": (["tasks", "notes"].map(|name| json!({
4362 "name": name, "primaryKey": "id", "columns": [
4363 {"name":"id", "type":"string", "nullable":false},
4364 {"name":"project_id", "type":"string", "nullable":false},
4365 {"name":"title", "type":"string", "nullable":false}],
4366 "scopes": [{"pattern":"project:{project_id}"}],
4367 "indexes": [{"name":format!("{name}_title"), "columns":["title"], "unique":unique || name == "notes"}],
4368 "ftsIndexes": [{"name":format!("{name}_fts"), "columns":["title"], "tokenize":"unicode61"}]
4369 })))});
4370 let mut incremental =
4371 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4372 let mut reference =
4373 SyncClient::new("pending".into(), &schema, ClientLimits::default()).unwrap();
4374 let tables = vec!["tasks".to_owned(), "notes".to_owned()];
4375 for instance in [&mut incremental, &mut reference] {
4376 for table in &tables {
4377 for index in 0..12 {
4378 instance
4379 .write_base_row(
4380 table,
4381 &vec![
4382 Some(ColumnValue::String(format!("row{index}"))),
4383 Some(ColumnValue::String("p0".into())),
4384 Some(ColumnValue::String(format!("base{index}"))),
4385 ],
4386 1,
4387 )
4388 .unwrap();
4389 }
4390 }
4391 instance
4392 .rebuild_overlay_if_dirty()
4393 .expect("rebuild overlay");
4394 }
4395 for index in 0..80 {
4396 let mutations = (0..3)
4397 .map(|offset| {
4398 let table = tables[(index + offset) % 2].clone();
4399 let id = format!("row{}", (index + offset) % 16);
4400 if (index + offset) % 7 == 0 {
4401 Mutation::Delete {
4402 table,
4403 row_id: id,
4404 base_version: None,
4405 }
4406 } else {
4407 Mutation::Upsert {
4408 table,
4409 values: Map::from_iter([
4410 ("id".into(), Value::from(id)),
4411 ("project_id".into(), Value::from(format!("p{}", index % 3))),
4412 (
4413 "title".into(),
4414 Value::from(format!("needle{}", (index + offset) % 16)),
4415 ),
4416 ]),
4417 base_version: None,
4418 }
4419 }
4420 })
4421 .collect::<Vec<_>>();
4422 for instance in [&mut incremental, &mut reference] {
4423 instance.mutate(mutations.clone()).unwrap();
4424 }
4425 let changes = (0..3)
4428 .map(|offset| {
4429 let table_index = if index % 4 == 0 { offset % 2 } else { 0 };
4430 let id = format!("row{}", (index + offset) % 16);
4431 let values = Map::from_iter([
4432 ("id".into(), Value::from(id.clone())),
4433 (
4434 "project_id".into(),
4435 Value::from(format!("p{}", (index + 1) % 3)),
4436 ),
4437 (
4438 "title".into(),
4439 Value::from(format!("remote{index}_{offset}")),
4440 ),
4441 ]);
4442 ssp2::model::Change {
4443 table_index: table_index as u16,
4444 row_id: id.clone(),
4445 op: if (index + offset) % 5 == 0 {
4446 Op::Delete
4447 } else {
4448 Op::Upsert
4449 },
4450 row_version: Some(index as i64 + 2),
4451 scopes: vec![],
4452 row: Some(
4453 encode_row_json(
4454 incremental.schema.table(&tables[table_index]).unwrap(),
4455 &id,
4456 &values,
4457 &incremental.encryption,
4458 )
4459 .unwrap(),
4460 ),
4461 }
4462 })
4463 .collect::<Vec<_>>();
4464 reference.overlay_dirty.set(true);
4465 incremental
4466 .apply_commit_frame(&tables, &changes, None)
4467 .unwrap();
4468 reference
4469 .apply_commit_frame(&tables, &changes, None)
4470 .unwrap();
4471 for table in &tables {
4472 for sql in [format!("SELECT * FROM {table} ORDER BY id"),
4473 format!("SELECT * FROM _syncular_base_{table} ORDER BY id"),
4474 format!("SELECT _syncular_source_id, title FROM {table}_fts ORDER BY _syncular_source_id"),
4475 format!("SELECT _syncular_source_id FROM {table}_fts WHERE {table}_fts MATCH 'needle1' ORDER BY _syncular_source_id")] {
4476 assert_eq!(incremental.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "unique={unique}, frame={index}: {sql}");
4477 }
4478 }
4479 assert_eq!(incremental.local_revision(), reference.local_revision());
4480 assert_eq!(
4481 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
4482 serde_json::to_value(reference.drain_change_batches()).unwrap()
4483 );
4484 }
4485 assert_eq!(
4486 incremental.overlay_rebuild_count.get(),
4487 if unique { 81 } else { 21 }
4488 );
4489 assert_eq!(reference.overlay_rebuild_count.get(), 81);
4490 }
4491 }
4492
4493 #[test]
4494 fn remote_unique_values_reconsider_pending_writes_on_other_rows() {
4495 let schema = json!({"version": 1, "tables": [{"name":"tasks", "primaryKey":"id",
4496 "columns":[{"name":"id", "type":"string", "nullable":false},
4497 {"name":"title", "type":"string", "nullable":false}],
4498 "scopes":[], "indexes":[{"name":"unique_title", "columns":["title"], "unique":true}]}]});
4499 let mut client =
4500 SyncClient::new("unique-pending".into(), &schema, ClientLimits::default()).unwrap();
4501 for (id, title) in [("a", "original"), ("b", "occupied")] {
4502 client
4503 .write_base_row(
4504 "tasks",
4505 &vec![
4506 Some(ColumnValue::String(id.into())),
4507 Some(ColumnValue::String(title.into())),
4508 ],
4509 1,
4510 )
4511 .unwrap();
4512 }
4513 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4514 let pending = client
4515 .mutate(vec![Mutation::Upsert {
4516 table: "tasks".into(),
4517 values: Map::from_iter([
4518 ("id".into(), Value::from("a")),
4519 ("title".into(), Value::from("available")),
4520 ]),
4521 base_version: None,
4522 }])
4523 .unwrap();
4524 assert_eq!(
4525 client
4526 .conn
4527 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4528 .get::<_, String>(
4529 0
4530 ))
4531 .unwrap(),
4532 "available"
4533 );
4534 for (title, expected) in [("available", "original"), ("occupied", "available")] {
4535 let change = ssp2::model::Change {
4536 table_index: 0,
4537 row_id: "b".into(),
4538 op: Op::Upsert,
4539 row_version: Some(2),
4540 scopes: vec![],
4541 row: Some(
4542 encode_row_json(
4543 client.schema.table("tasks").unwrap(),
4544 "b",
4545 &Map::from_iter([
4546 ("id".into(), Value::from("b")),
4547 ("title".into(), Value::from(title)),
4548 ]),
4549 &client.encryption,
4550 )
4551 .unwrap(),
4552 ),
4553 };
4554 client
4555 .apply_commit_frame(&["tasks".into()], &[change], None)
4556 .unwrap();
4557 assert_eq!(
4558 client
4559 .conn
4560 .query_row("SELECT title FROM tasks WHERE id = 'a'", [], |row| row
4561 .get::<_, String>(
4562 0
4563 ))
4564 .unwrap(),
4565 expected
4566 );
4567 assert_eq!(client.pending_commit_ids(), vec![pending.clone()]);
4568 }
4569 assert_eq!(client.overlay_rebuild_count.get(), 3);
4570 }
4571
4572 fn measure_scoped_overlay(unchanged_rows: i64, rounds: i64) -> Vec<u128> {
4573 let schema = json!({ "version": 1, "tables": [
4574 { "name":"tasks", "primaryKey":"id", "columns":[
4575 {"name":"id","type":"string","nullable":false},
4576 {"name":"project_id","type":"string","nullable":false}],
4577 "scopes":[{"pattern":"project:{project_id}"}] },
4578 { "name":"catalogue", "primaryKey":"id", "columns":[
4579 {"name":"id","type":"string","nullable":false},
4580 {"name":"project_id","type":"string","nullable":false},
4581 {"name":"title","type":"string","nullable":false}],
4582 "scopes":[{"pattern":"project:{project_id}"}],
4583 "ftsIndexes":[{"name":"catalogue_fts","columns":["title"],"tokenize":"unicode61"}] }
4584 ] });
4585 let mut client =
4586 SyncClient::new("overlay-scope".into(), &schema, ClientLimits::default()).unwrap();
4587 if unchanged_rows > 0 {
4588 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();
4589 }
4590 client.rebuild_overlay().expect("rebuild overlay");
4591 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();
4592 let mut elapsed = Vec::new();
4593 for round in 1..=rounds {
4594 let pending = client
4595 .mutate(vec![Mutation::Upsert {
4596 table: "tasks".into(),
4597 values: Map::from_iter([
4598 ("id".into(), json!("t1")),
4599 ("project_id".into(), json!("p1")),
4600 ]),
4601 base_version: None,
4602 }])
4603 .unwrap();
4604 let started = std::time::Instant::now();
4605 client
4606 .write_base_row(
4607 "tasks",
4608 &vec![
4609 Some(ColumnValue::String("t1".into())),
4610 Some(ColumnValue::String("p1".into())),
4611 ],
4612 round,
4613 )
4614 .unwrap();
4615 let mut report = SyncReport::default();
4616 client
4617 .handle_push_results(
4618 &[Frame::PushResult {
4619 client_commit_id: pending.clone(),
4620 status: PushStatus::Applied,
4621 commit_seq: Some(round),
4622 results: vec![OpResult::Applied { op_index: 0 }],
4623 }],
4624 &HashSet::from([pending.as_str()]),
4625 &HashMap::new(),
4626 &mut report,
4627 Some(&pending),
4628 )
4629 .unwrap();
4630 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
4631 elapsed.push(started.elapsed().as_nanos());
4632 }
4633 assert_eq!(
4634 client
4635 .conn
4636 .query_row("SELECT count(*) FROM audit", [], |r| r.get::<_, i64>(0))
4637 .unwrap(),
4638 0,
4639 "unchanged catalogue must never be copied"
4640 );
4641 assert_eq!(
4642 client
4643 .conn
4644 .query_row(
4645 "SELECT count(*) FROM catalogue_fts WHERE catalogue_fts MATCH 'needle'",
4646 [],
4647 |r| r.get::<_, i64>(0)
4648 )
4649 .unwrap(),
4650 unchanged_rows
4651 );
4652 assert_eq!(
4653 client.query("SELECT * FROM tasks", &[]).unwrap()[0]["_syncular_version"],
4654 json!(rounds)
4655 );
4656 elapsed
4657 }
4658
4659 #[test]
4660 fn changed_overlay_leaves_large_unchanged_table_and_fts_untouched() {
4661 let elapsed = measure_scoped_overlay(100_000, 1);
4662 eprintln!(
4663 "table-scoped overlay with 100k unchanged rows: {} ns",
4664 elapsed[0]
4665 );
4666 }
4667
4668 #[test]
4669 #[ignore = "isolated performance measurement; run with the shared heavy-check lock"]
4670 fn benchmark_scoped_overlay() {
4671 for rows in [0, 100_000, 200_000] {
4672 let mut elapsed = measure_scoped_overlay(rows, 30);
4673 elapsed.sort_unstable();
4674 eprintln!(
4675 "{}",
4676 json!({"unchangedRows": rows, "rounds": elapsed.len(),
4677 "medianNs": elapsed[elapsed.len()/2], "minNs": elapsed[0],
4678 "maxNs": elapsed[elapsed.len()-1], "unchangedDeletes": 0})
4679 );
4680 }
4681 }
4682
4683 #[test]
4684 fn released_context_discards_in_flight_ack_and_server_rows() {
4685 for barrier in [
4686 "preflight",
4687 "purge",
4688 "rebootstrap",
4689 "subscription",
4690 "failed-purge-replay",
4691 "failed-purge-publish",
4692 "failed-rebootstrap",
4693 ] {
4694 let mut client = client();
4695 client.set_meta(LOG_EPOCH_KEY, "epoch");
4696 client
4697 .subscribe(
4698 "tasks".into(),
4699 "tasks".into(),
4700 vec![("project_id".into(), vec!["p1".into()])],
4701 None,
4702 )
4703 .unwrap();
4704 let commit = client
4705 .mutate(vec![Mutation::Upsert {
4706 table: "tasks".into(),
4707 values: Map::from_iter([
4708 ("id".into(), json!("local")),
4709 ("project_id".into(), json!("p1")),
4710 ]),
4711 base_version: None,
4712 }])
4713 .unwrap();
4714 let prepared = client.prepare_sync_round(false).unwrap();
4715 client.set_transport_enabled(&mut CountingRealtimeTransport::default(), false);
4716 let payload = encode_row_json(
4717 &client.schema.tables[0],
4718 "server",
4719 &Map::from_iter([
4720 ("id".into(), json!("server")),
4721 ("project_id".into(), json!("p1")),
4722 ]),
4723 &client.encryption,
4724 )
4725 .unwrap();
4726 let completed = crate::CompletedSyncRound {
4727 prepared,
4728 exchange: crate::round::ExchangeResult::Reply {
4729 transport_failed: false,
4730 downloads: Default::default(),
4731 response: Ok(Message {
4732 wire_version: WIRE_VERSION,
4733 msg_kind: MsgKind::Response,
4734 frames: vec![
4735 Frame::RespHeader {
4736 required_schema_version: None,
4737 latest_schema_version: None,
4738 log_epoch: Some("epoch".into()),
4739 reset_required: Some(false),
4740 },
4741 Frame::PushResult {
4742 client_commit_id: commit.clone(),
4743 status: PushStatus::Applied,
4744 commit_seq: Some(1),
4745 results: vec![OpResult::Applied { op_index: 0 }],
4746 },
4747 Frame::SubStart {
4748 id: "tasks".into(),
4749 status: SubStatus::Active,
4750 reason_code: String::new(),
4751 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
4752 bootstrap: true,
4753 },
4754 Frame::Commit {
4755 commit_seq: 1,
4756 created_at_ms: 0,
4757 actor_id: "actor".into(),
4758 tables: vec!["tasks".into()],
4759 changes: vec![ssp2::model::Change {
4760 table_index: 0,
4761 row_id: "server".into(),
4762 op: Op::Upsert,
4763 row_version: Some(1),
4764 scopes: vec![("project_id".into(), "p1".into())],
4765 row: Some(payload),
4766 }],
4767 },
4768 Frame::SubEnd {
4769 next_cursor: 1,
4770 bootstrap_state: None,
4771 },
4772 ],
4773 }),
4774 },
4775 };
4776 match barrier {
4777 "preflight" => client.begin_security_preflight(),
4778 "purge" => {
4779 client
4780 .purge_local_data(&LocalDataPurgeInput {
4781 purge_id: "pending-round".into(),
4782 targets: vec![LocalDataPurgeTarget {
4783 table: "tasks".into(),
4784 selectors: BTreeMap::from([(
4785 "project_id".into(),
4786 vec!["p1".into()],
4787 )]),
4788 }],
4789 })
4790 .unwrap();
4791 }
4792 "rebootstrap" => {
4793 client
4794 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4795 rebootstrap_id: "pending-round".into(),
4796 })
4797 .unwrap();
4798 }
4799 "failed-purge-replay" | "failed-purge-publish" | "failed-rebootstrap" => {
4800 let trigger = if barrier == "failed-purge-replay" {
4801 "CREATE TRIGGER fail_local BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4802 } else {
4803 "CREATE TRIGGER fail_local BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT, 'local fault'); END"
4804 };
4805 client.conn.execute_batch(trigger).unwrap();
4806 let revision = client.local_revision();
4807 if barrier == "failed-rebootstrap" {
4808 assert!(client
4809 .rebootstrap_local_data(&LocalDataRebootstrapInput {
4810 rebootstrap_id: "failed-round".into(),
4811 })
4812 .is_err());
4813 } else {
4814 assert!(client
4815 .purge_local_data(&LocalDataPurgeInput {
4816 purge_id: "failed-round".into(),
4817 targets: vec![LocalDataPurgeTarget {
4818 table: "tasks".into(),
4819 selectors: BTreeMap::from([(
4820 "project_id".into(),
4821 vec!["p1".into()]
4822 )]),
4823 }],
4824 })
4825 .is_err());
4826 }
4827 assert_eq!(client.local_revision(), revision);
4828 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
4829 client
4830 .conn
4831 .execute_batch("DROP TRIGGER fail_local")
4832 .unwrap();
4833 let applied = client.apply_sync_round(completed);
4834 assert!(
4835 matches!(
4836 applied,
4837 crate::AppliedSyncRound::Complete {
4838 outcome: SyncOutcome::Ok(_),
4839 ..
4840 }
4841 ),
4842 "{barrier}"
4843 );
4844 assert!(client.pending_commit_ids().is_empty());
4845 assert_eq!(
4846 client
4847 .query("SELECT id FROM tasks WHERE id = 'server'", &[])
4848 .unwrap()
4849 .len(),
4850 1
4851 );
4852 continue;
4853 }
4854 "subscription" => {
4855 client.unsubscribe("tasks");
4856 client
4857 .subscribe(
4858 "tasks".into(),
4859 "tasks".into(),
4860 vec![("project_id".into(), vec!["p2".into()])],
4861 None,
4862 )
4863 .unwrap();
4864 }
4865 _ => unreachable!(),
4866 }
4867 let pending_before_reply = client.pending_commit_ids();
4868 let revision = client.local_revision();
4869 let applied = client.apply_sync_round(completed);
4870 assert!(
4871 matches!(applied, crate::AppliedSyncRound::Complete { outcome: SyncOutcome::Failed { ref error_code, .. }, ref controls, .. } if error_code == "client.round_cancelled" && controls.is_empty())
4872 );
4873 assert_eq!(client.local_revision(), revision);
4874 assert!(client
4875 .conn
4876 .query_row(
4877 "SELECT NOT EXISTS(SELECT 1 FROM tasks WHERE id='server')",
4878 [],
4879 |r| r.get::<_, bool>(0)
4880 )
4881 .unwrap());
4882 assert_eq!(client.pending_commit_ids(), pending_before_reply);
4883 }
4884 }
4885
4886 #[test]
4887 fn sync_round_deadline_is_anchored_once_per_round_and_carried_across_continues() {
4888 struct DeadlineRecording {
4889 budget: std::time::Duration,
4890 seen: Vec<Option<std::time::Instant>>,
4891 }
4892 impl Transport for DeadlineRecording {
4893 fn round_deadline(&self) -> Option<std::time::Duration> {
4894 Some(self.budget)
4895 }
4896 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
4897 self.seen.push(deadline);
4898 }
4899 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
4900 Err(TransportError::new("transport.failed", "injected"))
4901 }
4902 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
4903 self.sync(request)
4904 }
4905 fn download_segment(
4906 &mut self,
4907 _: &SegmentRequest,
4908 _: &mut dyn FnMut(u64),
4909 ) -> Result<Vec<u8>, TransportError> {
4910 Err(TransportError::new("transport.failed", "injected"))
4911 }
4912 fn blob_upload_grant(
4913 &mut self,
4914 _: &str,
4915 _: u64,
4916 _: Option<&str>,
4917 ) -> Result<crate::BlobUploadGrant, TransportError> {
4918 Ok(crate::BlobUploadGrant::Present)
4919 }
4920 fn realtime_connect(&mut self) -> Result<(), TransportError> {
4921 Ok(())
4922 }
4923 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
4924 Ok(())
4925 }
4926 fn realtime_close(&mut self) -> Result<(), TransportError> {
4927 Ok(())
4928 }
4929 }
4930
4931 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
4932 {"name":"id","type":"string","nullable":false},
4933 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
4934 let mut client =
4935 SyncClient::new("deadline-round".into(), &schema, Default::default()).unwrap();
4936 client.set_meta(LOG_EPOCH_KEY, "epoch");
4937 client
4938 .upload_blob(b"pending upload", Some("text/plain".into()), None)
4939 .unwrap();
4940
4941 let mut transport = DeadlineRecording {
4942 budget: std::time::Duration::from_secs(30),
4943 seen: Vec::new(),
4944 };
4945 let prepared = client.prepare_sync_round(false).unwrap();
4946 assert_eq!(prepared.uploads.len(), 1, "one staged upload");
4947 let prepared = match client.apply_sync_round(prepared.exchange(&mut transport)) {
4949 crate::AppliedSyncRound::Continue(prepared) => prepared,
4950 crate::AppliedSyncRound::Complete { .. } => panic!("the upload must continue"),
4951 };
4952 let completed = prepared.exchange(&mut transport);
4954 let anchor = completed
4955 .prepared
4956 .round_deadline_at
4957 .expect("the upload round anchors a deadline");
4958 assert!(matches!(
4959 client.apply_sync_round(completed),
4960 crate::AppliedSyncRound::Complete { .. }
4961 ));
4962 assert_eq!(transport.seen.len(), 4, "one scope per exchange");
4963 assert_eq!(
4964 transport.seen[0],
4965 Some(anchor),
4966 "the upload anchors the round"
4967 );
4968 assert_eq!(
4969 transport.seen[1], None,
4970 "the upload exchange clears its scope"
4971 );
4972 assert_eq!(
4973 transport.seen[2],
4974 Some(anchor),
4975 "a continuation reuses the carried anchor instead of refreshing it"
4976 );
4977 assert_eq!(transport.seen[3], None, "the continuation clears its scope");
4978
4979 transport.budget = std::time::Duration::from_secs(60);
4982 let prepared = client.prepare_sync_round(false).unwrap();
4983 let completed = prepared.exchange(&mut transport);
4984 let fresh = completed
4985 .prepared
4986 .round_deadline_at
4987 .expect("the next round anchors a deadline");
4988 client.apply_sync_round(completed);
4989 assert!(
4990 fresh >= anchor + std::time::Duration::from_secs(30),
4991 "the next round anchors a fresh deadline from the new budget"
4992 );
4993 assert_eq!(transport.seen[4], Some(fresh));
4994 assert_eq!(
4995 transport.seen[5], None,
4996 "the new round clears its scope too"
4997 );
4998 }
4999
5000 #[derive(Default)]
5003 struct RoundDeadlineProbe {
5004 budget: Option<std::time::Duration>,
5005 budget_queries: std::cell::Cell<usize>,
5006 scoped: Vec<Option<std::time::Instant>>,
5007 }
5008
5009 impl Transport for RoundDeadlineProbe {
5010 fn round_deadline(&self) -> Option<std::time::Duration> {
5011 self.budget_queries.set(self.budget_queries.get() + 1);
5012 self.budget
5013 }
5014 fn set_round_deadline(&mut self, deadline: Option<std::time::Instant>) {
5015 self.scoped.push(deadline);
5016 }
5017 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5018 Err(TransportError::new("transport.failed", "injected"))
5019 }
5020 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5021 self.sync(request)
5022 }
5023 fn download_segment(
5024 &mut self,
5025 _: &SegmentRequest,
5026 _: &mut dyn FnMut(u64),
5027 ) -> Result<Vec<u8>, TransportError> {
5028 Err(TransportError::new("transport.failed", "injected"))
5029 }
5030 fn blob_upload_grant(
5031 &mut self,
5032 _: &str,
5033 _: u64,
5034 _: Option<&str>,
5035 ) -> Result<crate::BlobUploadGrant, TransportError> {
5036 Ok(crate::BlobUploadGrant::Present)
5037 }
5038 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5039 Ok(())
5040 }
5041 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5042 Ok(())
5043 }
5044 fn realtime_close(&mut self) -> Result<(), TransportError> {
5045 Ok(())
5046 }
5047 }
5048
5049 #[test]
5050 fn an_unusable_upload_body_does_not_anchor_the_round_deadline() {
5051 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5052 {"name":"id","type":"string","nullable":false},
5053 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5054 let mut client =
5055 SyncClient::new("deadline-unusable".into(), &schema, ClientLimits::default()).unwrap();
5056 client.set_meta(LOG_EPOCH_KEY, "epoch");
5057 client
5058 .upload_blob(b"pending upload", Some("text/plain".into()), None)
5059 .unwrap();
5060 client
5061 .conn
5062 .execute_batch("DELETE FROM _syncular_blobs")
5063 .unwrap();
5064 let prepared = client.prepare_sync_round(false).unwrap();
5065 assert!(
5066 prepared.uploads.front().is_some_and(|u| u.bytes.is_err()),
5067 "the staged body must fail locally"
5068 );
5069 let mut transport = RoundDeadlineProbe {
5070 budget: Some(std::time::Duration::from_secs(30)),
5071 ..Default::default()
5072 };
5073 let completed = prepared.exchange(&mut transport);
5074 assert!(matches!(
5075 completed.exchange,
5076 crate::round::ExchangeResult::Upload { result: Err(_), .. }
5077 ));
5078 assert_eq!(
5079 transport.budget_queries.get(),
5080 0,
5081 "a round with no network work never asks for a budget"
5082 );
5083 assert_eq!(transport.scoped, vec![None]);
5084 }
5085
5086 #[test]
5087 fn a_transport_budget_the_clock_cannot_represent_fails_before_network() {
5088 struct HugeBudget;
5089 impl Transport for HugeBudget {
5090 fn round_deadline(&self) -> Option<std::time::Duration> {
5091 Some(std::time::Duration::MAX)
5092 }
5093 fn sync(&mut self, _: &[u8]) -> Result<Vec<u8>, TransportError> {
5094 panic!("no network for an invalid budget")
5095 }
5096 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
5097 self.sync(request)
5098 }
5099 fn download_segment(
5100 &mut self,
5101 _: &SegmentRequest,
5102 _: &mut dyn FnMut(u64),
5103 ) -> Result<Vec<u8>, TransportError> {
5104 panic!("no network for an invalid budget")
5105 }
5106 fn realtime_connect(&mut self) -> Result<(), TransportError> {
5107 Ok(())
5108 }
5109 fn realtime_send(&mut self, _: &str) -> Result<(), TransportError> {
5110 Ok(())
5111 }
5112 fn realtime_close(&mut self) -> Result<(), TransportError> {
5113 Ok(())
5114 }
5115 }
5116
5117 let mut client = client();
5118 client.set_meta(LOG_EPOCH_KEY, "epoch");
5119 let prepared = client.prepare_sync_round(false).unwrap();
5120 let outcome = match client.apply_sync_round(prepared.exchange(&mut HugeBudget)) {
5121 crate::AppliedSyncRound::Complete { outcome, .. } => outcome,
5122 crate::AppliedSyncRound::Continue(_) => {
5123 panic!("an unrepresentable budget must not continue")
5124 }
5125 };
5126 match outcome {
5127 SyncOutcome::Failed {
5128 error_code,
5129 message,
5130 ..
5131 } => {
5132 assert_eq!(error_code, "sync.invalid_request");
5133 assert_eq!(message, "transport round deadline is out of range");
5134 }
5135 _ => panic!("an unrepresentable budget must fail synchronously"),
5136 }
5137 }
5138
5139 #[test]
5140 fn pending_row_reconciliation_failure_preserves_queue_and_frame_state() {
5141 for failure in ["visible", "revision", "commit"] {
5142 let mut client = client();
5143 let id = client
5144 .mutate(vec![Mutation::Upsert {
5145 table: "tasks".into(),
5146 values: Map::from_iter([
5147 ("id".into(), Value::from("t1")),
5148 ("project_id".into(), Value::from("pending")),
5149 ]),
5150 base_version: None,
5151 }])
5152 .unwrap();
5153 client.drain_change_batches();
5154 let revision = client.local_revision();
5155 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
5156 let trigger = match failure {
5157 "visible" => "CREATE TRIGGER fail_pending BEFORE INSERT ON tasks WHEN NEW.project_id = 'remote' BEGIN SELECT RAISE(ABORT, 'injected visible failure'); END".to_owned(),
5158 "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"),
5159 _ => "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(),
5160 };
5161 client.conn.execute_batch(&trigger).unwrap();
5162 let change = ssp2::model::Change {
5163 table_index: 0,
5164 row_id: "t1".into(),
5165 op: Op::Upsert,
5166 row_version: Some(2),
5167 scopes: vec![],
5168 row: Some(
5169 encode_row_json(
5170 client.schema.table("tasks").unwrap(),
5171 "t1",
5172 &Map::from_iter([
5173 ("id".into(), Value::from("t1")),
5174 ("project_id".into(), Value::from("remote")),
5175 ]),
5176 &client.encryption,
5177 )
5178 .unwrap(),
5179 ),
5180 };
5181 assert!(client
5182 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), None)
5183 .is_err());
5184 assert!(client.conn.is_autocommit());
5185 assert!(!client.overlay_dirty.get());
5186 assert_eq!(client.local_revision(), revision);
5187 assert!(client.drain_change_batches().is_empty());
5188 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5189 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
5190 assert!(client
5191 .query("SELECT * FROM _syncular_base_tasks", &[])
5192 .unwrap()
5193 .is_empty());
5194 client
5195 .conn
5196 .execute_batch("DROP TRIGGER fail_pending")
5197 .unwrap();
5198 client
5199 .apply_commit_frame(&["tasks".into()], &[change], None)
5200 .unwrap();
5201 assert_eq!(client.local_revision(), revision + 1);
5202 assert_eq!(client.pending_commit_ids(), vec![id]);
5203 let mut expected = before[0].clone();
5206 expected.insert("_syncular_version".to_owned(), Value::from(2));
5207 assert_eq!(
5208 client.query("SELECT * FROM tasks", &[]).unwrap(),
5209 vec![expected]
5210 );
5211 assert_eq!(
5212 client
5213 .conn
5214 .query_row("SELECT project_id FROM _syncular_base_tasks", [], |row| row
5215 .get::<_, String>(0))
5216 .unwrap(),
5217 "remote"
5218 );
5219 }
5220 }
5221
5222 #[test]
5223 fn appended_overlay_matches_full_fifo_replay_with_constraints_and_fts() {
5224 let schema = json!({"version": 1, "tables": [{"name": "tasks", "primaryKey": "id",
5225 "columns": [
5226 {"name":"id", "type":"string", "nullable":false},
5227 {"name":"project_id", "type":"string", "nullable":false},
5228 {"name":"title", "type":"string", "nullable":false}],
5229 "scopes": [{"pattern":"project:{project_id}"}],
5230 "indexes": [{"name":"unique_title", "columns":["title"], "unique":true}],
5231 "ftsIndexes": [{"name":"tasks_fts", "columns":["title"], "tokenize":"unicode61"}]}]});
5232 let mut incremental =
5233 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5234 let mut reference =
5235 SyncClient::new("append".to_owned(), &schema, ClientLimits::default()).unwrap();
5236 for instance in [&mut incremental, &mut reference] {
5237 for index in 0..16 {
5238 instance
5239 .write_base_row(
5240 "tasks",
5241 &vec![
5242 Some(ColumnValue::String(format!("task{index}"))),
5243 Some(ColumnValue::String("p0".to_owned())),
5244 Some(ColumnValue::String(format!("base{index}"))),
5245 ],
5246 1,
5247 )
5248 .unwrap();
5249 }
5250 instance
5251 .rebuild_overlay_if_dirty()
5252 .expect("rebuild overlay");
5253 instance.overlay_rebuild_count.set(0);
5254 }
5255 for index in 0..100 {
5256 let mutations = (0..1 + index % 4)
5257 .map(|offset| {
5258 let id = format!("task{}", (index + offset) % 24);
5259 if (index + offset) % 7 == 0 {
5260 Mutation::Delete {
5261 table: "tasks".to_owned(),
5262 row_id: id,
5263 base_version: None,
5264 }
5265 } else {
5266 Mutation::Upsert {
5267 table: "tasks".to_owned(),
5268 values: Map::from_iter([
5269 ("id".to_owned(), Value::from(id)),
5270 (
5271 "project_id".to_owned(),
5272 Value::from(format!("p{}", index % 2)),
5273 ),
5274 (
5275 "title".to_owned(),
5276 Value::from(format!("needle{}", (index + offset) % 24)),
5277 ),
5278 ]),
5279 base_version: None,
5280 }
5281 }
5282 })
5283 .collect::<Vec<_>>();
5284 reference.overlay_dirty.set(true);
5285 incremental.mutate(mutations.clone()).unwrap();
5286 reference.mutate(mutations).unwrap();
5287 for sql in ["SELECT * FROM tasks ORDER BY id", "SELECT _syncular_source_id, title FROM tasks_fts ORDER BY _syncular_source_id",
5288 "SELECT _syncular_source_id FROM tasks_fts WHERE tasks_fts MATCH 'needle1' ORDER BY _syncular_source_id"] {
5289 assert_eq!(incremental.query(sql, &[]).unwrap(), reference.query(sql, &[]).unwrap(), "commit {index}: {sql}");
5290 }
5291 assert_eq!(incremental.local_revision(), reference.local_revision());
5292 assert_eq!(
5293 serde_json::to_value(incremental.drain_change_batches()).unwrap(),
5294 serde_json::to_value(reference.drain_change_batches()).unwrap()
5295 );
5296 }
5297 assert_eq!(incremental.outbox.len(), 100);
5298 assert_eq!(incremental.overlay_rebuild_count.get(), 0);
5299 assert_eq!(reference.overlay_rebuild_count.get(), 100);
5300 }
5301
5302 #[test]
5303 fn failed_append_rolls_back_durable_queue_visible_rows_and_memory() {
5304 for fail_at in ["outbox", "revision"] {
5305 let mut client = client();
5306 let values = Map::from_iter([
5307 ("id".to_owned(), Value::from("t1")),
5308 ("project_id".to_owned(), Value::from("p1")),
5309 ]);
5310 let mutation = Mutation::Upsert {
5311 table: "tasks".to_owned(),
5312 values,
5313 base_version: None,
5314 };
5315 let trigger = if fail_at == "outbox" {
5316 "CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_outbox BEGIN SELECT RAISE(FAIL, 'injected outbox failure'); END".to_owned()
5317 } else {
5318 format!("CREATE TRIGGER fail_append BEFORE INSERT ON _syncular_meta WHEN NEW.key = '{LOCAL_REVISION_KEY}' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END")
5319 };
5320 client.conn.execute_batch(&trigger).unwrap();
5321 let revision = client.local_revision();
5322 assert!(client.mutate(vec![mutation.clone()]).is_err(), "{fail_at}");
5323 assert_eq!(client.local_revision(), revision);
5324 assert!(client.outbox.is_empty());
5325 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5326 assert_eq!(
5327 client
5328 .conn
5329 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5330 .get::<_, i64>(0))
5331 .unwrap(),
5332 0
5333 );
5334 assert!(client.drain_change_batches().is_empty());
5335 assert!(!client.overlay_dirty.get());
5336 client
5337 .conn
5338 .execute_batch("DROP TRIGGER fail_append")
5339 .unwrap();
5340 client.mutate(vec![mutation]).unwrap();
5341 assert_eq!(client.outbox.len(), 1);
5342 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap().len(), 1);
5343 }
5344 }
5345
5346 #[test]
5347 fn mirrored_commit_failure_rolls_back_base_visible_and_revision() {
5348 let mut client = client();
5349 let tables = vec!["tasks".to_owned(), "missing".to_owned()];
5350 let values = Map::from_iter([
5351 ("id".to_owned(), Value::from("t1")),
5352 ("project_id".to_owned(), Value::from("p1")),
5353 ]);
5354 let payload = encode_row_json(
5355 client.schema.table("tasks").unwrap(),
5356 "t1",
5357 &values,
5358 &client.encryption,
5359 )
5360 .unwrap();
5361 let first = ssp2::model::Change {
5362 table_index: 0,
5363 row_id: "t1".to_owned(),
5364 op: Op::Upsert,
5365 row_version: Some(1),
5366 scopes: vec![("project_id".to_owned(), "p1".to_owned())],
5367 row: Some(payload),
5368 };
5369 let mut invalid = first.clone();
5370 invalid.table_index = 1;
5371 let revision = client.local_revision();
5372 client.begin_observation("failed_mirror").unwrap();
5373 assert!(client
5374 .apply_commit_changes(&tables, &[first, invalid])
5375 .is_err());
5376 client.rollback_observation("failed_mirror");
5377 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
5378 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
5379 assert_eq!(
5380 client
5381 .conn
5382 .query_row("SELECT count(*) FROM _syncular_base_tasks", [], |row| row
5383 .get::<_, i64>(
5384 0
5385 ))
5386 .unwrap(),
5387 0
5388 );
5389 assert_eq!(client.local_revision(), revision);
5390 assert!(client.drain_change_batches().is_empty());
5391 }
5392
5393 #[test]
5394 fn successful_acknowledgement_runs_are_atomic_and_preserve_completed_runs() {
5395 for failure in ["journal", "revision", "commit", "later-frame"] {
5396 let mut client = client();
5397 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5398 for id in ["first", "second"] {
5399 client
5400 .mutate(vec![Mutation::Upsert {
5401 table: "tasks".to_owned(),
5402 values: Map::from_iter([
5403 ("id".to_owned(), Value::from(id)),
5404 ("project_id".to_owned(), Value::from("p1")),
5405 ]),
5406 base_version: None,
5407 }])
5408 .unwrap();
5409 }
5410 let (_, meta) = client.build_request(false).unwrap();
5411 client
5413 .mutate(vec![Mutation::Upsert {
5414 table: "tasks".to_owned(),
5415 values: Map::from_iter([
5416 ("id".to_owned(), Value::from("later")),
5417 ("project_id".to_owned(), Value::from("p1")),
5418 ]),
5419 base_version: None,
5420 }])
5421 .unwrap();
5422 let ids = client.pending_commit_ids();
5423 let rows = client
5424 .query("SELECT * FROM tasks ORDER BY id", &[])
5425 .unwrap();
5426 let revision = client.local_revision();
5427 client.drain_change_batches();
5428 if failure != "later-frame" {
5429 client.conn.execute_batch(match failure {
5430 "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",
5431 "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",
5432 _ => "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",
5433 }).unwrap();
5434 }
5435 let mut response = Message {
5436 wire_version: WIRE_VERSION,
5437 msg_kind: MsgKind::Response,
5438 frames: vec![Frame::RespHeader {
5439 required_schema_version: None,
5440 latest_schema_version: None,
5441 log_epoch: Some("epoch-1".to_owned()),
5442 reset_required: Some(false),
5443 }],
5444 };
5445 for id in [&ids[0], &ids[0], "unknown", &ids[1], &ids[2]] {
5446 response.frames.push(Frame::PushResult {
5447 client_commit_id: id.to_owned(),
5448 status: PushStatus::Applied,
5449 commit_seq: Some(1),
5450 results: vec![OpResult::Applied { op_index: 0 }],
5451 });
5452 }
5453 if failure == "later-frame" {
5454 response.frames.push(Frame::Error {
5455 code: "sync.invalid_request".to_owned(),
5456 message: "later error".to_owned(),
5457 category: "protocol".to_owned(),
5458 retryable: false,
5459 recommended_action: "retry".to_owned(),
5460 details: None,
5461 });
5462 }
5463 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5464 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5465 let expected = match failure {
5468 "later-frame" => "sync.invalid_request",
5469 "commit" => "storage.failed",
5470 _ => "client.outcome_persistence_failed",
5471 };
5472 assert!(
5473 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5474 "{failure}: {outcome:?}"
5475 );
5476 if failure == "later-frame" {
5477 assert_eq!(client.pending_commit_ids(), ids[2..]);
5478 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5479 assert!(client.commit_outcome(&ids[1]).unwrap().is_some());
5480 let batches = client.drain_change_batches();
5481 assert_eq!(batches.len(), 1);
5482 assert_eq!(batches[0].status.as_ref().unwrap().outbox, 1);
5483 assert_eq!(client.local_revision(), revision + 1);
5484 response.frames.pop();
5485 } else {
5486 assert_eq!(client.pending_commit_ids(), ids);
5487 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5488 assert!(client.commit_outcome(&ids[1]).unwrap().is_none());
5489 assert_eq!(client.local_revision(), revision);
5490 assert!(client.drain_change_batches().is_empty());
5491 assert_eq!(
5492 client
5493 .query("SELECT * FROM tasks ORDER BY id", &[])
5494 .unwrap(),
5495 rows
5496 );
5497 client.conn.execute_batch("DROP TRIGGER fail_run").unwrap();
5498 }
5499 for frame in &mut response.frames {
5500 if let Frame::PushResult { status, .. } = frame {
5501 *status = PushStatus::Cached;
5502 }
5503 }
5504 let SyncOutcome::Ok(report) = client.process_response(&mut transport, response, &meta)
5505 else {
5506 panic!("retry failed");
5507 };
5508 assert_eq!(
5509 report.applied,
5510 if failure == "later-frame" {
5511 vec![]
5512 } else {
5513 ids[..2].to_vec()
5514 }
5515 );
5516 assert_eq!(client.pending_commit_ids(), ids[2..]);
5517 let batches = client.drain_change_batches();
5518 assert_eq!(batches.len(), usize::from(failure != "later-frame"));
5519 if let Some(batch) = batches.first() {
5520 assert_eq!(batch.status.as_ref().unwrap().outbox, 1);
5521 }
5522 assert_eq!(client.local_revision(), revision + 1);
5523 }
5524 }
5525
5526 #[test]
5527 fn failed_push_result_preserves_memory_disk_and_retry_identity() {
5528 for failure in ["revision", "journal", "commit"] {
5529 for status in ["applied", "cached", "rejected", "conflict"] {
5530 let mut client = client();
5531 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5532 for id in ["first", "later"] {
5533 client
5534 .mutate(vec![Mutation::Upsert {
5535 table: "tasks".to_owned(),
5536 values: Map::from_iter([
5537 ("id".to_owned(), Value::from(id)),
5538 ("project_id".to_owned(), Value::from("p1")),
5539 ]),
5540 base_version: None,
5541 }])
5542 .unwrap();
5543 }
5544 let (_, meta) = client.build_request(false).unwrap();
5545 let ids = client.pending_commit_ids();
5546 let rows = client
5547 .query("SELECT * FROM tasks ORDER BY id", &[])
5548 .unwrap();
5549 let revision = client.local_revision();
5550 client.drain_change_batches();
5551 let (push_status, results) = match status {
5552 "applied" => (PushStatus::Applied, vec![OpResult::Applied { op_index: 0 }]),
5553 "cached" => (PushStatus::Cached, vec![OpResult::Applied { op_index: 0 }]),
5554 "conflict" => (
5555 PushStatus::Rejected,
5556 vec![OpResult::Conflict {
5557 op_index: 0,
5558 code: "sync.version_conflict".to_owned(),
5559 message: "conflict".to_owned(),
5560 server_version: 1,
5561 server_row: encode_row_json(
5562 client.schema.table("tasks").unwrap(),
5563 "first",
5564 &Map::from_iter([
5565 ("id".to_owned(), Value::from("first")),
5566 ("project_id".to_owned(), Value::from("p1")),
5567 ]),
5568 &client.encryption,
5569 )
5570 .unwrap(),
5571 conflict_columns: Vec::new(),
5572 }],
5573 ),
5574 _ => (
5575 PushStatus::Rejected,
5576 vec![OpResult::Error {
5577 op_index: 0,
5578 code: "sync.validation_failed".to_owned(),
5579 message: "rejected".to_owned(),
5580 retryable: false,
5581 }],
5582 ),
5583 };
5584 let response = Message {
5585 wire_version: WIRE_VERSION,
5586 msg_kind: MsgKind::Response,
5587 frames: vec![
5588 Frame::RespHeader {
5589 required_schema_version: None,
5590 latest_schema_version: None,
5591 log_epoch: Some("epoch-1".to_owned()),
5592 reset_required: Some(false),
5593 },
5594 Frame::PushResult {
5595 client_commit_id: ids[0].clone(),
5596 status: push_status,
5597 commit_seq: Some(1),
5598 results,
5599 },
5600 ],
5601 };
5602 client.conn.execute_batch(match failure {
5603 "journal" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(FAIL, 'injected journal failure'); END",
5604 "revision" => "CREATE TRIGGER fail_ack BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(FAIL, 'injected revision failure'); END",
5605 _ => "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",
5606 }).unwrap();
5607 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
5608 let outcome = client.process_response(&mut transport, response.clone(), &meta);
5609 assert_eq!(
5610 client.pending_commit_ids(),
5611 ids,
5612 "{failure}/{status}: pending identity"
5613 );
5614 assert_eq!(
5615 client.local_revision(),
5616 revision,
5617 "{failure}/{status}: revision"
5618 );
5619 assert!(client.commit_outcome(&ids[0]).unwrap().is_none());
5620 assert!(client.conflicts().is_empty());
5621 assert!(client.rejections().is_empty());
5622 assert!(client.drain_change_batches().is_empty());
5623 assert_eq!(
5624 client
5625 .query("SELECT * FROM tasks ORDER BY id", &[])
5626 .unwrap(),
5627 rows
5628 );
5629 let expected = if failure == "commit" {
5632 "storage.failed"
5633 } else {
5634 "client.outcome_persistence_failed"
5635 };
5636 assert!(
5637 matches!(&outcome, SyncOutcome::Failed { error_code, .. } if error_code == expected),
5638 "{failure}/{status}: failed round"
5639 );
5640 client.conn.execute_batch("DROP TRIGGER fail_ack").unwrap();
5641 assert!(matches!(
5642 client.process_response(&mut transport, response, &meta),
5643 SyncOutcome::Ok(_)
5644 ));
5645 assert_eq!(client.pending_commit_ids(), ids[1..]);
5646 assert!(client.commit_outcome(&ids[0]).unwrap().is_some());
5647 assert_eq!(client.drain_change_batches().len(), 1);
5648 }
5649 }
5650 }
5651
5652 #[test]
5653 fn mixed_push_results_reconcile_each_boundary_and_prune_once_per_response() {
5654 let mut client = client();
5655 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5656 for index in 0..4 {
5657 client
5658 .mutate(vec![Mutation::Upsert {
5659 table: "tasks".to_owned(),
5660 values: Map::from_iter([
5661 ("id".to_owned(), Value::from(format!("task-{index}"))),
5662 ("project_id".to_owned(), Value::from("project-1")),
5663 ]),
5664 base_version: None,
5665 }])
5666 .expect("queue commit");
5667 }
5668
5669 let (_, request_meta) = client.build_request(false).unwrap();
5670 let ids = &request_meta.pushed_ids;
5671 assert_eq!(ids.len(), 4);
5672 let response = Message {
5673 wire_version: WIRE_VERSION,
5674 msg_kind: MsgKind::Response,
5675 frames: vec![
5676 Frame::RespHeader {
5677 required_schema_version: None,
5678 latest_schema_version: None,
5679 log_epoch: Some("epoch-1".to_owned()),
5680 reset_required: Some(false),
5681 },
5682 Frame::PushResult {
5683 client_commit_id: ids[0].clone(),
5684 status: PushStatus::Applied,
5685 commit_seq: Some(1),
5686 results: vec![OpResult::Applied { op_index: 0 }],
5687 },
5688 Frame::PushResult {
5689 client_commit_id: ids[1].clone(),
5690 status: PushStatus::Cached,
5691 commit_seq: Some(2),
5692 results: vec![OpResult::Applied { op_index: 0 }],
5693 },
5694 Frame::PushResult {
5695 client_commit_id: ids[2].clone(),
5696 status: PushStatus::Rejected,
5697 commit_seq: None,
5698 results: vec![OpResult::Error {
5699 op_index: 0,
5700 code: "sync.validation_failed".to_owned(),
5701 message: "rejected".to_owned(),
5702 retryable: false,
5703 }],
5704 },
5705 Frame::PushResult {
5706 client_commit_id: ids[3].clone(),
5707 status: PushStatus::Rejected,
5708 commit_seq: None,
5709 results: vec![OpResult::Error {
5710 op_index: 0,
5711 code: "sync.idempotency_cache_miss".to_owned(),
5712 message: "retry".to_owned(),
5713 retryable: true,
5714 }],
5715 },
5716 ],
5717 };
5718 let mut transport =
5719 HostTransport::new_from_config(&json!({})).expect("no-network host transport");
5720
5721 client.overlay_rebuild_count.set(0);
5722 client.outcome_prune_count.set(0);
5723 let outcome = client.process_response(&mut transport, response, &request_meta);
5724 let SyncOutcome::Ok(report) = outcome else {
5725 panic!("mixed push-result response failed");
5726 };
5727
5728 assert_eq!(report.applied, ids[..2]);
5729 assert_eq!(report.rejected, ids[2..3]);
5730 assert_eq!(report.retryable, ids[3..4]);
5731 assert_eq!(client.pending_commit_ids(), ids[3..4]);
5732 assert_eq!(
5733 client
5734 .query("SELECT id FROM tasks ORDER BY id", &[])
5735 .expect("query visible overlay"),
5736 ["task-0", "task-1", "task-3"]
5737 .map(|id| Map::from_iter([("id".to_owned(), Value::from(id))]))
5738 .to_vec()
5739 );
5740 assert_eq!(client.overlay_rebuild_count.get(), 2);
5742 assert_eq!(client.outcome_prune_count.get(), 1);
5743 }
5744
5745 fn replay_schema(fts: bool) -> Value {
5746 json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
5747 {"name":"id","type":"string","nullable":false},
5748 {"name":"project_id","type":"string","nullable":false},
5749 {"name":"title","type":"string","nullable":false},
5750 {"name":"done","type":"boolean","nullable":false}
5751 ],"scopes":[{"pattern":"project:{project_id}"}], "ftsIndexes": if fts { json!([{"name":"tasks_fts","columns":["title"],"tokenize":"unicode61"}]) } else { json!([]) }}]})
5752 }
5753
5754 #[test]
5755 fn aborted_schema_and_epoch_resets_restore_memory_and_durable_intent() {
5756 for schema_reset in [false, true] {
5757 let mut schema = replay_schema(false);
5758 let mut client =
5759 SyncClient::new("reset-failure".into(), &schema, ClientLimits::default()).unwrap();
5760 let id = client
5761 .mutate(vec![Mutation::Upsert {
5762 table: "tasks".into(),
5763 values: Map::from_iter([
5764 ("id".into(), json!("queued")),
5765 ("project_id".into(), json!("p1")),
5766 ("title".into(), json!("old")),
5767 ("done".into(), json!(false)),
5768 ]),
5769 base_version: None,
5770 }])
5771 .unwrap();
5772 client.set_meta(LOG_EPOCH_KEY, "old");
5773 let revision = client.local_revision();
5774 let needed = client.sync_needed;
5775 client.drain_change_batches();
5776 client.drain_sync_intents();
5777 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();
5778 if schema_reset {
5779 schema["version"] = json!(2);
5780 schema["tables"][0]["columns"]
5781 .as_array_mut()
5782 .unwrap()
5783 .retain(|column| column["name"] != "title");
5784 client.schema = parse_schema_json(&schema).unwrap();
5785 assert!(client.run_schema_reset().is_err());
5786 } else {
5787 assert!(client.run_log_epoch_reset("new").is_err());
5788 }
5789 assert!(!client.upgrading);
5790 assert_eq!(client.sync_needed, needed);
5791 assert_eq!(client.local_revision(), revision);
5792 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
5793 assert!(client.rejections.is_empty());
5794 assert!(client.commit_outcome(&id).unwrap().is_none());
5795 assert_eq!(client.get_meta(LOG_EPOCH_KEY).as_deref(), Some("old"));
5796 assert_eq!(
5797 client
5798 .query("SELECT title FROM tasks WHERE id='queued'", &[])
5799 .unwrap()[0]["title"],
5800 "old"
5801 );
5802 assert_eq!(
5803 client
5804 .conn
5805 .query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5806 .get::<_, i64>(0))
5807 .unwrap(),
5808 1
5809 );
5810 assert!(client.drain_change_batches().is_empty());
5811 assert!(client.drain_sync_intents().is_empty());
5812 assert!(client.conn.is_autocommit());
5813 }
5814 }
5815
5816 #[test]
5817 fn cached_blob_cleanup_propagates_delete_failures() {
5818 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
5819 {"name":"id","type":"string","nullable":false},
5820 {"name":"file","type":"blob_ref","nullable":true}],"scopes":[]}]});
5821 let mut client =
5822 SyncClient::new("blob-cleanup".into(), &schema, ClientLimits::default()).unwrap();
5823 client.conn.execute_batch("CREATE TRIGGER refuse_blob_cleanup BEFORE DELETE ON _syncular_blobs BEGIN SELECT RAISE(ABORT,'blob cleanup failed'); END;").unwrap();
5824 client.conn.execute("INSERT INTO _syncular_blobs(blob_id,bytes,byte_length,created_at_ms) VALUES ('test',X'01',1,0)", []).unwrap();
5825 client
5826 .subscribe("attachments".into(), "attachments".into(), vec![], None)
5827 .unwrap();
5828 client.subs[0].effective = Some(vec![]);
5829 client.persist_sub(&client.subs[0]).unwrap();
5830 let revision = client.local_revision();
5831 let (_, meta) = client.build_request(false).unwrap();
5832 let mut transport = CountingRealtimeTransport::default();
5833 let mut report = SyncReport::default();
5834 assert!(client
5835 .process_section(
5836 &mut transport,
5837 "attachments",
5838 SubStatus::Revoked,
5839 "sync.scope_revoked",
5840 vec![],
5841 vec![],
5842 Some((0, None)),
5843 &meta,
5844 &mut report,
5845 &HashSet::new()
5846 )
5847 .is_err());
5848 assert_eq!(client.subs[0].state, SubState::Active);
5849 assert_eq!(client.local_revision(), revision);
5850 assert!(report.revoked.is_empty());
5851 assert!(client.conn.is_autocommit());
5852 client
5853 .conn
5854 .execute_batch("DROP TRIGGER refuse_blob_cleanup")
5855 .unwrap();
5856 assert!(client
5857 .process_section(
5858 &mut transport,
5859 "attachments",
5860 SubStatus::Revoked,
5861 "sync.scope_revoked",
5862 vec![],
5863 vec![],
5864 Some((0, None)),
5865 &meta,
5866 &mut report,
5867 &HashSet::new()
5868 )
5869 .is_ok());
5870 assert_eq!(client.subs[0].state, SubState::Revoked);
5871 assert_eq!(
5872 client
5873 .conn
5874 .query_row("SELECT count(*) FROM _syncular_blobs", [], |row| row
5875 .get::<_, i64>(0))
5876 .unwrap(),
5877 0
5878 );
5879 }
5880
5881 #[test]
5882 fn overlay_replay_trigger_refuses_reopen_and_preserves_durable_intent() {
5883 let path =
5884 std::env::temp_dir().join(format!("syncular-replay-{}.db", uuid::Uuid::new_v4()));
5885 let schema = replay_schema(false);
5886 let mut client = SyncClient::open_path(
5887 "replay".into(),
5888 &schema,
5889 ClientLimits::default(),
5890 path.to_str().unwrap(),
5891 )
5892 .unwrap();
5893 let ids = ["early", "bad"].map(|id| {
5894 client
5895 .mutate(vec![Mutation::Upsert {
5896 table: "tasks".into(),
5897 values: Map::from_iter([
5898 ("id".into(), json!(id)),
5899 ("project_id".into(), json!("p1")),
5900 ("title".into(), json!(id)),
5901 ("done".into(), json!(false)),
5902 ]),
5903 base_version: None,
5904 }])
5905 .unwrap()
5906 });
5907 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();
5908 let revision = client.local_revision();
5909 drop(client);
5910 let error = SyncClient::open_path(
5911 "replay".into(),
5912 &schema,
5913 ClientLimits::default(),
5914 path.to_str().unwrap(),
5915 )
5916 .err()
5917 .expect("reopen must fail");
5918 assert!(error.contains("replay failed"));
5919 let conn = Connection::open(&path).unwrap();
5920 assert_eq!(
5921 conn.query_row("SELECT count(*) FROM tasks", [], |row| row.get::<_, i64>(0))
5922 .unwrap(),
5923 0
5924 );
5925 assert_eq!(
5926 conn.query_row("SELECT count(*) FROM _syncular_outbox", [], |row| row
5927 .get::<_, i64>(0))
5928 .unwrap(),
5929 2
5930 );
5931 assert_eq!(
5932 meta_get(&conn, LOCAL_REVISION_KEY),
5933 Some(revision.to_string())
5934 );
5935 conn.execute_batch("DROP TRIGGER fail_replay").unwrap();
5936 drop(conn);
5937 let reopened = SyncClient::open_path(
5938 "replay".into(),
5939 &schema,
5940 ClientLimits::default(),
5941 path.to_str().unwrap(),
5942 )
5943 .unwrap();
5944 assert_eq!(reopened.pending_commit_ids(), ids);
5945 assert_eq!(
5946 reopened
5947 .query("SELECT id FROM tasks ORDER BY id", &[])
5948 .unwrap()
5949 .len(),
5950 2
5951 );
5952 drop(reopened);
5953 std::fs::remove_file(path).unwrap();
5954 }
5955
5956 #[test]
5957 fn overlay_replay_failure_rolls_back_push_and_pull_apply_boundaries() {
5958 let schema = replay_schema(false);
5959 let mut client =
5960 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
5961 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
5962 client
5963 .write_base_row(
5964 "tasks",
5965 &vec![
5966 Some(ColumnValue::String("queued".into())),
5967 Some(ColumnValue::String("p1".into())),
5968 Some(ColumnValue::String("server".into())),
5969 Some(ColumnValue::Boolean(false)),
5970 ],
5971 1,
5972 )
5973 .unwrap();
5974 client.rebuild_overlay().unwrap();
5975 let id = client
5976 .patch(
5977 "tasks",
5978 "queued",
5979 Map::from_iter([("title".into(), json!("local"))]),
5980 Some(1),
5981 )
5982 .unwrap();
5983 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
5984 let base = client
5985 .query("SELECT * FROM _syncular_base_tasks", &[])
5986 .unwrap();
5987 let revision = client.local_revision();
5988 client.drain_change_batches();
5989 client.conn.execute_batch("CREATE TRIGGER fail_replay BEFORE INSERT ON tasks WHEN new.title='local' BEGIN SELECT RAISE(ABORT,'replay failed'); END").unwrap();
5990 let remote = vec![
5991 Some(ColumnValue::String("queued".into())),
5992 Some(ColumnValue::String("p1".into())),
5993 Some(ColumnValue::String("remote".into())),
5994 Some(ColumnValue::Boolean(false)),
5995 ];
5996 let bytes = crate::values::encode_row_json(
5997 client.schema.table("tasks").unwrap(),
5998 "queued",
5999 &Map::from_iter(
6000 client
6001 .schema
6002 .table("tasks")
6003 .unwrap()
6004 .columns
6005 .iter()
6006 .zip(&remote)
6007 .map(|(column, value)| (column.name.clone(), column_value_to_json(value))),
6008 ),
6009 &client.encryption,
6010 )
6011 .unwrap();
6012 let change = ssp2::model::Change {
6013 table_index: 0,
6014 op: Op::Upsert,
6015 row_id: "queued".into(),
6016 row_version: Some(2),
6017 scopes: vec![("project_id".into(), "p1".into())],
6018 row: Some(bytes),
6019 };
6020 assert!(client
6021 .apply_commit_frame(&["tasks".into()], std::slice::from_ref(&change), Some(2))
6022 .is_err());
6023 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6024 assert_eq!(
6025 client
6026 .query("SELECT * FROM _syncular_base_tasks", &[])
6027 .unwrap(),
6028 base
6029 );
6030 assert_eq!(client.local_revision(), revision);
6031 assert!(client.drain_change_batches().is_empty());
6032 let response = Message {
6033 wire_version: 2,
6034 msg_kind: MsgKind::Response,
6035 frames: vec![
6036 Frame::RespHeader {
6037 required_schema_version: None,
6038 latest_schema_version: None,
6039 log_epoch: Some("epoch-1".into()),
6040 reset_required: Some(false),
6041 },
6042 Frame::PushResult {
6043 client_commit_id: id.clone(),
6044 status: PushStatus::Applied,
6045 commit_seq: Some(2),
6046 results: vec![OpResult::Applied { op_index: 0 }],
6047 },
6048 ],
6049 };
6050 let (_, meta) = client.build_request(false).unwrap();
6051 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
6052 assert!(
6053 matches!(client.process_response(&mut transport,response.clone(),&meta),SyncOutcome::Failed { error_code,.. } if error_code=="client.outcome_persistence_failed")
6054 );
6055 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6056 assert!(client.commit_outcome(&id).unwrap().is_none());
6057 assert_eq!(
6058 client
6059 .conn
6060 .query_row("SELECT count(*) FROM _syncular_acked_rows", [], |row| row
6061 .get::<_, i64>(
6062 0
6063 ))
6064 .unwrap(),
6065 0
6066 );
6067 assert_eq!(
6068 client
6069 .conn
6070 .query_row("SELECT count(*) FROM _syncular_row_deliveries", [], |row| {
6071 row.get::<_, i64>(0)
6072 })
6073 .unwrap(),
6074 0
6075 );
6076 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
6077 assert_eq!(client.local_revision(), revision);
6078 assert!(client.drain_change_batches().is_empty());
6079 client
6080 .conn
6081 .execute_batch("DROP TRIGGER fail_replay")
6082 .unwrap();
6083 assert!(matches!(
6084 client.process_response(&mut transport, response, &meta),
6085 SyncOutcome::Ok(_)
6086 ));
6087 client
6088 .apply_commit_frame(&["tasks".into()], &[change], Some(2))
6089 .unwrap();
6090 assert!(client.pending_commit_ids().is_empty());
6091 assert_eq!(
6092 client.query("SELECT title FROM tasks", &[]).unwrap()[0]["title"],
6093 json!("remote")
6094 );
6095 }
6096
6097 #[test]
6098 fn overlay_replay_distinguishes_absence_from_sql_and_value_decode_errors() {
6099 let client = SyncClient::new(
6100 "replay".into(),
6101 &replay_schema(false),
6102 ClientLimits::default(),
6103 )
6104 .unwrap();
6105 let table = client.schema.table("tasks").unwrap();
6106 let patch = OutboxOp {
6107 table: "tasks".into(),
6108 row_id: "absent".into(),
6109 upsert: true,
6110 insert: false,
6111 base_version: Some(1),
6112 values: Some(Map::from_iter([
6113 ("id".into(), json!("absent")),
6114 ("title".into(), json!("patch")),
6115 ])),
6116 };
6117 assert!(client.visible_row(table, "absent").unwrap().is_none());
6118 client
6119 .apply_outbox_ops([&patch])
6120 .expect("sparse replay over truly absent row");
6121 client
6122 .conn
6123 .execute_batch("INSERT INTO tasks VALUES ('bad','p1','title','invalid',1)")
6124 .unwrap();
6125 assert!(client
6126 .visible_row(table, "bad")
6127 .unwrap_err()
6128 .starts_with("sync.local_corrupt:"));
6129 let mut invalid = patch.clone();
6130 invalid.values = Some(Map::from_iter([
6131 ("id".into(), json!("bad-value")),
6132 ("project_id".into(), json!("p1")),
6133 ("title".into(), json!("title")),
6134 ("done".into(), json!({"bad":true})),
6135 ]));
6136 assert!(client
6137 .apply_outbox_ops([&invalid])
6138 .unwrap_err()
6139 .starts_with("sync.local_corrupt:"));
6140 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6141 assert!(
6142 client.apply_outbox_ops([&patch]).is_err(),
6143 "SQL failure must not count as absence"
6144 );
6145 }
6146
6147 #[test]
6148 fn overlay_replay_keeps_secondary_unique_deferral_and_lazy_base_precedence() {
6149 let mut schema = replay_schema(false);
6150 schema["tables"][0]["indexes"] =
6151 json!([{"name":"tasks_project_title", "columns":["project_id","title"],"unique":true}]);
6152 let mut client =
6153 SyncClient::new("replay".into(), &schema, ClientLimits::default()).unwrap();
6154 let id = client
6155 .mutate(vec![Mutation::Upsert {
6156 table: "tasks".into(),
6157 values: Map::from_iter([
6158 ("id".into(), json!("queued")),
6159 ("project_id".into(), json!("p1")),
6160 ("title".into(), json!("occupied")),
6161 ("done".into(), json!(false)),
6162 ]),
6163 base_version: None,
6164 }])
6165 .unwrap();
6166 client
6167 .write_base_row(
6168 "tasks",
6169 &vec![
6170 Some(ColumnValue::String("server".into())),
6171 Some(ColumnValue::String("p1".into())),
6172 Some(ColumnValue::String("occupied".into())),
6173 Some(ColumnValue::Boolean(false)),
6174 ],
6175 1,
6176 )
6177 .unwrap();
6178 client.rebuild_overlay_if_dirty().unwrap();
6179 assert_eq!(client.pending_commit_ids(), vec![id.clone()]);
6180 assert!(client
6181 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6182 .unwrap()
6183 .is_none());
6184 assert!(client.rejections().is_empty());
6185 client.delete_base_row("tasks", "server").unwrap();
6186 client.rebuild_overlay_if_dirty().unwrap();
6187 assert!(client
6188 .visible_row(client.schema.table("tasks").unwrap(), "queued")
6189 .unwrap()
6190 .is_some());
6191 client
6192 .write_base_row(
6193 "tasks",
6194 &vec![
6195 Some(ColumnValue::String("base".into())),
6196 Some(ColumnValue::String("p1".into())),
6197 Some(ColumnValue::String("base-title".into())),
6198 Some(ColumnValue::Boolean(false)),
6199 ],
6200 1,
6201 )
6202 .unwrap();
6203 client.conn.execute_batch("DROP TABLE tasks").unwrap();
6204 client
6205 .retain_failed_commit(
6206 "retained",
6207 &[OutboxOp {
6208 table: "tasks".into(),
6209 row_id: "base".into(),
6210 upsert: true,
6211 insert: false,
6212 base_version: Some(1),
6213 values: Some(Map::from_iter([
6214 ("id".into(), json!("base")),
6215 ("title".into(), json!("patch")),
6216 ])),
6217 }],
6218 )
6219 .expect("an existing base must not read the unavailable visible table");
6220 }
6221
6222 #[test]
6223 fn overlay_replay_fts_and_savepoint_release_failures_roll_back_all_visible_changes() {
6224 for failure in ["fts", "release"] {
6225 let mut client = SyncClient::new(
6226 "replay".into(),
6227 &replay_schema(true),
6228 ClientLimits::default(),
6229 )
6230 .unwrap();
6231 client
6232 .mutate(vec![Mutation::Upsert {
6233 table: "tasks".into(),
6234 values: Map::from_iter([
6235 ("id".into(), json!("queued")),
6236 ("project_id".into(), json!("p1")),
6237 ("title".into(), json!("needle")),
6238 ("done".into(), json!(false)),
6239 ]),
6240 base_version: None,
6241 }])
6242 .unwrap();
6243 let before = client.query("SELECT * FROM tasks", &[]).unwrap();
6244 let fts_before = client
6245 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6246 .unwrap();
6247 let triggers_before: Vec<String> = client
6248 .conn
6249 .prepare("SELECT sql FROM sqlite_master WHERE type='trigger' ORDER BY name")
6250 .unwrap()
6251 .query_map([], |row| row.get(0))
6252 .unwrap()
6253 .collect::<Result<_, _>>()
6254 .unwrap();
6255 if failure == "fts" {
6256 client.conn.execute_batch("CREATE TRIGGER fail_fts BEFORE INSERT ON _syncular_fts_tasks_fts BEGIN SELECT RAISE(ABORT,'fts replay failed'); END;").unwrap();
6257 } else {
6258 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();
6259 }
6260 client.overlay_dirty.set(true);
6261 assert!(client.rebuild_overlay().is_err());
6262 assert!(client.overlay_dirty.get());
6263 assert!(client.conn.is_autocommit());
6264 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), before);
6265 assert_eq!(
6266 client
6267 .query("SELECT _syncular_source_id,title FROM tasks_fts", &[])
6268 .unwrap(),
6269 fts_before
6270 );
6271 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();
6272 assert_eq!(original_triggers, triggers_before);
6273 assert_eq!(client.pending_commit_ids().len(), 1);
6274 client
6275 .conn
6276 .execute_batch(if failure == "fts" {
6277 "DROP TRIGGER fail_fts"
6278 } else {
6279 "DROP TRIGGER fail_release"
6280 })
6281 .unwrap();
6282 client
6283 .rebuild_overlay()
6284 .expect("retry after removing the fault");
6285 }
6286 }
6287
6288 #[test]
6289 fn secondary_unique_collision_preserves_existing_synced_row() {
6290 let client = SyncClient::new(
6291 "unique-upsert-test".to_owned(),
6292 &json!({
6293 "version": 1,
6294 "tables": [{
6295 "name": "tasks",
6296 "primaryKey": "id",
6297 "columns": [
6298 { "name": "id", "type": "string", "nullable": false },
6299 { "name": "project_id", "type": "string", "nullable": false },
6300 { "name": "title", "type": "string", "nullable": false }
6301 ],
6302 "scopes": [{ "pattern": "project:{project_id}" }],
6303 "indexes": [{
6304 "name": "tasks_by_project_title",
6305 "columns": ["project_id", "title"],
6306 "unique": true
6307 }]
6308 }]
6309 }),
6310 ClientLimits::default(),
6311 )
6312 .expect("test client");
6313 let table = client.schema.table("tasks").expect("tasks table");
6314 let sql = client.insert_row_sql(&base_table("tasks"), table);
6315
6316 client
6317 .conn
6318 .execute(&sql, rusqlite::params!["t1", "p1", "original", 1])
6319 .expect("insert first row");
6320 client
6321 .conn
6322 .execute(&sql, rusqlite::params!["t1", "p1", "updated", 2])
6323 .expect("update same primary key");
6324 client
6325 .conn
6326 .execute(&sql, rusqlite::params!["t2", "p1", "original", 1])
6327 .expect("insert second row");
6328 assert!(client
6329 .conn
6330 .execute(&sql, rusqlite::params!["t3", "p1", "original", 2])
6331 .is_err());
6332
6333 let rows = client
6334 .conn
6335 .prepare("SELECT id, title FROM _syncular_base_tasks ORDER BY id")
6336 .expect("prepare rows")
6337 .query_map([], |row| {
6338 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
6339 })
6340 .expect("query rows")
6341 .collect::<Result<Vec<_>, _>>()
6342 .expect("collect rows");
6343 assert_eq!(
6344 rows,
6345 vec![
6346 ("t1".to_owned(), "updated".to_owned()),
6347 ("t2".to_owned(), "original".to_owned())
6348 ]
6349 );
6350 }
6351
6352 #[test]
6353 fn reopening_active_subscriptions_emits_a_catch_up_intent() {
6354 let path = std::env::temp_dir().join(format!(
6355 "syncular-startup-intent-{}.db",
6356 uuid::Uuid::new_v4()
6357 ));
6358 let schema = json!({
6359 "version": 1,
6360 "tables": [{
6361 "name": "tasks",
6362 "primaryKey": "id",
6363 "columns": [
6364 { "name": "id", "type": "string", "nullable": false },
6365 { "name": "project_id", "type": "string", "nullable": false }
6366 ],
6367 "scopes": [{ "pattern": "project:{project_id}" }]
6368 }]
6369 });
6370
6371 {
6372 let mut first = SyncClient::open_path_with_identity(
6373 None,
6374 &schema,
6375 ClientLimits::default(),
6376 path.to_str().expect("UTF-8 temp path"),
6377 )
6378 .expect("first open");
6379 first
6380 .set_window(
6381 &WindowBase {
6382 table: "tasks".to_owned(),
6383 variable: "project_id".to_owned(),
6384 fixed_scopes: Vec::new(),
6385 params: None,
6386 },
6387 &["persisted".to_owned()],
6388 )
6389 .expect("persist window");
6390 }
6391
6392 let mut reopened = SyncClient::open_path_with_identity(
6393 None,
6394 &schema,
6395 ClientLimits::default(),
6396 path.to_str().expect("UTF-8 temp path"),
6397 )
6398 .expect("reopen");
6399 assert!(reopened.sync_needed());
6400 assert!(matches!(
6401 reopened.drain_sync_intents().as_slice(),
6402 [SyncIntent::Interactive]
6403 ));
6404 drop(reopened);
6405 std::fs::remove_file(path).expect("remove temp database");
6406 }
6407
6408 #[test]
6409 fn reopening_preserves_immutable_subscription_identity_and_progress() {
6410 let path = std::env::temp_dir().join(format!(
6411 "syncular-subscription-identity-{}.db",
6412 uuid::Uuid::new_v4()
6413 ));
6414 let schema = json!({
6415 "version": 1,
6416 "tables": [
6417 {
6418 "name": "tasks",
6419 "primaryKey": "id",
6420 "columns": [
6421 { "name": "id", "type": "string", "nullable": false },
6422 { "name": "project_id", "type": "string", "nullable": false }
6423 ],
6424 "scopes": [{ "pattern": "project:{project_id}" }]
6425 },
6426 {
6427 "name": "docs",
6428 "primaryKey": "id",
6429 "columns": [
6430 { "name": "id", "type": "string", "nullable": false },
6431 { "name": "org_id", "type": "string", "nullable": false },
6432 { "name": "project_id", "type": "string", "nullable": false }
6433 ],
6434 "scopes": [
6435 { "pattern": "org:{org_id}" },
6436 { "pattern": "project:{projectId}", "column": "project_id" }
6437 ]
6438 }
6439 ]
6440 });
6441
6442 {
6443 let mut first = SyncClient::open_path_with_identity(
6444 None,
6445 &schema,
6446 ClientLimits::default(),
6447 path.to_str().expect("UTF-8 temp path"),
6448 )
6449 .expect("first open");
6450 first
6451 .subscribe(
6452 "stable-subscription".to_owned(),
6453 "tasks".to_owned(),
6454 vec![(
6455 "project_id".to_owned(),
6456 vec!["p2".to_owned(), "p1".to_owned()],
6457 )],
6458 Some(r#"{"view":"v1"}"#.to_owned()),
6459 )
6460 .expect("persist subscription");
6461 let persisted = {
6462 let subscription = first
6463 .subs
6464 .iter_mut()
6465 .find(|subscription| subscription.id == "stable-subscription")
6466 .expect("subscription");
6467 subscription.cursor = 41;
6468 subscription.bootstrap_state = Some("resume-token".to_owned());
6469 subscription.effective = Some(vec![(
6470 "project_id".to_owned(),
6471 vec!["p1".to_owned(), "p2".to_owned()],
6472 )]);
6473 subscription.synced_once = true;
6474 subscription.clone()
6475 };
6476 first.persist_sub(&persisted).unwrap();
6477 }
6478
6479 let mut reopened = SyncClient::open_path_with_identity(
6480 None,
6481 &schema,
6482 ClientLimits::default(),
6483 path.to_str().expect("UTF-8 temp path"),
6484 )
6485 .expect("reopen");
6486 let progress = reopened
6487 .subscription_state("stable-subscription")
6488 .expect("persisted state");
6489 assert_eq!(progress.cursor, 41);
6490 assert!(progress.has_resume_token);
6491
6492 reopened
6493 .subscribe(
6494 "stable-subscription".to_owned(),
6495 "tasks".to_owned(),
6496 vec![(
6497 "project_id".to_owned(),
6498 vec!["p1".to_owned(), "p2".to_owned(), "p1".to_owned()],
6499 )],
6500 Some(r#"{"view":"v1"}"#.to_owned()),
6501 )
6502 .expect("canonical intent is idempotent");
6503 assert_eq!(
6504 reopened
6505 .subscription_state("stable-subscription")
6506 .expect("unchanged state")
6507 .cursor,
6508 progress.cursor
6509 );
6510
6511 for (table, scopes, params) in [
6512 (
6513 "tasks",
6514 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6515 Some(r#"{"view":"v1"}"#.to_owned()),
6516 ),
6517 (
6518 "tasks",
6519 vec![(
6520 "project_id".to_owned(),
6521 vec!["p2".to_owned(), "p1".to_owned()],
6522 )],
6523 Some(r#"{"view":"v2"}"#.to_owned()),
6524 ),
6525 (
6526 "docs",
6527 vec![
6528 ("org_id".to_owned(), vec!["o1".to_owned()]),
6529 ("projectId".to_owned(), vec!["p1".to_owned()]),
6530 ],
6531 Some(r#"{"view":"v1"}"#.to_owned()),
6532 ),
6533 ] {
6534 let error = reopened
6535 .subscribe(
6536 "stable-subscription".to_owned(),
6537 table.to_owned(),
6538 scopes,
6539 params,
6540 )
6541 .expect_err("identity rebind must fail");
6542 assert!(error.starts_with("client.subscription_intent_mismatch:"));
6543 assert_eq!(
6544 reopened
6545 .subscription_state("stable-subscription")
6546 .expect("unchanged state")
6547 .cursor,
6548 progress.cursor
6549 );
6550 }
6551
6552 drop(reopened);
6553 std::fs::remove_file(path).expect("remove temp database");
6554 }
6555
6556 #[test]
6557 fn reopening_clears_a_schema_floor_the_running_app_already_satisfies() {
6558 let path = std::env::temp_dir().join(format!(
6559 "syncular-satisfied-schema-floor-{}.db",
6560 uuid::Uuid::new_v4()
6561 ));
6562 let schema = json!({
6563 "version": 23,
6564 "tables": [{
6565 "name": "tasks",
6566 "primaryKey": "id",
6567 "columns": [
6568 { "name": "id", "type": "string", "nullable": false },
6569 { "name": "project_id", "type": "string", "nullable": false }
6570 ],
6571 "scopes": [{ "pattern": "project:{project_id}" }]
6572 }]
6573 });
6574
6575 {
6576 let mut first = SyncClient::open_path_with_identity(
6577 None,
6578 &schema,
6579 ClientLimits::default(),
6580 path.to_str().expect("UTF-8 temp path"),
6581 )
6582 .expect("first open");
6583 first
6584 .subscribe(
6585 "tasks".to_owned(),
6586 "tasks".to_owned(),
6587 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6588 None,
6589 )
6590 .expect("persist subscription");
6591 first.set_schema_floor(Some(SchemaFloor {
6592 required_schema_version: Some(22),
6593 latest_schema_version: Some(22),
6594 }));
6595 }
6596
6597 let mut reopened = SyncClient::open_path_with_identity(
6598 None,
6599 &schema,
6600 ClientLimits::default(),
6601 path.to_str().expect("UTF-8 temp path"),
6602 )
6603 .expect("reopen");
6604 assert!(reopened.schema_floor().is_none());
6605 assert!(reopened.get_meta(SCHEMA_FLOOR_KEY).is_none());
6606 assert!(reopened.sync_needed());
6607 assert!(matches!(
6608 reopened.drain_sync_intents().as_slice(),
6609 [SyncIntent::Interactive]
6610 ));
6611 drop(reopened);
6612 std::fs::remove_file(path).expect("remove temp database");
6613 }
6614
6615 #[test]
6616 fn reopening_keeps_an_unsatisfied_schema_floor_stopped() {
6617 let path = std::env::temp_dir().join(format!(
6618 "syncular-unsatisfied-schema-floor-{}.db",
6619 uuid::Uuid::new_v4()
6620 ));
6621 let schema = json!({
6622 "version": 1,
6623 "tables": [{
6624 "name": "tasks",
6625 "primaryKey": "id",
6626 "columns": [
6627 { "name": "id", "type": "string", "nullable": false },
6628 { "name": "project_id", "type": "string", "nullable": false }
6629 ],
6630 "scopes": [{ "pattern": "project:{project_id}" }]
6631 }]
6632 });
6633
6634 {
6635 let mut first = SyncClient::open_path_with_identity(
6636 None,
6637 &schema,
6638 ClientLimits::default(),
6639 path.to_str().expect("UTF-8 temp path"),
6640 )
6641 .expect("first open");
6642 first
6643 .subscribe(
6644 "tasks".to_owned(),
6645 "tasks".to_owned(),
6646 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6647 None,
6648 )
6649 .expect("persist subscription");
6650 first.set_schema_floor(Some(SchemaFloor {
6651 required_schema_version: Some(2),
6652 latest_schema_version: Some(2),
6653 }));
6654 }
6655
6656 let reopened = SyncClient::open_path_with_identity(
6657 None,
6658 &schema,
6659 ClientLimits::default(),
6660 path.to_str().expect("UTF-8 temp path"),
6661 )
6662 .expect("reopen");
6663 assert_eq!(
6664 reopened.schema_floor(),
6665 Some(&SchemaFloor {
6666 required_schema_version: Some(2),
6667 latest_schema_version: Some(2),
6668 })
6669 );
6670 assert!(!reopened.sync_needed());
6671 drop(reopened);
6672 std::fs::remove_file(path).expect("remove temp database");
6673 }
6674
6675 #[test]
6676 fn schema_bump_precedes_index_ddl_and_prunes_removed_subscriptions() {
6677 let path = std::env::temp_dir().join(format!(
6678 "syncular-indexed-column-bump-{}.db",
6679 uuid::Uuid::new_v4()
6680 ));
6681 let old_schema = json!({
6682 "version": 1,
6683 "tables": [
6684 {
6685 "name": "tasks",
6686 "primaryKey": "id",
6687 "columns": [
6688 { "name": "id", "type": "string", "nullable": false },
6689 { "name": "project_id", "type": "string", "nullable": false }
6690 ],
6691 "scopes": [{ "pattern": "project:{project_id}" }]
6692 },
6693 {
6694 "name": "legacy",
6695 "primaryKey": "id",
6696 "columns": [
6697 { "name": "id", "type": "string", "nullable": false },
6698 { "name": "project_id", "type": "string", "nullable": false }
6699 ],
6700 "scopes": [{ "pattern": "project:{project_id}" }]
6701 }
6702 ]
6703 });
6704 {
6705 let mut first = SyncClient::open_path_with_identity(
6706 None,
6707 &old_schema,
6708 ClientLimits::default(),
6709 path.to_str().expect("UTF-8 temp path"),
6710 )
6711 .expect("open old schema");
6712 first
6713 .subscribe(
6714 "legacy-sub".to_owned(),
6715 "legacy".to_owned(),
6716 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
6717 None,
6718 )
6719 .expect("persist legacy subscription");
6720 }
6721
6722 let new_schema = json!({
6723 "version": 2,
6724 "tables": [{
6725 "name": "tasks",
6726 "primaryKey": "id",
6727 "columns": [
6728 { "name": "id", "type": "string", "nullable": false },
6729 { "name": "project_id", "type": "string", "nullable": false },
6730 { "name": "facility_membership_id", "type": "string", "nullable": true }
6731 ],
6732 "scopes": [{ "pattern": "project:{project_id}" }],
6733 "indexes": [{
6734 "name": "tasks_by_membership",
6735 "columns": ["project_id", "facility_membership_id"],
6736 "unique": false
6737 }]
6738 }]
6739 });
6740 let upgraded = SyncClient::open_path_with_identity(
6741 None,
6742 &new_schema,
6743 ClientLimits::default(),
6744 path.to_str().expect("UTF-8 temp path"),
6745 )
6746 .expect("open upgraded schema");
6747 let columns = upgraded
6748 .conn
6749 .prepare("PRAGMA table_info(tasks)")
6750 .expect("prepare columns")
6751 .query_map([], |row| row.get::<_, String>(1))
6752 .expect("query columns")
6753 .collect::<Result<Vec<_>, _>>()
6754 .expect("collect columns");
6755 assert!(columns.contains(&"facility_membership_id".to_owned()));
6756 let index_count: i64 = upgraded
6757 .conn
6758 .query_row(
6759 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = 'tasks_by_membership'",
6760 [],
6761 |row| row.get(0),
6762 )
6763 .expect("query index");
6764 assert_eq!(index_count, 1);
6765 assert!(upgraded.subscription_state("legacy-sub").is_none());
6766 drop(upgraded);
6767 std::fs::remove_file(path).expect("remove temp database");
6768 }
6769
6770 #[test]
6771 fn migrates_pre_envelope_outcome_journal_additively() {
6772 let path = std::env::temp_dir().join(format!(
6773 "syncular-outcome-migration-{}.db",
6774 uuid::Uuid::new_v4()
6775 ));
6776 let conn = Connection::open(&path).expect("open legacy database");
6777 conn.execute_batch(
6778 "CREATE TABLE _syncular_commit_outcomes (
6779 seq INTEGER PRIMARY KEY AUTOINCREMENT,
6780 client_commit_id TEXT NOT NULL UNIQUE,
6781 status TEXT NOT NULL,
6782 recorded_at_ms INTEGER NOT NULL,
6783 results_json TEXT NOT NULL,
6784 resolution TEXT NOT NULL DEFAULT 'active',
6785 resolved_at_ms INTEGER,
6786 replacement_client_commit_id TEXT);",
6787 )
6788 .expect("create legacy outcome journal");
6789 drop(conn);
6790 let schema = json!({
6791 "version": 1,
6792 "tables": [{
6793 "name": "tasks",
6794 "primaryKey": "id",
6795 "columns": [
6796 { "name": "id", "type": "string", "nullable": false },
6797 { "name": "project_id", "type": "string", "nullable": false }
6798 ],
6799 "scopes": [{ "pattern": "project:{project_id}" }]
6800 }]
6801 });
6802 let client = SyncClient::open_path(
6803 "migration-native".to_owned(),
6804 &schema,
6805 ClientLimits::default(),
6806 path.to_str().expect("UTF-8 temp path"),
6807 )
6808 .expect("migrate database");
6809 let has_operations = client
6810 .conn
6811 .prepare("PRAGMA table_info(_syncular_commit_outcomes)")
6812 .expect("prepare table info")
6813 .query_map([], |row| row.get::<_, String>(1))
6814 .expect("query table info")
6815 .filter_map(Result::ok)
6816 .any(|column| column == "operations_json");
6817 assert!(has_operations);
6818 drop(client);
6819 std::fs::remove_file(path).expect("remove temp database");
6820 }
6821
6822 #[test]
6823 fn retained_rejection_survives_file_reopen_and_security_purge() {
6824 let path =
6825 std::env::temp_dir().join(format!("syncular-retained-{}.db", uuid::Uuid::new_v4()));
6826 let schema = json!({ "version": 1, "tables": [{ "name": "tasks", "primaryKey": "id", "columns": [
6827 { "name": "id", "type": "string", "nullable": false },
6828 { "name": "project_id", "type": "string", "nullable": false },
6829 { "name": "title", "type": "string", "nullable": false }
6830 ], "scopes": [{ "pattern": "project:{project_id}" }] }] });
6831 {
6832 let mut first = SyncClient::open_path(
6833 "retained-native".to_owned(),
6834 &schema,
6835 ClientLimits::default(),
6836 path.to_str().unwrap(),
6837 )
6838 .unwrap();
6839 first
6840 .write_base_row(
6841 "tasks",
6842 &vec![
6843 Some(ColumnValue::String("t1".into())),
6844 Some(ColumnValue::String("p1".into())),
6845 Some(ColumnValue::String("server".into())),
6846 ],
6847 1,
6848 )
6849 .unwrap();
6850 first.rebuild_overlay().expect("rebuild overlay");
6851 let operation = OutboxOp {
6852 table: "tasks".into(),
6853 row_id: "t1".into(),
6854 upsert: true,
6855 insert: false,
6856 base_version: Some(1),
6857 values: Some(Map::from_iter([
6858 ("id".into(), json!("t1")),
6859 ("title".into(), json!("mine")),
6860 ])),
6861 };
6862 first
6863 .retain_failed_commit("retained", std::slice::from_ref(&operation))
6864 .unwrap();
6865 first
6866 .persist_commit_outcome(
6867 "retained",
6868 CommitOutcomeStatus::Rejected,
6869 &[CommitOperationOutcome::Error {
6870 rejection: RejectionRecord {
6871 client_commit_id: "retained".into(),
6872 op_index: 0,
6873 code: "validation.denied".into(),
6874 message: "denied".into(),
6875 retryable: false,
6876 details: None,
6877 operation: None,
6878 },
6879 }],
6880 Some(&[operation]),
6881 )
6882 .unwrap();
6883 first.rebuild_overlay().expect("rebuild overlay");
6884 }
6885 {
6886 let mut reopened = SyncClient::open_path(
6887 "retained-native".to_owned(),
6888 &schema,
6889 ClientLimits::default(),
6890 path.to_str().unwrap(),
6891 )
6892 .unwrap();
6893 let row = reopened
6894 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6895 .expect("read visible row")
6896 .unwrap();
6897 assert_eq!(row.0[2], Some(ColumnValue::String("mine".into())));
6898 assert_eq!(
6899 reopened
6900 .commit_outcome("retained")
6901 .unwrap()
6902 .unwrap()
6903 .retained_rows
6904 .unwrap()[0]
6905 .server_row
6906 .as_ref()
6907 .unwrap()["title"],
6908 json!("server")
6909 );
6910 reopened
6911 .conn
6912 .execute("DELETE FROM _syncular_base_tasks WHERE id = 't1'", [])
6913 .unwrap();
6914 reopened.rebuild_overlay().expect("rebuild overlay");
6915 assert!(reopened
6916 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6917 .expect("read visible row")
6918 .is_none());
6919 let retained = reopened.commit_outcome("retained").unwrap().unwrap();
6920 assert!(retained.retained_rows.is_some());
6921 assert_eq!(
6922 retained.operations.unwrap()[0].values.as_ref().unwrap()["title"],
6923 json!("mine")
6924 );
6925 reopened
6926 .purge_local_data(&LocalDataPurgeInput {
6927 purge_id: "protected-purge".into(),
6928 targets: vec![LocalDataPurgeTarget {
6929 table: "tasks".into(),
6930 selectors: BTreeMap::from_iter([("project_id".into(), vec!["p1".into()])]),
6931 }],
6932 })
6933 .unwrap();
6934 assert!(reopened
6935 .visible_row(reopened.schema.table("tasks").unwrap(), "t1")
6936 .expect("read visible row")
6937 .is_none());
6938 assert!(reopened
6939 .commit_outcome("retained")
6940 .unwrap()
6941 .unwrap()
6942 .retained_rows
6943 .is_none());
6944 }
6945 std::fs::remove_file(path).unwrap();
6946 }
6947
6948 #[test]
6949 fn durable_conflict_outcome_and_resolution_survive_reopen() {
6950 let path = std::env::temp_dir().join(format!(
6951 "syncular-durable-outcome-{}.db",
6952 uuid::Uuid::new_v4()
6953 ));
6954 let schema = json!({
6955 "version": 1,
6956 "tables": [{
6957 "name": "tasks",
6958 "primaryKey": "id",
6959 "columns": [
6960 { "name": "id", "type": "string", "nullable": false },
6961 { "name": "project_id", "type": "string", "nullable": false }
6962 ],
6963 "scopes": [{ "pattern": "project:{project_id}" }]
6964 }]
6965 });
6966 let conflict = ConflictRecord {
6967 client_commit_id: "losing-commit".to_owned(),
6968 op_index: 0,
6969 table: "tasks".to_owned(),
6970 row_id: "t1".to_owned(),
6971 code: "sync.version_conflict".to_owned(),
6972 message: "stale base version".to_owned(),
6973 server_version: 2,
6974 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
6975 conflict_columns: vec!["project_id".to_owned()],
6976 operation: Some(CommitOperation {
6977 table: "tasks".to_owned(),
6978 row_id: "t1".to_owned(),
6979 op: "upsert".to_owned(),
6980 base_version: Some(1),
6981 values: Some(Map::from_iter([
6982 ("id".into(), json!("t1")),
6983 ("project_id".into(), json!("p1")),
6984 ])),
6985 insert: false,
6986 }),
6987 };
6988 let failed_operations = vec![
6989 OutboxOp {
6990 upsert: true,
6991 insert: false,
6992 table: "tasks".to_owned(),
6993 row_id: "t1".to_owned(),
6994 base_version: Some(1),
6995 values: Some(Map::from_iter([
6996 ("id".into(), json!("t1")),
6997 ("project_id".into(), json!("p1")),
6998 ])),
6999 },
7000 OutboxOp {
7001 upsert: true,
7002 insert: false,
7003 table: "tasks".to_owned(),
7004 row_id: "status-event-1".to_owned(),
7005 base_version: Some(0),
7006 values: Some(Map::from_iter([
7007 ("id".into(), json!("status-event-1")),
7008 ("project_id".into(), json!("p1")),
7009 ])),
7010 },
7011 ];
7012
7013 {
7014 let mut first = SyncClient::open_path(
7015 "durable-native".to_owned(),
7016 &schema,
7017 ClientLimits::default(),
7018 path.to_str().expect("UTF-8 temp path"),
7019 )
7020 .expect("first open");
7021 first
7022 .begin_observation("test_outcome")
7023 .expect("begin outcome");
7024 first
7025 .persist_commit_outcome(
7026 "losing-commit",
7027 CommitOutcomeStatus::Conflict,
7028 &[CommitOperationOutcome::Conflict {
7029 conflict: conflict.clone(),
7030 }],
7031 Some(&failed_operations),
7032 )
7033 .expect("persist outcome");
7034 first.conflicts.push(conflict);
7035 first
7036 .finish_observation(
7037 "test_outcome",
7038 ChangeAccumulator {
7039 conflicts: true,
7040 outcomes: true,
7041 ..ChangeAccumulator::default()
7042 },
7043 )
7044 .expect("commit outcome");
7045 }
7046
7047 {
7048 let mut reopened = SyncClient::open_path(
7049 "durable-native".to_owned(),
7050 &schema,
7051 ClientLimits::default(),
7052 path.to_str().expect("UTF-8 temp path"),
7053 )
7054 .expect("reopen");
7055 assert_eq!(reopened.conflicts().len(), 1);
7056 assert_eq!(
7057 reopened.conflicts()[0].conflict_columns,
7058 vec!["project_id".to_owned()],
7059 "conflictColumns survives the journal round trip"
7060 );
7061 let outcome = reopened
7062 .commit_outcome("losing-commit")
7063 .expect("read outcome")
7064 .expect("outcome");
7065 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7066 let operations = outcome.operations.expect("aggregate envelope");
7067 assert_eq!(operations.len(), 2);
7068 assert_eq!(operations[1].row_id, "status-event-1");
7069 let resolved = reopened
7070 .resolve_commit_outcome(ResolveCommitOutcomeInput {
7071 client_commit_id: "losing-commit".to_owned(),
7072 resolution: CommitOutcomeResolution::ResolvedKeepServer,
7073 replacement_client_commit_id: None,
7074 })
7075 .expect("resolve");
7076 assert_eq!(
7077 resolved.resolution,
7078 CommitOutcomeResolution::ResolvedKeepServer
7079 );
7080 assert!(reopened.conflicts().is_empty());
7081 }
7082
7083 let reopened = SyncClient::open_path(
7084 "durable-native".to_owned(),
7085 &schema,
7086 ClientLimits::default(),
7087 path.to_str().expect("UTF-8 temp path"),
7088 )
7089 .expect("second reopen");
7090 assert!(reopened.conflicts().is_empty());
7091 assert_eq!(
7092 reopened
7093 .commit_outcome("losing-commit")
7094 .expect("read outcome")
7095 .expect("outcome")
7096 .resolution,
7097 CommitOutcomeResolution::ResolvedKeepServer
7098 );
7099 drop(reopened);
7100 std::fs::remove_file(path).expect("remove temp database");
7101 }
7102
7103 #[test]
7104 fn file_snapshot_reader_matches_owner_rows_revision_and_coverage() {
7105 let path =
7106 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7107 let schema = json!({
7108 "version": 1,
7109 "tables": [{
7110 "name": "tasks",
7111 "primaryKey": "id",
7112 "columns": [
7113 { "name": "id", "type": "string", "nullable": false },
7114 { "name": "project_id", "type": "string", "nullable": false }
7115 ],
7116 "scopes": [{ "pattern": "project:{project_id}" }]
7117 }]
7118 });
7119 let mut client = SyncClient::open_path_with_identity(
7120 Some("sidecar-client".to_owned()),
7121 &schema,
7122 ClientLimits::default(),
7123 path.to_str().expect("UTF-8 temp path"),
7124 )
7125 .expect("open owner");
7126 let base = WindowBase {
7127 table: "tasks".to_owned(),
7128 variable: "project_id".to_owned(),
7129 fixed_scopes: Vec::new(),
7130 params: None,
7131 };
7132 client
7133 .set_window(&base, &["one".to_owned()])
7134 .expect("set window");
7135 client
7136 .mutate(vec![Mutation::Upsert {
7137 table: "tasks".to_owned(),
7138 values: Map::from_iter([
7139 ("id".to_owned(), Value::from("t1")),
7140 ("project_id".to_owned(), Value::from("one")),
7141 ]),
7142 base_version: None,
7143 }])
7144 .expect("local mutate");
7145
7146 let coverage = [WindowCoverage {
7147 base,
7148 units: vec!["one".to_owned(), "missing".to_owned()],
7149 }];
7150 let owner = client
7151 .query_snapshot(
7152 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7153 &[],
7154 &coverage,
7155 None,
7156 )
7157 .expect("owner snapshot");
7158 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7159 let sidecar = reader
7160 .query_snapshot(
7161 "SELECT id, project_id, _sync_version AS server_version FROM tasks ORDER BY id",
7162 &[],
7163 &coverage,
7164 )
7165 .expect("sidecar snapshot");
7166
7167 assert_eq!(sidecar.revision, owner.revision);
7168 assert_eq!(sidecar.rows, owner.rows);
7169 assert_eq!(
7170 serde_json::to_value(&sidecar.coverage).expect("serialize sidecar coverage"),
7171 serde_json::to_value(&owner.coverage).expect("serialize owner coverage")
7172 );
7173 assert_eq!(sidecar.revision, "2");
7174 assert_eq!(sidecar.rows[0]["id"], "t1");
7175 assert_eq!(sidecar.rows[0]["server_version"], -1);
7176 assert!(!sidecar.coverage.complete);
7177 assert_eq!(sidecar.coverage.pending.len(), 1);
7178 assert_eq!(sidecar.coverage.missing.len(), 1);
7179
7180 drop(reader);
7181 drop(client);
7182 std::fs::remove_file(path).expect("remove temp database");
7183 }
7184
7185 #[test]
7186 fn file_snapshot_reader_reports_catchup_delivery_and_multi_statement() {
7187 let path =
7188 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7189 let schema = json!({
7190 "version": 1,
7191 "tables": [{
7192 "name": "tasks",
7193 "primaryKey": "id",
7194 "columns": [
7195 { "name": "id", "type": "string", "nullable": false },
7196 { "name": "project_id", "type": "string", "nullable": false }
7197 ],
7198 "scopes": [{ "pattern": "project:{project_id}" }]
7199 }]
7200 });
7201 let mut client = SyncClient::open_path_with_identity(
7202 Some("sidecar-catchup".to_owned()),
7203 &schema,
7204 ClientLimits::default(),
7205 path.to_str().expect("UTF-8 temp path"),
7206 )
7207 .expect("open owner");
7208 client
7209 .subscribe(
7210 "s1".into(),
7211 "tasks".into(),
7212 vec![("project_id".into(), vec!["one".into()])],
7213 None,
7214 )
7215 .expect("subscribe");
7216 let commit_id = client
7217 .mutate(vec![Mutation::Upsert {
7218 table: "tasks".to_owned(),
7219 values: Map::from_iter([
7220 ("id".to_owned(), Value::from("t1")),
7221 ("project_id".to_owned(), Value::from("one")),
7222 ]),
7223 base_version: None,
7224 }])
7225 .expect("mutate");
7226 let revision = client.local_revision().to_string();
7227
7228 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7229 assert_eq!(reader.local_revision().expect("revision"), revision);
7230
7231 let read = reader
7232 .snapshot_read(&SnapshotReadRequest {
7233 statements: vec![
7234 SnapshotStatement {
7235 sql: "SELECT 1 AS n",
7236 params: &[],
7237 },
7238 SnapshotStatement {
7239 sql: "SELECT id FROM tasks ORDER BY id",
7240 params: &[],
7241 },
7242 ],
7243 coverage: &[],
7244 subscriptions: vec!["s1".to_owned(), "absent".to_owned()],
7245 commit_ids: vec![commit_id.clone(), "never-known".to_owned()],
7246 })
7247 .expect("snapshot read");
7248 assert_eq!(read.revision, revision);
7249 assert_eq!(read.queries.len(), 2);
7250 assert_eq!(read.queries[0][0]["n"], 1);
7251 assert_eq!(read.queries[1][0]["id"], "t1");
7252 match &read.subscriptions[0] {
7253 SubscriptionCatchup::Known {
7254 status,
7255 cursor,
7256 bootstrap_complete,
7257 known_pending_pages,
7258 ..
7259 } => {
7260 assert_eq!(status, "active");
7261 assert_eq!(*cursor, -1);
7262 assert!(!bootstrap_complete);
7263 assert!(known_pending_pages);
7264 }
7265 other => panic!("expected a known subscription: {other:?}"),
7266 }
7267 assert!(matches!(
7268 &read.subscriptions[1],
7269 SubscriptionCatchup::Unknown { id } if id.as_str() == "absent"
7270 ));
7271 assert!(matches!(
7272 &read.deliveries[0],
7273 CommitDelivery::Pending { client_commit_id } if client_commit_id == &commit_id
7274 ));
7275 assert!(matches!(
7276 &read.deliveries[1],
7277 CommitDelivery::Unknown { client_commit_id } if client_commit_id.as_str() == "never-known"
7278 ));
7279
7280 assert!(reader
7282 .query_snapshot("SELECT * FROM does_not_exist", &[], &[])
7283 .is_err());
7284 assert_eq!(
7285 reader.local_revision().expect("revision after rollback"),
7286 revision
7287 );
7288 assert!(reader
7290 .query_snapshot("DELETE FROM tasks", &[], &[])
7291 .is_err());
7292
7293 client
7295 .conn
7296 .execute(
7297 "UPDATE _syncular_meta SET value = '+1' WHERE key = 'localRevision'",
7298 [],
7299 )
7300 .expect("corrupt revision");
7301 let failure = reader.local_revision().expect_err("corrupt revision fails");
7302 assert_eq!(failure.code, Some("sync.local_corrupt"));
7303
7304 drop(reader);
7305 drop(client);
7306 std::fs::remove_file(path).expect("remove temp database");
7307 }
7308
7309 #[test]
7310 fn snapshot_delivery_is_the_persisted_journal_view_without_retained_rows() {
7311 let path =
7312 std::env::temp_dir().join(format!("syncular-read-sidecar-{}.db", uuid::Uuid::new_v4()));
7313 let schema = json!({
7314 "version": 1,
7315 "tables": [{
7316 "name": "tasks",
7317 "primaryKey": "id",
7318 "columns": [
7319 { "name": "id", "type": "string", "nullable": false },
7320 { "name": "project_id", "type": "string", "nullable": false }
7321 ],
7322 "scopes": [{ "pattern": "project:{project_id}" }]
7323 }]
7324 });
7325 let mut client = SyncClient::open_path_with_identity(
7326 Some("sidecar-delivery".to_owned()),
7327 &schema,
7328 ClientLimits::default(),
7329 path.to_str().expect("UTF-8 temp path"),
7330 )
7331 .expect("open owner");
7332 client.begin_observation("test_delivery").expect("observe");
7333 let operations = [OutboxOp {
7334 upsert: true,
7335 insert: false,
7336 table: "tasks".to_owned(),
7337 row_id: "t1".to_owned(),
7338 base_version: Some(1),
7339 values: Some(Map::from_iter([
7340 ("id".into(), json!("t1")),
7341 ("project_id".into(), json!("p1")),
7342 ])),
7343 }];
7344 client
7345 .persist_commit_outcome(
7346 "conflict-commit",
7347 CommitOutcomeStatus::Conflict,
7348 &[CommitOperationOutcome::Conflict {
7349 conflict: ConflictRecord {
7350 client_commit_id: "conflict-commit".to_owned(),
7351 op_index: 0,
7352 table: "tasks".to_owned(),
7353 row_id: "t1".to_owned(),
7354 code: "sync.version_conflict".to_owned(),
7355 message: "stale base version".to_owned(),
7356 server_version: 2,
7357 server_row: Map::from_iter([("id".to_owned(), json!("t1"))]),
7358 conflict_columns: vec!["project_id".to_owned()],
7359 operation: None,
7360 },
7361 }],
7362 Some(&operations),
7363 )
7364 .expect("persist conflict");
7365 client
7366 .persist_commit_outcome(
7367 "rejected-commit",
7368 CommitOutcomeStatus::Rejected,
7369 &[CommitOperationOutcome::Error {
7370 rejection: RejectionRecord {
7371 client_commit_id: "rejected-commit".to_owned(),
7372 op_index: 0,
7373 code: "sync.unknown_table".to_owned(),
7374 message: "unknown table".to_owned(),
7375 retryable: false,
7376 details: None,
7377 operation: None,
7378 },
7379 }],
7380 Some(&operations),
7381 )
7382 .expect("persist rejection");
7383 client
7384 .finish_observation(
7385 "test_delivery",
7386 ChangeAccumulator {
7387 conflicts: true,
7388 outcomes: true,
7389 ..ChangeAccumulator::default()
7390 },
7391 )
7392 .expect("commit outcomes");
7393
7394 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7395 match reader.commit_delivery("conflict-commit").expect("conflict") {
7396 CommitDelivery::Known { outcome, .. } => {
7397 assert_eq!(outcome.status, CommitOutcomeStatus::Conflict);
7398 assert!(matches!(
7399 outcome.results.as_slice(),
7400 [CommitOperationOutcome::Conflict { conflict }]
7401 if conflict.code == "sync.version_conflict"
7402 && conflict.server_version == 2
7403 ));
7404 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7405 assert!(
7406 outcome.retained_rows.is_none(),
7407 "the sidecar reports the persisted view, not owner-derived retained rows"
7408 );
7409 }
7410 other => panic!("expected a known conflict: {other:?}"),
7411 }
7412 match reader
7413 .commit_delivery("rejected-commit")
7414 .expect("rejection")
7415 {
7416 CommitDelivery::Known { outcome, .. } => {
7417 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
7418 assert!(matches!(
7419 outcome.results.as_slice(),
7420 [CommitOperationOutcome::Error { rejection }]
7421 if rejection.code == "sync.unknown_table"
7422 ));
7423 assert_eq!(outcome.operations.as_ref().map(Vec::len), Some(1));
7424 assert!(outcome.retained_rows.is_none());
7425 }
7426 other => panic!("expected a known rejection: {other:?}"),
7427 }
7428 drop(reader);
7429 drop(client);
7430 std::fs::remove_file(path).expect("remove temp database");
7431 }
7432
7433 #[test]
7434 fn snapshot_read_isolates_a_concurrent_writer_commit() {
7435 use std::sync::mpsc::channel;
7436 let path = std::env::temp_dir().join(format!(
7437 "syncular-read-concurrent-{}.db",
7438 uuid::Uuid::new_v4()
7439 ));
7440 let schema = json!({
7441 "version": 1,
7442 "tables": [{
7443 "name": "tasks",
7444 "primaryKey": "id",
7445 "columns": [
7446 { "name": "id", "type": "string", "nullable": false },
7447 { "name": "project_id", "type": "string", "nullable": false }
7448 ],
7449 "scopes": [{ "pattern": "project:{project_id}" }]
7450 }]
7451 });
7452 let mut client = SyncClient::open_path_with_identity(
7453 Some("sidecar-concurrent".to_owned()),
7454 &schema,
7455 ClientLimits::default(),
7456 path.to_str().expect("UTF-8 temp path"),
7457 )
7458 .expect("open owner");
7459 client
7460 .mutate(vec![Mutation::Upsert {
7461 table: "tasks".to_owned(),
7462 values: Map::from_iter([
7463 ("id".to_owned(), Value::from("t1")),
7464 ("project_id".to_owned(), Value::from("one")),
7465 ]),
7466 base_version: None,
7467 }])
7468 .expect("seed row");
7469 let revision_before = client.local_revision().to_string();
7470
7471 let (ready_tx, ready_rx) = channel::<()>();
7474 let (done_tx, done_rx) = channel::<()>();
7475 let writer_path = path.to_string_lossy().into_owned();
7476 let writer_schema = schema.clone();
7477 let writer = std::thread::spawn(move || {
7478 let mut writer = SyncClient::open_path_with_identity(
7479 Some("sidecar-concurrent".to_owned()),
7480 &writer_schema,
7481 ClientLimits::default(),
7482 &writer_path,
7483 )
7484 .expect("open writer");
7485 ready_rx.recv().expect("reader signalled");
7486 writer
7487 .mutate(vec![Mutation::Upsert {
7488 table: "tasks".to_owned(),
7489 values: Map::from_iter([
7490 ("id".to_owned(), Value::from("t2")),
7491 ("project_id".to_owned(), Value::from("one")),
7492 ]),
7493 base_version: None,
7494 }])
7495 .expect("writer commit");
7496 let _ = done_tx.send(());
7497 });
7498 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7499 SNAPSHOT_READ_HOOK.with(|slot| {
7500 *slot.borrow_mut() = Some(Box::new(move || {
7501 ready_tx.send(()).expect("signal writer");
7502 done_rx.recv().expect("writer finished");
7503 }));
7504 });
7505 let read = reader
7506 .snapshot_read(&SnapshotReadRequest {
7507 statements: vec![
7508 SnapshotStatement {
7509 sql: "SELECT count(*) AS n FROM tasks",
7510 params: &[],
7511 },
7512 SnapshotStatement {
7513 sql: "SELECT count(*) AS n FROM tasks",
7514 params: &[],
7515 },
7516 ],
7517 coverage: &[],
7518 subscriptions: Vec::new(),
7519 commit_ids: Vec::new(),
7520 })
7521 .expect("snapshot read");
7522 assert_eq!(
7523 read.queries[0][0]["n"], 1,
7524 "first statement sees the pre-commit row"
7525 );
7526 assert_eq!(
7527 read.queries[1][0]["n"], 1,
7528 "a later statement keeps the one read snapshot"
7529 );
7530 assert_eq!(
7531 read.revision, revision_before,
7532 "the read reports the snapshot revision, not the writer's"
7533 );
7534 writer.join().expect("join writer");
7535
7536 assert_ne!(
7537 reader.local_revision().expect("revision after commit"),
7538 revision_before,
7539 "the next read observes the committed revision"
7540 );
7541 let after = reader
7542 .query_snapshot("SELECT count(*) AS n FROM tasks", &[], &[])
7543 .expect("count after commit");
7544 assert_eq!(
7545 after.rows[0]["n"], 2,
7546 "the committed row is visible afterwards"
7547 );
7548
7549 drop(reader);
7550 drop(client);
7551 std::fs::remove_file(path).expect("remove temp database");
7552 }
7553
7554 #[test]
7555 fn snapshot_read_rejects_every_non_canonical_revision() {
7556 let path = std::env::temp_dir().join(format!(
7557 "syncular-read-revision-{}.db",
7558 uuid::Uuid::new_v4()
7559 ));
7560 let schema = json!({
7561 "version": 1,
7562 "tables": [{
7563 "name": "tasks",
7564 "primaryKey": "id",
7565 "columns": [
7566 { "name": "id", "type": "string", "nullable": false },
7567 { "name": "project_id", "type": "string", "nullable": false }
7568 ],
7569 "scopes": [{ "pattern": "project:{project_id}" }]
7570 }]
7571 });
7572 let client = SyncClient::open_path_with_identity(
7573 Some("sidecar-revision".to_owned()),
7574 &schema,
7575 ClientLimits::default(),
7576 path.to_str().expect("UTF-8 temp path"),
7577 )
7578 .expect("open owner");
7579 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7580 for bad in [
7581 "+1",
7582 "01",
7583 "-1",
7584 " 1",
7585 "1 ",
7586 "18446744073709551616",
7587 "1.0",
7588 "abc",
7589 "",
7590 ] {
7591 client
7592 .conn
7593 .execute(
7594 "UPDATE _syncular_meta SET value = ?1 WHERE key = 'localRevision'",
7595 rusqlite::params![bad],
7596 )
7597 .expect("corrupt revision");
7598 let failure = reader.local_revision().expect_err("corrupt marker fails");
7599 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad:?}");
7600 }
7601 for value in ["X'31'", "CAST(X'FF' AS TEXT)"] {
7602 client
7603 .conn
7604 .execute_batch(&format!(
7605 "UPDATE _syncular_meta SET value={value} WHERE key='localRevision'"
7606 ))
7607 .expect("corrupt stored revision type");
7608 assert_eq!(
7609 reader.local_revision().expect_err("invalid SQL field").code,
7610 Some("sync.local_corrupt")
7611 );
7612 }
7613 client
7615 .conn
7616 .execute("DELETE FROM _syncular_meta WHERE key = 'localRevision'", [])
7617 .expect("delete marker");
7618 assert_eq!(reader.local_revision().expect("legacy zero"), "0");
7619
7620 drop(reader);
7621 drop(client);
7622 std::fs::remove_file(path).expect("remove temp database");
7623 }
7624
7625 #[test]
7626 fn snapshot_read_rejects_corrupt_subscription_state() {
7627 let path = std::env::temp_dir().join(format!(
7628 "syncular-read-substate-{}.db",
7629 uuid::Uuid::new_v4()
7630 ));
7631 let schema = json!({
7632 "version": 1,
7633 "tables": [{
7634 "name": "tasks",
7635 "primaryKey": "id",
7636 "columns": [
7637 { "name": "id", "type": "string", "nullable": false },
7638 { "name": "project_id", "type": "string", "nullable": false }
7639 ],
7640 "scopes": [{ "pattern": "project:{project_id}" }]
7641 }]
7642 });
7643 let mut client = SyncClient::open_path_with_identity(
7644 Some("sidecar-substate".to_owned()),
7645 &schema,
7646 ClientLimits::default(),
7647 path.to_str().expect("UTF-8 temp path"),
7648 )
7649 .expect("open owner");
7650 client
7651 .subscribe(
7652 "s1".into(),
7653 "tasks".into(),
7654 vec![("project_id".into(), vec!["one".into()])],
7655 None,
7656 )
7657 .expect("subscribe");
7658 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7659 for bad in [
7660 "not json".to_owned(),
7661 "{}".to_owned(),
7662 json!({ "status": "bogus", "cursor": 0 }).to_string(),
7663 json!({ "status": "active", "cursor": "x" }).to_string(),
7664 json!({ "status": "active", "cursor": 0, "bootstrapState": 7 }).to_string(),
7665 json!({ "status": "active", "cursor": 0, "reasonCode": 7 }).to_string(),
7666 ] {
7667 client
7668 .conn
7669 .execute(
7670 "UPDATE _syncular_subscriptions SET state_json = ?1 WHERE id = 's1'",
7671 rusqlite::params![bad],
7672 )
7673 .expect("corrupt subscription state");
7674 let failure = reader
7675 .subscription_catchup("s1")
7676 .expect_err("corrupt state fails");
7677 assert_eq!(failure.code, Some("sync.local_corrupt"), "{bad}");
7678 }
7679
7680 for assignment in [
7681 "tbl=X'7461736b73'",
7682 "state_json=X'7b7d'",
7683 "state_json=CAST(X'FF' AS TEXT)",
7684 ] {
7685 client.conn.execute_batch(&format!(
7686 "UPDATE _syncular_subscriptions SET tbl='tasks',state_json='{{\"status\":\"active\",\"cursor\":0}}'; UPDATE _syncular_subscriptions SET {assignment} WHERE id='s1'"
7687 )).expect("corrupt stored subscription type");
7688 assert_eq!(
7689 reader
7690 .subscription_catchup("s1")
7691 .expect_err("invalid SQL field")
7692 .code,
7693 Some("sync.local_corrupt")
7694 );
7695 }
7696
7697 drop(reader);
7698 drop(client);
7699 std::fs::remove_file(path).expect("remove temp database");
7700 }
7701
7702 #[test]
7703 fn snapshot_read_rolls_back_a_later_invalid_statement_and_guards_every_write() {
7704 let path = std::env::temp_dir().join(format!(
7705 "syncular-read-rollback-{}.db",
7706 uuid::Uuid::new_v4()
7707 ));
7708 let schema = json!({
7709 "version": 1,
7710 "tables": [{
7711 "name": "tasks",
7712 "primaryKey": "id",
7713 "columns": [
7714 { "name": "id", "type": "string", "nullable": false },
7715 { "name": "project_id", "type": "string", "nullable": false }
7716 ],
7717 "scopes": [{ "pattern": "project:{project_id}" }]
7718 }]
7719 });
7720 let mut client = SyncClient::open_path_with_identity(
7721 Some("sidecar-rollback".to_owned()),
7722 &schema,
7723 ClientLimits::default(),
7724 path.to_str().expect("UTF-8 temp path"),
7725 )
7726 .expect("open owner");
7727 let commit_id = client
7728 .mutate(vec![Mutation::Upsert {
7729 table: "tasks".to_owned(),
7730 values: Map::from_iter([
7731 ("id".to_owned(), Value::from("t1")),
7732 ("project_id".to_owned(), Value::from("one")),
7733 ]),
7734 base_version: None,
7735 }])
7736 .expect("seed row");
7737 let mut reader = FileQuerySnapshotReader::new(path.to_string_lossy());
7738 let revision = reader.local_revision().expect("revision");
7739
7740 let failure = reader
7743 .snapshot_read(&SnapshotReadRequest {
7744 statements: vec![
7745 SnapshotStatement {
7746 sql: "SELECT id FROM tasks",
7747 params: &[],
7748 },
7749 SnapshotStatement {
7750 sql: "SELECT * FROM does_not_exist",
7751 params: &[],
7752 },
7753 ],
7754 coverage: &[],
7755 subscriptions: Vec::new(),
7756 commit_ids: vec![commit_id.clone()],
7757 })
7758 .expect_err("second statement fails");
7759 assert_eq!(failure.code, None);
7760 assert_eq!(
7761 reader.local_revision().expect("revision after rollback"),
7762 revision,
7763 "a rolled-back read consumes no revision"
7764 );
7765 assert!(
7766 !matches!(
7767 reader
7768 .commit_delivery(&commit_id)
7769 .expect("delivery after rollback"),
7770 CommitDelivery::Unknown { .. }
7771 ),
7772 "the same reader serves the next read after releasing the connection"
7773 );
7774
7775 let guard = reader
7777 .snapshot_read(&SnapshotReadRequest {
7778 statements: vec![SnapshotStatement {
7779 sql: "DELETE FROM tasks",
7780 params: &[],
7781 }],
7782 coverage: &[],
7783 subscriptions: Vec::new(),
7784 commit_ids: Vec::new(),
7785 })
7786 .expect_err("write guard");
7787 assert!(guard.message.contains("read-only"), "{}", guard.message);
7788
7789 drop(reader);
7790 drop(client);
7791 std::fs::remove_file(path).expect("remove temp database");
7792 }
7793
7794 #[test]
7795 fn owned_query_failures_classify_order_bound_and_clear() {
7796 let io = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7797 rusqlite::ffi::Error::new(266),
7798 Some("private SQLite prose".to_owned()),
7799 ));
7800 assert_eq!(io.code, Some("client.storage_io"));
7801 assert_eq!(io.sqlite_code, Some(266));
7802 assert_eq!(io.message, "local SQLite storage I/O failed");
7803 let corrupt = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7804 rusqlite::ffi::Error::new(26),
7805 None,
7806 ));
7807 assert_eq!(corrupt.code, Some("client.storage_corrupt"));
7808 assert!(!io.retryable());
7809 assert!(!corrupt.retryable());
7810
7811 for (raw, extended) in [(5, 5), (5 | (1 << 8), 261), (6, 6), (6 | (1 << 8), 262)] {
7814 let busy = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7815 rusqlite::ffi::Error::new(raw),
7816 Some("database is locked".to_owned()),
7817 ));
7818 assert_eq!(busy.code, Some("client.storage_busy"));
7819 assert_eq!(busy.sqlite_code, Some(extended));
7820 assert_eq!(busy.message, "local SQLite storage is busy");
7821 assert!(busy.retryable());
7822 }
7823 let unrelated = QueryReadFailure::from(rusqlite::Error::SqliteFailure(
7824 rusqlite::ffi::Error::new(1),
7825 Some("generic SQLite prose".to_owned()),
7826 ));
7827 assert_eq!(unrelated.code, None);
7828 assert!(!unrelated.retryable());
7829
7830 let mut client = client();
7831 let generic = QueryReadFailure::from("private query prose".to_owned());
7832 client.record_query_read(
7833 QueryOwner {
7834 id: "first",
7835 tables: &["tasks", "tasks"],
7836 },
7837 Some(&generic),
7838 );
7839 client.record_query_read(
7840 QueryOwner {
7841 id: "second",
7842 tables: &["tasks"],
7843 },
7844 Some(&generic),
7845 );
7846 client.record_query_read(
7847 QueryOwner {
7848 id: "first",
7849 tables: &["docs"],
7850 },
7851 Some(&io),
7852 );
7853 let failures = &client
7854 .diagnostics_snapshot(&Default::default())
7855 .expect("diagnostics")
7856 .query_failures;
7857 assert_eq!(
7858 failures
7859 .iter()
7860 .map(|entry| entry.id.as_str())
7861 .collect::<Vec<_>>(),
7862 vec!["second", "first"]
7863 );
7864 assert_eq!(failures[1].tables, vec!["docs"]);
7865 assert_eq!(failures[1].code, "client.storage_io");
7866
7867 for index in 0..=MAX_DIAGNOSTIC_QUERY_FAILURES {
7868 client.record_query_read(
7869 QueryOwner {
7870 id: &format!("query-{index}"),
7871 tables: &["tasks"],
7872 },
7873 Some(&generic),
7874 );
7875 }
7876 let failures = &client
7877 .diagnostics_snapshot(&Default::default())
7878 .expect("bounded diagnostics")
7879 .query_failures;
7880 assert_eq!(failures.len(), MAX_DIAGNOSTIC_QUERY_FAILURES);
7881 assert_eq!(failures.last().expect("last failure").id, "query-256");
7882
7883 client
7884 .query_snapshot(
7885 "SELECT id FROM tasks",
7886 &[],
7887 &[],
7888 Some(QueryOwner {
7889 id: "query-256",
7890 tables: &["tasks"],
7891 }),
7892 )
7893 .expect("successful owned read");
7894 assert!(client
7895 .diagnostics_snapshot(&Default::default())
7896 .expect("cleared diagnostics")
7897 .query_failures
7898 .iter()
7899 .all(|entry| entry.id != "query-256"));
7900 }
7901
7902 #[test]
7903 fn image_chunks_keep_their_committed_prefix_after_a_later_invalid_row() {
7904 let mut client = client();
7905 let image = Connection::open_in_memory().unwrap();
7906 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT, _syncular_version INTEGER NOT NULL);
7907 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7908 INSERT INTO tasks SELECT printf('%05d',x),CASE WHEN x=1500 THEN NULL ELSE 'p1' END,1 FROM n;
7909 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
7910 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',2050);").unwrap();
7911 let table = client.schema.table("tasks").unwrap().clone();
7912 let scopes = vec![("project_id".into(), vec!["p1".into()])];
7913 assert!(client
7914 .apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest")
7915 .is_err());
7916 assert!(client.conn.is_autocommit());
7917 assert_eq!(
7918 client
7919 .query("SELECT count(*) AS n FROM tasks", &[])
7920 .unwrap()[0]["n"],
7921 1024
7922 );
7923 assert_eq!(client.drain_change_batches().len(), 1);
7924 image
7925 .execute("UPDATE tasks SET project_id='p1' WHERE id='01500'", [])
7926 .unwrap();
7927 assert!(matches!(
7928 client.apply_sqlite_image(&image, &table, true, &scopes, 2050, 7, "digest"),
7929 Ok(2050)
7930 ));
7931 assert_eq!(
7932 client
7933 .query("SELECT count(*) AS n FROM tasks", &[])
7934 .unwrap()[0]["n"],
7935 2050
7936 );
7937 assert_eq!(client.drain_change_batches().len(), 3);
7938 }
7939
7940 #[test]
7941 fn eviction_chunks_commit_before_failure_and_resume_with_exact_scope() {
7942 let mut client = client();
7943 let base = WindowBase {
7944 table: "tasks".into(),
7945 variable: "project_id".into(),
7946 fixed_scopes: Vec::new(),
7947 params: None,
7948 };
7949 client
7950 .set_window(&base, &["p1".into(), "p2".into()])
7951 .unwrap();
7952 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7953 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7954 INSERT INTO _syncular_base_tasks VALUES ('held','p2',1);
7955 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7956 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
7957 client.drain_change_batches();
7958 assert!(client.set_window(&base, &["p2".into()]).is_err());
7959 assert!(client.conn.is_autocommit());
7960 assert_eq!(
7961 client
7962 .query("SELECT count(*) AS n FROM tasks", &[])
7963 .unwrap()[0]["n"],
7964 1027
7965 );
7966 assert_eq!(client.window_state(&base).units, vec!["p2"]);
7967 assert_eq!(client.load_pending_evictions().unwrap().len(), 1);
7968 assert_eq!(client.drain_change_batches().len(), 1);
7969 client
7970 .conn
7971 .execute_batch("DROP TRIGGER interrupt_evict")
7972 .unwrap();
7973 client.drain_pending_evictions().unwrap();
7974 assert!(client.load_pending_evictions().unwrap().is_empty());
7975 assert_eq!(
7976 client.query("SELECT id FROM tasks", &[]).unwrap()[0]["id"],
7977 "held"
7978 );
7979 assert_eq!(client.drain_change_batches().len(), 2);
7980 }
7981
7982 #[test]
7983 fn eviction_marker_failures_roll_back_window_changes() {
7984 let mut client = client();
7985 let base = WindowBase {
7986 table: "tasks".into(),
7987 variable: "project_id".into(),
7988 fixed_scopes: Vec::new(),
7989 params: None,
7990 };
7991 client.set_window(&base, &["p1".into()]).unwrap();
7992 client.conn.execute_batch("WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<2050)
7993 INSERT INTO _syncular_base_tasks SELECT printf('%05d',x),'p1',1 FROM n;
7994 INSERT INTO tasks SELECT * FROM _syncular_base_tasks;
7995 CREATE TRIGGER fail_marker BEFORE INSERT ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
7996 let revision = client.local_revision();
7997 assert!(client.set_window(&base, &[]).is_err());
7998 assert!(client.conn.is_autocommit());
7999 assert_eq!(client.local_revision(), revision);
8000 assert_eq!(
8001 client
8002 .query("SELECT count(*) AS n FROM tasks", &[])
8003 .unwrap()[0]["n"],
8004 2050
8005 );
8006 assert_eq!(client.window_state(&base).units, vec!["p1"]);
8007 client.conn.execute_batch("DROP TRIGGER fail_marker;
8008 CREATE TRIGGER interrupt_evict BEFORE DELETE ON tasks WHEN old.id='01500' BEGIN SELECT RAISE(ABORT,'interrupted chunk'); END;").unwrap();
8009 assert!(client.set_window(&base, &[]).is_err());
8010 client.conn.execute_batch("DROP TRIGGER interrupt_evict;
8011 CREATE TRIGGER fail_marker BEFORE DELETE ON _syncular_window_pending_evict BEGIN SELECT RAISE(ABORT,'marker failed'); END;").unwrap();
8012 assert!(client.drain_pending_evictions().is_err());
8013 assert!(client.conn.is_autocommit());
8014 assert_eq!(
8015 client
8016 .query("SELECT count(*) AS n FROM tasks", &[])
8017 .unwrap()[0]["n"],
8018 2
8019 );
8020 assert!(client.set_window(&base, &["p1".into()]).is_err());
8021 assert!(client.conn.is_autocommit());
8022 assert!(client.window_state(&base).units.is_empty());
8023 assert!(client.subs.is_empty());
8024 assert_eq!(
8025 client
8026 .query(
8027 "SELECT count(*) AS n FROM _syncular_window_pending_evict",
8028 &[]
8029 )
8030 .unwrap()[0]["n"],
8031 1
8032 );
8033 client
8034 .conn
8035 .execute_batch("DROP TRIGGER fail_marker")
8036 .unwrap();
8037 client.set_window(&base, &["p1".into()]).unwrap();
8038 assert_eq!(client.window_state(&base).units, vec!["p1"]);
8039 }
8040
8041 #[test]
8042 fn malformed_eviction_marker_is_reported_without_starting_cleanup() {
8043 let mut client = client();
8044 client
8045 .conn
8046 .execute(
8047 "INSERT INTO _syncular_window_pending_evict VALUES ('broken','tasks','{')",
8048 [],
8049 )
8050 .unwrap();
8051 assert_eq!(
8052 client.drain_pending_evictions().unwrap_err(),
8053 "sync.local_corrupt: invalid pending eviction scopes"
8054 );
8055 assert!(client.conn.is_autocommit());
8056 assert!(client.enqueue_startup_sync_if_needed().is_err());
8057 }
8058
8059 #[test]
8060 fn image_chunks_reject_null_primary_keys() {
8061 let mut client = client();
8062 let image = Connection::open_in_memory().unwrap();
8063 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8064 WITH RECURSIVE n(x) AS (SELECT 1 UNION ALL SELECT x+1 FROM n WHERE x<1025)
8065 INSERT INTO tasks SELECT NULL,'p1',1 FROM n;
8066 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8067 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1025);").unwrap();
8068 let table = client.schema.table("tasks").unwrap().clone();
8069 assert!(client
8070 .apply_sqlite_image(
8071 &image,
8072 &table,
8073 true,
8074 &[("project_id".into(), vec!["p1".into()])],
8075 1025,
8076 7,
8077 "digest"
8078 )
8079 .is_err());
8080 assert!(client.conn.is_autocommit());
8081 assert_eq!(
8082 client
8083 .query("SELECT count(*) AS n FROM tasks", &[])
8084 .unwrap()[0]["n"],
8085 0
8086 );
8087 }
8088
8089 #[test]
8090 fn import_chunks_with_unique_constraints_match_full_pending_replay() {
8091 for images in [false, true] {
8092 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8093 "name":name,"primaryKey":"id","columns":[
8094 {"name":"id","type":"string","nullable":false},
8095 {"name":"project_id","type":"string","nullable":false},
8096 {"name":"title","type":"string","nullable":false}],
8097 "scopes":[{"pattern":"project:{project_id}"}],
8098 "indexes":[{"name":format!("{name}_unique_title"),"columns":["title"],"unique":true}],
8099 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8100 })))});
8101 let mut actual =
8102 SyncClient::new("actual".into(), &schema, ClientLimits::default()).unwrap();
8103 let mut reference =
8104 SyncClient::new("reference".into(), &schema, ClientLimits::default()).unwrap();
8105 let scopes = vec![("project_id".into(), vec!["p1".into()])];
8106 for client in [&mut actual, &mut reference] {
8107 client
8108 .subscribe("tasks".into(), "tasks".into(), scopes.clone(), None)
8109 .unwrap();
8110 client.subs[0].effective = Some(scopes.clone());
8111 for (table, id, title) in [
8112 ("tasks", "a", "original"),
8113 ("tasks", "b", "occupied"),
8114 ("tasks", "c", "deleted"),
8115 ("notes", "n", "untouched"),
8116 ] {
8117 client
8118 .write_base_row(
8119 table,
8120 &vec![
8121 Some(ColumnValue::String(id.into())),
8122 Some(ColumnValue::String("p1".into())),
8123 Some(ColumnValue::String(title.into())),
8124 ],
8125 1,
8126 )
8127 .unwrap();
8128 }
8129 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8130 client
8131 .mutate(vec![
8132 Mutation::Upsert {
8133 table: "tasks".into(),
8134 values: Map::from_iter([
8135 ("id".into(), Value::from("a")),
8136 ("project_id".into(), Value::from("p1")),
8137 ("title".into(), Value::from("available")),
8138 ]),
8139 base_version: None,
8140 },
8141 Mutation::Delete {
8142 table: "tasks".into(),
8143 row_id: "c".into(),
8144 base_version: None,
8145 },
8146 Mutation::Upsert {
8147 table: "tasks".into(),
8148 values: Map::from_iter([
8149 ("id".into(), Value::from("local")),
8150 ("project_id".into(), Value::from("p1")),
8151 ("title".into(), Value::from("local value")),
8152 ]),
8153 base_version: None,
8154 },
8155 Mutation::Upsert {
8156 table: "notes".into(),
8157 values: Map::from_iter([
8158 ("id".into(), Value::from("n")),
8159 ("project_id".into(), Value::from("p1")),
8160 ("title".into(), Value::from("pending note")),
8161 ]),
8162 base_version: None,
8163 },
8164 ])
8165 .unwrap();
8166 }
8167 let pending = actual.pending_commit_ids();
8168 actual.overlay_rebuild_count.set(0);
8169 for (id, title) in [
8170 ("b", "available"),
8171 ("b", "occupied"),
8172 ("a", "server original"),
8173 ("d", "local value"),
8174 ("d", "released"),
8175 ("c", "redelivered"),
8176 ] {
8177 let table = actual.schema.table("tasks").unwrap().clone();
8178 let values = vec![
8179 Some(ColumnValue::String(id.into())),
8180 Some(ColumnValue::String("p1".into())),
8181 Some(ColumnValue::String(title.into())),
8182 ];
8183 if images {
8184 let image = Connection::open_in_memory().unwrap();
8185 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8186 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8187 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8188 image
8189 .execute("INSERT INTO tasks VALUES (?1,'p1',?2,2)", [id, title])
8190 .unwrap();
8191 assert!(matches!(
8192 actual.apply_sqlite_image(&image, &table, false, &scopes, 1, 7, "digest"),
8193 Ok(1)
8194 ));
8195 } else {
8196 let segment = RowsSegment {
8197 table: "tasks".into(),
8198 schema_version: 1,
8199 columns: table.wire_columns.clone(),
8200 blocks: vec![vec![ssp2::segment::SegmentRow {
8201 server_version: 2,
8202 values: values.clone(),
8203 }]],
8204 };
8205 assert!(matches!(actual.apply_segment(0, &segment, false), Ok(1)));
8206 }
8207 reference.write_base_row("tasks", &values, 2).unwrap();
8208 reference.rebuild_overlay().expect("rebuild overlay");
8209 for table in ["tasks", "notes"] {
8210 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")] {
8211 assert_eq!(actual.query(&sql, &[]).unwrap(), reference.query(&sql, &[]).unwrap(), "images={images}, {id}/{title}: {sql}");
8212 }
8213 }
8214 assert_eq!(actual.pending_commit_ids(), pending);
8215 }
8216 assert_eq!(
8217 actual.overlay_rebuild_count.get(),
8218 0,
8219 "each chunk must leave unrelated tables alone"
8220 );
8221 let before = actual
8222 .query("SELECT * FROM tasks ORDER BY id", &[])
8223 .unwrap();
8224 let fts_before = actual
8225 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8226 .unwrap();
8227 let revision = actual.local_revision();
8228 actual.conn.execute_batch("CREATE TRIGGER fail_import BEFORE INSERT ON tasks WHEN new.id='b' BEGIN SELECT RAISE(ABORT,'restore failed'); END").unwrap();
8229 let table = actual.schema.table("tasks").unwrap().clone();
8230 let segment = RowsSegment {
8231 table: "tasks".into(),
8232 schema_version: 1,
8233 columns: table.wire_columns.clone(),
8234 blocks: vec![vec![ssp2::segment::SegmentRow {
8235 server_version: 3,
8236 values: vec![
8237 Some(ColumnValue::String("b".into())),
8238 Some(ColumnValue::String("p1".into())),
8239 Some(ColumnValue::String("fresh".into())),
8240 ],
8241 }]],
8242 };
8243 assert!(actual.apply_segment(0, &segment, false).is_err());
8244 assert!(actual.conn.is_autocommit());
8245 assert_eq!(
8246 actual
8247 .query("SELECT * FROM tasks ORDER BY id", &[])
8248 .unwrap(),
8249 before
8250 );
8251 assert_eq!(
8252 actual
8253 .query("SELECT * FROM tasks_fts ORDER BY _syncular_source_id", &[])
8254 .unwrap(),
8255 fts_before
8256 );
8257 assert_eq!(actual.local_revision(), revision);
8258 assert_eq!(actual.pending_commit_ids(), pending);
8259 }
8260 }
8261
8262 #[test]
8263 fn import_reconciliation_preserves_typed_pending_primary_keys() {
8264 for (kind, a, b) in [
8265 ("string", json!("a"), json!("b")),
8266 ("integer", json!(1), json!(2)),
8267 ("boolean", json!(true), json!(false)),
8268 ("json", json!("1"), json!("2")),
8269 ] {
8270 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}]}]});
8271 for images in [false, true] {
8272 let mut client =
8273 SyncClient::new("typed".into(), &schema, ClientLimits::default()).unwrap();
8274 let table = client.schema.table("tasks").unwrap().clone();
8275 client
8276 .subscribe("tasks".into(), "tasks".into(), vec![], None)
8277 .unwrap();
8278 for (id, title) in [(&a, "original"), (&b, "occupied")] {
8279 client
8280 .write_base_row(
8281 "tasks",
8282 &vec![
8283 json_to_column_value(&table.columns[0], Some(id)).unwrap(),
8284 Some(ColumnValue::String(title.into())),
8285 ],
8286 1,
8287 )
8288 .unwrap();
8289 }
8290 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
8291 client
8292 .mutate(vec![Mutation::Upsert {
8293 table: "tasks".into(),
8294 values: Map::from_iter([
8295 ("id".into(), a.clone()),
8296 ("title".into(), json!("available")),
8297 ]),
8298 base_version: None,
8299 }])
8300 .unwrap();
8301 client.overlay_rebuild_count.set(0);
8302 for (title, expected) in [("available", "original"), ("occupied", "available")] {
8303 let values = vec![
8304 json_to_column_value(&table.columns[0], Some(&b)).unwrap(),
8305 Some(ColumnValue::String(title.into())),
8306 ];
8307 if images {
8308 let image = Connection::open_in_memory().unwrap();
8309 image.execute_batch("CREATE TABLE tasks(id PRIMARY KEY, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8310 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);
8311 INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',1);").unwrap();
8312 image
8313 .execute(
8314 "INSERT INTO tasks VALUES (?1,?2,2)",
8315 rusqlite::params![RowParam::Cell(&values[0]), title],
8316 )
8317 .unwrap();
8318 assert!(
8319 matches!(
8320 client.apply_sqlite_image(
8321 &image,
8322 &table,
8323 false,
8324 &[],
8325 1,
8326 7,
8327 "digest"
8328 ),
8329 Ok(1)
8330 ),
8331 "{kind}, images={images}"
8332 );
8333 } else {
8334 let segment = RowsSegment {
8335 table: "tasks".into(),
8336 schema_version: 1,
8337 columns: table.wire_columns.clone(),
8338 blocks: vec![vec![ssp2::segment::SegmentRow {
8339 server_version: 2,
8340 values,
8341 }]],
8342 };
8343 assert!(
8344 matches!(client.apply_segment(0, &segment, false), Ok(1)),
8345 "{kind}"
8346 );
8347 }
8348 let key = json_to_column_value(&table.columns[0], Some(&a)).unwrap();
8349 let actual: String = client
8350 .conn
8351 .query_row(
8352 "SELECT title FROM tasks WHERE id=?1",
8353 [RowParam::Cell(&key)],
8354 |row| row.get(0),
8355 )
8356 .unwrap();
8357 assert_eq!(actual, expected, "{kind}, images={images}");
8358 }
8359 assert_eq!(client.overlay_rebuild_count.get(), 0);
8360 }
8361 }
8362 }
8363
8364 fn unique_import_fixture(rows: usize) -> (SyncClient, Connection, TableSchema) {
8365 let schema = json!({"version":1,"tables": (["tasks", "notes"].map(|name| json!({
8366 "name":name,"primaryKey":"id","columns":[{"name":"id","type":"string","nullable":false},{"name":"project_id","type":"string","nullable":false},{"name":"title","type":"string","nullable":false}],
8367 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[{"name":format!("{name}_title"),"columns":["title"],"unique":true}],
8368 "ftsIndexes":[{"name":format!("{name}_fts"),"columns":["title"],"tokenize":"unicode61"}]
8369 })))});
8370 let mut client =
8371 SyncClient::new("unique-import".into(), &schema, ClientLimits::default()).unwrap();
8372 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();
8373 client.rebuild_overlay().expect("rebuild overlay");
8374 client
8375 .mutate(vec![Mutation::Upsert {
8376 table: "tasks".into(),
8377 values: Map::from_iter([
8378 ("id".into(), json!("offline")),
8379 ("project_id".into(), json!("p1")),
8380 ("title".into(), json!("pending edit")),
8381 ]),
8382 base_version: None,
8383 }])
8384 .unwrap();
8385 let image = Connection::open_in_memory().unwrap();
8386 image.execute_batch("CREATE TABLE tasks(id TEXT PRIMARY KEY, project_id TEXT NOT NULL, title TEXT NOT NULL, _syncular_version INTEGER NOT NULL);
8387 CREATE TABLE _syncular_segment(format INTEGER, \"table\" TEXT, \"schemaVersion\" INTEGER, \"asOfCommitSeq\" INTEGER, \"scopeDigest\" TEXT, \"rowCount\" INTEGER);").unwrap();
8388 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();
8389 image
8390 .execute(
8391 "INSERT INTO _syncular_segment VALUES (1,'tasks',1,7,'digest',?1)",
8392 [rows],
8393 )
8394 .unwrap();
8395 client.overlay_rebuild_count.set(0);
8396 let table = client.schema.table("tasks").unwrap().clone();
8397 (client, image, table)
8398 }
8399
8400 #[test]
8401 fn sqlite_full_image_import_retains_the_first_code_and_recovers() {
8402 let (mut client, image, table) = unique_import_fixture(1);
8403 image
8404 .execute("UPDATE tasks SET title = ?1", ["full ".repeat(32768)])
8405 .unwrap();
8406 client
8407 .conn
8408 .execute_batch("PRAGMA max_page_count = 1")
8409 .unwrap();
8410 let effective = [("project_id".into(), vec!["p1".into()])];
8411 assert!(client
8412 .apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest")
8413 .is_err());
8414 let failure = client.storage_failure.borrow().clone().unwrap();
8415 assert_eq!(failure.code, Some("client.storage_full"));
8416 assert_eq!(failure.sqlite_code, Some(13));
8417 assert!(!failure.message.contains("rollback"));
8418 assert!(client.conn.is_autocommit());
8419 client
8420 .conn
8421 .execute_batch("PRAGMA max_page_count = 1073741823")
8422 .unwrap();
8423 assert!(matches!(
8424 client.apply_sqlite_image(&image, &table, true, &effective, 1, 7, "digest"),
8425 Ok(1)
8426 ));
8427 assert!(client.conn.is_autocommit());
8428 }
8429
8430 #[test]
8431 fn authoring_storage_failures_are_structured_and_scoped() {
8432 let path = std::env::temp_dir().join(format!(
8433 "syncular-authoring-failure-{}.db",
8434 uuid::Uuid::new_v4()
8435 ));
8436 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8437 {"name":"id","type":"string","nullable":false},
8438 {"name":"project_id","type":"string","nullable":false},
8439 {"name":"title","type":"string","nullable":false}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8440 let mut client = SyncClient::open_path(
8441 "authoring".into(),
8442 &schema,
8443 ClientLimits::default(),
8444 path.to_str().unwrap(),
8445 )
8446 .expect("open client");
8447 client.create_synced_tables().expect("create synced tables");
8448 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
8450
8451 let upsert = |id: &str| {
8452 vec![Mutation::Upsert {
8453 table: "tasks".into(),
8454 values: Map::from_iter([
8455 ("id".into(), json!(id)),
8456 ("project_id".into(), json!("p1")),
8457 ("title".into(), json!("first")),
8458 ]),
8459 base_version: None,
8460 }]
8461 };
8462
8463 let locker = Connection::open(&path).expect("second connection");
8464 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8465 let busy = client.mutate(upsert("t1")).expect_err("busy write");
8466 assert_eq!(busy.code, "client.storage_busy");
8467 assert!(busy.retryable);
8468 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
8469 assert!(client.pending_commit_ids().is_empty());
8470 assert_eq!(client.local_revision(), 0);
8471
8472 let invalid = client.mutate(Vec::new()).expect_err("empty commit");
8474 assert_eq!(invalid.code, "sync.invalid_request");
8475 assert_eq!(invalid.message, "the authoring request is invalid");
8476 assert!(
8477 invalid.details.as_ref().unwrap()["legacyCause"]
8478 .as_str()
8479 .unwrap()
8480 .contains("at least one operation"),
8481 "{invalid:?}"
8482 );
8483
8484 locker.execute_batch("ROLLBACK").unwrap();
8485 let id = client.mutate(upsert("t1")).expect("retry after busy");
8486 assert_eq!(client.pending_commit_ids(), vec![id]);
8487 assert_eq!(client.local_revision(), 1);
8488
8489 locker.execute_batch("BEGIN IMMEDIATE").unwrap();
8491 let busy_again = client.mutate(upsert("t2")).expect_err("busy write again");
8492 assert_eq!(busy_again.code, "client.storage_busy");
8493 assert!(busy_again.retryable);
8494 locker.execute_batch("ROLLBACK").unwrap();
8495
8496 let full = ClientError::from(QueryReadFailure::from(rusqlite::Error::SqliteFailure(
8498 rusqlite::ffi::Error::new(13),
8499 Some("database or disk is full".to_owned()),
8500 )));
8501 assert_eq!(full.code, "client.storage_full");
8502 assert!(!full.retryable);
8503 assert_eq!(full.details.as_ref().unwrap()["sqliteCode"], 13);
8504
8505 drop(locker);
8506 drop(client);
8507 let _ = std::fs::remove_file(&path);
8508 }
8509
8510 #[test]
8511 fn caller_value_failures_are_invalid_request_and_leave_no_outbox_entry() {
8512 let path = std::env::temp_dir().join(format!(
8513 "syncular-caller-values-{}.db",
8514 uuid::Uuid::new_v4()
8515 ));
8516 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8517 {"name":"id","type":"string","nullable":false},
8518 {"name":"project_id","type":"string","nullable":false},
8519 {"name":"title","type":"string","nullable":false},
8520 {"name":"priority","type":"integer","nullable":false},
8521 {"name":"payload","type":"bytes","nullable":false},
8522 {"name":"extra","type":"bytes","nullable":true}],"scopes":[{"pattern":"project:{project_id}"}]}]});
8523 let mut client = SyncClient::open_path(
8524 "caller-values".into(),
8525 &schema,
8526 ClientLimits::default(),
8527 path.to_str().unwrap(),
8528 )
8529 .expect("open client");
8530 client.create_synced_tables().expect("create synced tables");
8531
8532 let values = |id: &str| {
8533 Map::from_iter([
8534 ("id".to_owned(), json!(id)),
8535 ("project_id".to_owned(), json!("p1")),
8536 ("title".to_owned(), json!("first")),
8537 ("priority".to_owned(), json!(1)),
8538 ("payload".to_owned(), json!({"$bytes": "0a"})),
8539 ])
8540 };
8541 let upsert = |values: Map<String, Value>| {
8542 vec![Mutation::Upsert {
8543 table: "tasks".into(),
8544 values,
8545 base_version: None,
8546 }]
8547 };
8548 let seed = client.mutate(upsert(values("t1"))).expect("seed row");
8549 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
8550
8551 for (label, override_value, cause) in [
8556 ("unknown column", ("nope", json!(1)), "unknown column"),
8557 (
8558 "internal sync column",
8559 ("_sync_version", json!(1)),
8560 "internal sync column",
8561 ),
8562 ("wrong value type", ("title", json!(7)), "expected a string"),
8563 (
8564 "malformed bytes envelope",
8565 ("payload", json!({"$bytes": "zz"})),
8566 "expected exactly",
8567 ),
8568 (
8569 "odd-length bytes envelope",
8570 ("payload", json!({"$bytes": "abc"})),
8571 "expected exactly",
8572 ),
8573 (
8574 "plus-sign bytes envelope",
8575 ("payload", json!({"$bytes": "+a"})),
8576 "expected exactly",
8577 ),
8578 (
8579 "bytes envelope with a second key",
8580 ("payload", json!({"$bytes": "0a", "extra": 1})),
8581 "expected exactly",
8582 ),
8583 (
8584 "no bytes envelope",
8585 ("payload", json!({"nested": true})),
8586 "expected exactly",
8587 ),
8588 ] {
8589 let mut full = values("t2");
8590 full.insert(override_value.0.to_owned(), override_value.1);
8591 let error = client.mutate(upsert(full)).expect_err(label);
8592 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8593 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8594 assert!(!error.retryable, "{label}");
8595 let legacy = error.details.as_ref().unwrap()["legacyCause"]
8596 .as_str()
8597 .unwrap();
8598 assert!(
8599 legacy.starts_with("sync.invalid_request: ") && legacy.contains(cause),
8600 "{label}: {legacy}"
8601 );
8602 assert_eq!(client.pending_commit_ids(), vec![seed.clone()], "{label}");
8603 assert_eq!(client.local_revision(), 1, "{label}");
8604 assert_eq!(
8605 client.query("SELECT * FROM tasks", &[]).unwrap(),
8606 rows,
8607 "{label}"
8608 );
8609 }
8610
8611 let mut missing = values("t3");
8614 missing.remove("payload");
8615 let missing_error = client.mutate(upsert(missing)).expect_err("missing column");
8616 assert_eq!(missing_error.code, "sync.invalid_request");
8617 assert!(missing_error.details.as_ref().unwrap()["legacyCause"]
8618 .as_str()
8619 .unwrap()
8620 .contains("is not nullable"));
8621
8622 let mut bad_row_id = values("t4");
8625 bad_row_id.insert("id".to_owned(), json!({"nested": true}));
8626 let row_id_error = client.mutate(upsert(bad_row_id)).expect_err("bad rowId");
8627 assert_eq!(row_id_error.code, "sync.invalid_request");
8628
8629 for (label, partial, cause) in [
8631 (
8632 "patch wrong value type",
8633 ("priority", json!("high")),
8634 "expected an integer",
8635 ),
8636 ("patch unknown column", ("nope", json!(1)), "unknown column"),
8637 ] {
8638 let error = client
8639 .patch(
8640 "tasks",
8641 "t1",
8642 Map::from_iter([(partial.0.to_owned(), partial.1)]),
8643 None,
8644 )
8645 .expect_err(label);
8646 assert_eq!(error.code, "sync.invalid_request", "{label}: {error:?}");
8647 assert_eq!(error.message, "the authoring request is invalid", "{label}");
8648 assert!(
8649 error.details.as_ref().unwrap()["legacyCause"]
8650 .as_str()
8651 .unwrap()
8652 .contains(cause),
8653 "{label}"
8654 );
8655 }
8656 assert_eq!(client.pending_commit_ids(), vec![seed.clone()]);
8657 assert_eq!(client.local_revision(), 1);
8658 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
8659
8660 for (label, payload) in [
8663 ("explicit null", json!(null)),
8664 ("empty envelope", json!({"$bytes": ""})),
8665 ("uppercase envelope", json!({"$bytes": "0AFF"})),
8666 ] {
8667 let mut accepted = values("t-ok");
8668 accepted.insert("extra".to_owned(), payload);
8669 let encoded = client.mutate(upsert(accepted)).unwrap_or_else(|error| {
8670 panic!("{label} must be accepted: {error:?}");
8671 });
8672 assert!(!encoded.is_empty(), "{label}");
8673 }
8674
8675 let next = client
8677 .mutate(upsert(values("t5")))
8678 .expect("valid after failures");
8679 assert_ne!(next, seed);
8680
8681 drop(client);
8682 let _ = std::fs::remove_file(&path);
8683 }
8684
8685 #[test]
8686 fn a_legacy_outbox_value_the_codec_refuses_is_dropped_instead_of_wedging() {
8687 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8688 {"name":"id","type":"string","nullable":false},
8689 {"name":"project_id","type":"string","nullable":false},
8690 {"name":"payload","type":"bytes","nullable":true}],
8691 "scopes":[{"pattern":"project:{project_id}"}]}]});
8692 let open = |path: &std::path::Path| {
8693 SyncClient::open_path(
8694 "legacy-values".into(),
8695 &schema,
8696 ClientLimits::default(),
8697 path.to_str().unwrap(),
8698 )
8699 .expect("open client")
8700 };
8701 let row = |id: &str| {
8702 Map::from_iter([
8703 ("id".to_owned(), json!(id)),
8704 ("project_id".to_owned(), json!("p1")),
8705 ("payload".to_owned(), json!({"$bytes": "0a"})),
8706 ])
8707 };
8708 let upsert = |values| {
8709 vec![Mutation::Upsert {
8710 table: "tasks".into(),
8711 values,
8712 base_version: None,
8713 }]
8714 };
8715 for (label, stored_cell, representable) in [
8716 ("plus-sign envelope", json!({"$bytes": "+a"}), false),
8717 ("non-hex envelope", json!({"$bytes": "zz"}), false),
8718 ("odd-length envelope", json!({"$bytes": "abc"}), false),
8719 ("no envelope", json!({"nested": true}), false),
8720 ("wrong declared type", json!("high"), true),
8724 ("stored null for a non-nullable column", json!(null), true),
8725 ] {
8726 let path = std::env::temp_dir().join(format!(
8727 "syncular-legacy-values-{}-{}.db",
8728 label.replace(' ', "-"),
8729 uuid::Uuid::new_v4()
8730 ));
8731 let mut client = open(&path);
8732 client.create_synced_tables().expect("create synced tables");
8733 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
8734 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8735 drop(client);
8736
8737 let connection = Connection::open(&path).expect("raw connection");
8739 let (column, cell) = if stored_cell.is_null() {
8740 ("project_id", stored_cell.clone())
8741 } else {
8742 ("payload", stored_cell.clone())
8743 };
8744 let mut planted_values = json!({
8745 "id": "t1", "project_id": "p1", "payload": {"$bytes": "0a"}
8746 });
8747 planted_values[column] = cell;
8748 let planted = json!([{
8749 "op": "upsert",
8750 "table": "tasks",
8751 "rowId": "t1",
8752 "baseVersion": null,
8753 "values": planted_values
8754 }]);
8755 connection
8756 .execute(
8757 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8758 rusqlite::params![planted.to_string(), stale],
8759 )
8760 .expect("plant legacy intent");
8761 drop(connection);
8762
8763 let reopened = open(&path);
8766 assert_eq!(
8767 reopened.pending_commit_ids(),
8768 vec![successor.clone()],
8769 "{label}"
8770 );
8771 let rejection = reopened
8772 .rejections()
8773 .iter()
8774 .find(|rejection| rejection.client_commit_id == stale)
8775 .unwrap_or_else(|| panic!("{label}: rejection"));
8776 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
8777 assert_eq!(
8778 rejection.message, "the persisted commit carries values the current codec refuses",
8779 "{label}"
8780 );
8781 assert_eq!(rejection.operation.is_some(), representable, "{label}");
8782 assert_eq!(
8783 rejection
8784 .details
8785 .as_ref()
8786 .and_then(|details| details.reason.as_deref()),
8787 Some(INVALID_STORED_VALUES_REASON),
8788 "{label}"
8789 );
8790 let journal = reopened
8792 .commit_outcome(&stale)
8793 .expect("read outcome")
8794 .expect("journal entry");
8795 assert!(matches!(journal.status, CommitOutcomeStatus::Rejected));
8796 drop(reopened);
8797 let mut reopened = open(&path);
8798 let reread = reopened
8799 .commit_outcome(&stale)
8800 .expect("read outcome")
8801 .expect("journal entry");
8802 assert!(matches!(reread.status, CommitOutcomeStatus::Rejected));
8803
8804 assert_eq!(
8807 reopened
8808 .query("SELECT id FROM tasks ORDER BY id", &[])
8809 .unwrap(),
8810 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8811 "{label}"
8812 );
8813 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8814 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
8815 assert!(!reopened
8816 .drop_unencodable_outbox(false, None)
8817 .expect("idempotent prepass"));
8818 let mut transport = CountingRealtimeTransport::default();
8819 let outcome = reopened.sync(&mut transport);
8820 assert!(matches!(outcome, SyncOutcome::Failed { .. }), "{outcome:?}");
8821 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
8822 let next = reopened.mutate(upsert(row("t3"))).expect("after the drop");
8823 assert_eq!(reopened.pending_commit_ids(), vec![successor, next]);
8824 assert_eq!(reopened.pending_payloads().len(), 2, "{label}");
8825 drop(reopened);
8826 let _ = std::fs::remove_file(&path);
8827 }
8828 }
8829
8830 #[test]
8831 fn two_legacy_values_on_the_same_row_recover_in_fifo_order() {
8832 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8833 {"name":"id","type":"string","nullable":false},
8834 {"name":"project_id","type":"string","nullable":false},
8835 {"name":"payload","type":"bytes","nullable":true}],
8836 "scopes":[{"pattern":"project:{project_id}"}]}]});
8837 for (label, column, planted) in [
8838 ("malformed envelope", "payload", json!({"$bytes": "zz"})),
8839 (
8840 "stored null for a non-nullable column",
8841 "project_id",
8842 json!(null),
8843 ),
8844 ("wrong declared type", "payload", json!(7)),
8845 ] {
8846 let path = std::env::temp_dir().join(format!(
8847 "syncular-shared-row-{}-{}.db",
8848 label.replace(' ', "-"),
8849 uuid::Uuid::new_v4()
8850 ));
8851 let open = |path: &std::path::Path| {
8852 SyncClient::open_path(
8853 "shared-row".into(),
8854 &schema,
8855 ClientLimits::default(),
8856 path.to_str().unwrap(),
8857 )
8858 .expect("open client")
8859 };
8860 let row = |id: &str| {
8861 Map::from_iter([
8862 ("id".to_owned(), json!(id)),
8863 ("project_id".to_owned(), json!("p1")),
8864 ("payload".to_owned(), json!({"$bytes": "0a"})),
8865 ])
8866 };
8867 let upsert = |values| {
8868 vec![Mutation::Upsert {
8869 table: "tasks".into(),
8870 values,
8871 base_version: None,
8872 }]
8873 };
8874 let mut client = open(&path);
8875 client.create_synced_tables().expect("create synced tables");
8876 let older = client.mutate(upsert(row("t1"))).expect("older commit");
8877 let newer = client.mutate(upsert(row("t1"))).expect("newer commit");
8878 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
8879 drop(client);
8880
8881 let connection = Connection::open(&path).expect("raw connection");
8882 for commit_id in [&older, &newer] {
8883 let mut values = row("t1");
8884 values.insert(column.to_owned(), planted.clone());
8885 let planted_ops = json!([{
8886 "op": "upsert",
8887 "table": "tasks",
8888 "rowId": "t1",
8889 "baseVersion": null,
8890 "values": values
8891 }]);
8892 connection
8893 .execute(
8894 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
8895 rusqlite::params![planted_ops.to_string(), commit_id],
8896 )
8897 .expect("plant legacy intent");
8898 }
8899 drop(connection);
8900
8901 let mut reopened = open(&path);
8903 assert_eq!(
8904 reopened.pending_commit_ids(),
8905 vec![successor.clone()],
8906 "{label}"
8907 );
8908 assert_eq!(
8909 reopened
8910 .rejections()
8911 .iter()
8912 .map(|rejection| rejection.client_commit_id.clone())
8913 .collect::<Vec<_>>(),
8914 vec![older.clone(), newer.clone()],
8915 "{label}"
8916 );
8917 assert!(
8918 reopened
8919 .rejections()
8920 .iter()
8921 .all(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE
8922 && rejection.details.as_ref().and_then(|d| d.reason.as_deref())
8923 == Some(INVALID_STORED_VALUES_REASON)),
8924 "{label}"
8925 );
8926 for commit_id in [&older, &newer] {
8927 assert!(
8928 matches!(
8929 reopened
8930 .commit_outcome(commit_id)
8931 .expect("outcome")
8932 .map(|o| o.status),
8933 Some(CommitOutcomeStatus::Rejected)
8934 ),
8935 "{label}"
8936 );
8937 }
8938 assert_eq!(
8939 reopened
8940 .query("SELECT id FROM tasks ORDER BY id", &[])
8941 .unwrap(),
8942 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
8943 "{label}"
8944 );
8945 let revision = reopened.local_revision();
8946 let batches = reopened.drain_change_batches();
8947 assert!(
8948 batches.iter().any(|batch| batch.rejections_changed
8949 && batch.outcomes_changed
8950 && batch.tables.iter().any(|change| change.table == "tasks")
8951 && batch.revision == revision.to_string()),
8952 "{label}: {batches:?}"
8953 );
8954 drop(reopened);
8955
8956 let mut stable = open(&path);
8959 assert_eq!(stable.local_revision(), revision, "{label}");
8960 assert!(stable.drain_change_batches().is_empty(), "{label}");
8961 assert_eq!(stable.pending_commit_ids(), vec![successor], "{label}");
8962 drop(stable);
8963 let _ = std::fs::remove_file(&path);
8964 }
8965 }
8966
8967 #[test]
8968 fn recovery_omits_a_legacy_operation_the_journal_reader_rejects() {
8969 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
8970 {"name":"id","type":"string","nullable":false},
8971 {"name":"project_id","type":"string","nullable":false},
8972 {"name":"payload","type":"bytes","nullable":true}],
8973 "scopes":[{"pattern":"project:{project_id}"}]}]});
8974 for (label, second_op) in [
8975 (
8976 "delete carrying values",
8977 json!({"op": "delete", "table": "tasks", "rowId": "t9", "values": {}}),
8978 ),
8979 (
8980 "baseVersion beyond the safe bound",
8981 json!({"op": "upsert", "table": "tasks", "rowId": "t9",
8982 "baseVersion": MAX_JS_SAFE_INTEGER + 1,
8983 "values": {"id": "t9", "project_id": "p1", "payload": {"$bytes": "0a"}}}),
8984 ),
8985 ] {
8986 let path = std::env::temp_dir().join(format!(
8987 "syncular-journal-shape-{}-{}.db",
8988 label.replace(' ', "-"),
8989 uuid::Uuid::new_v4()
8990 ));
8991 let open = |path: &std::path::Path| {
8992 SyncClient::open_path(
8993 "journal-shape".into(),
8994 &schema,
8995 ClientLimits::default(),
8996 path.to_str().unwrap(),
8997 )
8998 .expect("open client")
8999 };
9000 let values = |id: &str| {
9001 Map::from_iter([
9002 ("id".to_owned(), json!(id)),
9003 ("project_id".to_owned(), json!("p1")),
9004 ("payload".to_owned(), json!({"$bytes": "0a"})),
9005 ])
9006 };
9007 let upsert = |values| {
9008 vec![Mutation::Upsert {
9009 table: "tasks".into(),
9010 values,
9011 base_version: None,
9012 }]
9013 };
9014 let mut client = open(&path);
9015 client.create_synced_tables().expect("create synced tables");
9016 let stale = client.mutate(upsert(values("t1"))).expect("stale commit");
9017 drop(client);
9018
9019 let connection = Connection::open(&path).expect("raw connection");
9020 let planted = json!([
9024 {
9025 "op": "upsert",
9026 "table": "tasks",
9027 "rowId": "t1",
9028 "baseVersion": null,
9029 "values": {"id": "t1", "project_id": null,
9030 "payload": {"$bytes": "0a"}}
9031 },
9032 second_op
9033 ]);
9034 connection
9035 .execute(
9036 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9037 rusqlite::params![planted.to_string(), stale],
9038 )
9039 .expect("plant legacy intent");
9040 drop(connection);
9041
9042 let reopened = open(&path);
9043 let journal = reopened
9044 .commit_outcome(&stale)
9045 .unwrap_or_else(|error| panic!("{label}: journal read: {error}"))
9046 .unwrap_or_else(|| panic!("{label}: journal entry"));
9047 assert!(
9048 matches!(journal.status, CommitOutcomeStatus::Rejected),
9049 "{label}"
9050 );
9051 assert!(journal.operations.is_none(), "{label}");
9052 assert!(
9053 reopened
9054 .commit_outcomes(CommitOutcomeQuery::default())
9055 .is_ok(),
9056 "{label}: outcome list query"
9057 );
9058 assert!(reopened.pending_commit_ids().is_empty(), "{label}");
9059 drop(reopened);
9060 let _ = std::fs::remove_file(&path);
9061 }
9062 }
9063
9064 #[test]
9065 fn legacy_values_recover_at_the_recreate_and_bump_reset_boundaries() {
9066 let schema = |version: i32, extra: bool| {
9067 let mut columns = vec![
9068 json!({"name":"id","type":"string","nullable":false}),
9069 json!({"name":"project_id","type":"string","nullable":false}),
9070 json!({"name":"payload","type":"bytes","nullable":true}),
9071 ];
9072 if extra {
9073 columns.push(json!({"name":"later","type":"string","nullable":true}));
9074 }
9075 json!({"version":version,"tables":[{"name":"tasks","primaryKey":"id","columns":columns,
9076 "scopes":[{"pattern":"project:{project_id}"}]}]})
9077 };
9078 for (label, bump) in [
9079 ("same-version recreation", false),
9080 ("version-bump reset", true),
9081 ("version-only bump reset", true),
9082 ] {
9083 let path = std::env::temp_dir().join(format!(
9084 "syncular-legacy-reset-{}-{}.db",
9085 label.replace(' ', "-"),
9086 uuid::Uuid::new_v4()
9087 ));
9088 let v1 = schema(1, false);
9089 let open = |schema: &Value, path: &std::path::Path| {
9090 SyncClient::open_path(
9091 "legacy-reset".into(),
9092 schema,
9093 ClientLimits::default(),
9094 path.to_str().unwrap(),
9095 )
9096 .expect("open client")
9097 };
9098 let row = |id: &str| {
9099 Map::from_iter([
9100 ("id".to_owned(), json!(id)),
9101 ("project_id".to_owned(), json!("p1")),
9102 ("payload".to_owned(), json!({"$bytes": "0a"})),
9103 ])
9104 };
9105 let upsert = |values| {
9106 vec![Mutation::Upsert {
9107 table: "tasks".into(),
9108 values,
9109 base_version: None,
9110 }]
9111 };
9112 let mut client = open(&v1, &path);
9113 client.create_synced_tables().expect("create synced tables");
9114 let stale = client.mutate(upsert(row("t1"))).expect("stale commit");
9115 let successor = client.mutate(upsert(row("t2"))).expect("successor commit");
9116 drop(client);
9117
9118 let connection = Connection::open(&path).expect("raw connection");
9119 let planted = json!([{
9120 "op": "upsert",
9121 "table": "tasks",
9122 "rowId": "t1",
9123 "baseVersion": null,
9124 "values": {"id": "t1", "project_id": 7, "payload": {"$bytes": "0a"}}
9125 }]);
9126 connection
9127 .execute(
9128 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9129 rusqlite::params![planted.to_string(), stale],
9130 )
9131 .expect("plant legacy intent");
9132 drop(connection);
9133
9134 let target = if bump {
9135 schema(2, label == "version-bump reset")
9136 } else {
9137 v1.clone()
9138 };
9139 let reopened = open(&target, &path);
9140 let rejection = reopened
9141 .rejections()
9142 .iter()
9143 .find(|rejection| rejection.client_commit_id == stale)
9144 .unwrap_or_else(|| panic!("{label}: rejection"));
9145 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9146 assert_eq!(
9147 rejection
9148 .details
9149 .as_ref()
9150 .and_then(|details| details.reason.as_deref()),
9151 Some(INVALID_STORED_VALUES_REASON),
9152 "{label}"
9153 );
9154 assert!(rejection.operation.is_some(), "{label}");
9155 assert_eq!(
9156 reopened.pending_commit_ids(),
9157 vec![successor.clone()],
9158 "{label}"
9159 );
9160 assert_eq!(reopened.pending_payloads().len(), 1, "{label}");
9164 if label == "version-bump reset" {
9165 assert_eq!(
9166 reopened
9167 .query("SELECT id, later FROM tasks ORDER BY id", &[])
9168 .unwrap(),
9169 vec![Map::from_iter([
9170 ("id".to_owned(), json!("t2")),
9171 ("later".to_owned(), Value::Null),
9172 ])],
9173 "{label}"
9174 );
9175 let mut next = reopened;
9179 let revision = next.local_revision();
9180 let batches = next.drain_change_batches();
9181 assert!(
9182 batches.iter().any(|batch| batch.rejections_changed
9183 && batch.outcomes_changed
9184 && batch.tables.iter().any(|change| change.table == "tasks")
9185 && batch.revision == revision.to_string()),
9186 "{label}: {batches:?}"
9187 );
9188 next.mutate(upsert(Map::from_iter([
9189 ("id".to_owned(), json!("t3")),
9190 ("project_id".to_owned(), json!("p1")),
9191 ("payload".to_owned(), json!({"$bytes": "0b"})),
9192 ("later".to_owned(), json!("v2")),
9193 ])))
9194 .expect("v2 authoring after the reset");
9195 assert_eq!(next.pending_commit_ids().len(), 2, "{label}");
9196 assert_eq!(next.pending_payloads().len(), 2, "{label}");
9197 drop(next);
9198 } else {
9199 assert_eq!(
9200 reopened
9201 .query("SELECT id FROM tasks ORDER BY id", &[])
9202 .unwrap(),
9203 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9204 "{label}"
9205 );
9206 drop(reopened);
9207 }
9208
9209 if !bump {
9212 let mut recreated = open(&v1, &path);
9213 let fresh = recreated
9214 .mutate(upsert(row("t3")))
9215 .expect("fresh commit while open");
9216 recreated.drain_change_batches();
9217 let planted_fresh = json!([{
9218 "op": "upsert",
9219 "table": "tasks",
9220 "rowId": "t3",
9221 "baseVersion": null,
9222 "values": {"id": "t3", "project_id": "p1",
9223 "payload": {"$bytes": "zz"}}
9224 }]);
9225 let connection = Connection::open(&path).expect("raw connection");
9226 let affected = connection
9227 .execute(
9228 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9229 rusqlite::params![planted_fresh.to_string(), fresh],
9230 )
9231 .expect("plant fresh legacy intent");
9232 assert_eq!(
9233 affected, 1,
9234 "{label}: the in-place plant must hit the queued commit"
9235 );
9236 drop(connection);
9237 let revision_before = recreated.local_revision();
9238 recreated
9239 .recreate_with_schema(&v1)
9240 .unwrap_or_else(|error| panic!("{label}: recreation failed: {error}"));
9241 assert_eq!(recreated.pending_commit_ids(), vec![successor], "{label}");
9242 let fresh_rejection = recreated
9243 .rejections()
9244 .iter()
9245 .find(|rejection| rejection.client_commit_id == fresh)
9246 .expect("the in-place recovery rejection");
9247 assert_eq!(fresh_rejection.code, OUTBOX_INCOMPATIBLE_CODE, "{label}");
9248 assert_eq!(
9249 fresh_rejection
9250 .details
9251 .as_ref()
9252 .and_then(|details| details.reason.as_deref()),
9253 Some(INVALID_STORED_VALUES_REASON),
9254 "{label}"
9255 );
9256 assert!(
9257 matches!(
9258 recreated
9259 .commit_outcome(&fresh)
9260 .expect("outcome read")
9261 .map(|outcome| outcome.status),
9262 Some(CommitOutcomeStatus::Rejected)
9263 ),
9264 "{label}"
9265 );
9266 assert!(recreated.local_revision() > revision_before, "{label}");
9267 let revision = recreated.local_revision();
9268 let batches = recreated.drain_change_batches();
9269 assert_eq!(batches.len(), 1, "{label}: one recovery batch");
9270 assert!(
9271 batches.iter().any(|batch| batch.rejections_changed
9272 && batch.outcomes_changed
9273 && batch.tables.iter().any(|change| change.table == "tasks")
9274 && batch.revision == revision.to_string()),
9275 "{label}: {batches:?}"
9276 );
9277 assert_eq!(recreated.pending_payloads().len(), 1, "{label}");
9278 assert_eq!(
9279 recreated
9280 .query("SELECT id FROM tasks ORDER BY id", &[])
9281 .unwrap(),
9282 vec![Map::from_iter([("id".to_owned(), json!("t2"))])],
9283 "{label}: the recovered commit leaves no projection"
9284 );
9285 drop(recreated);
9286 }
9287 let _ = std::fs::remove_file(&path);
9288 }
9289 }
9290
9291 #[test]
9292 fn legacy_values_recover_over_a_synced_authoritative_row() {
9293 let path = std::env::temp_dir().join(format!(
9294 "syncular-authoritative-row-{}.db",
9295 uuid::Uuid::new_v4()
9296 ));
9297 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9298 {"name":"id","type":"string","nullable":false},
9299 {"name":"project_id","type":"string","nullable":false},
9300 {"name":"title","type":"string","nullable":false},
9301 {"name":"payload","type":"bytes","nullable":true}],
9302 "scopes":[{"pattern":"project:{project_id}"}]}]});
9303 let open = |path: &std::path::Path| {
9304 SyncClient::open_path(
9305 "authoritative-row".into(),
9306 &schema,
9307 ClientLimits::default(),
9308 path.to_str().unwrap(),
9309 )
9310 .expect("open client")
9311 };
9312 let row = |title: &str| {
9313 Map::from_iter([
9314 ("id".to_owned(), json!("t1")),
9315 ("project_id".to_owned(), json!("p1")),
9316 ("title".to_owned(), json!(title)),
9317 ("payload".to_owned(), json!({"$bytes": "0a"})),
9318 ])
9319 };
9320 let upsert = |values| {
9321 vec![Mutation::Upsert {
9322 table: "tasks".into(),
9323 values,
9324 base_version: None,
9325 }]
9326 };
9327 let mut client = open(&path);
9328 client.create_synced_tables().expect("create synced tables");
9329 client
9332 .conn
9333 .execute_batch(
9334 "INSERT INTO _syncular_base_tasks VALUES ('t1','p1','authoritative',x'0a',1);
9335 INSERT INTO tasks VALUES ('t1','p1','authoritative',x'0a',1);",
9336 )
9337 .expect("seed authoritative row");
9338 let older = client.mutate(upsert(row("older"))).expect("older");
9339 let newer = client.mutate(upsert(row("newer"))).expect("newer");
9340 let successor = client.mutate(upsert(row("successor"))).expect("successor");
9341 drop(client);
9342
9343 let connection = Connection::open(&path).expect("raw connection");
9344 for commit_id in [&older, &newer] {
9345 let planted = json!([{
9346 "op": "upsert",
9347 "table": "tasks",
9348 "rowId": "t1",
9349 "baseVersion": null,
9350 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9351 "payload": {"$bytes": "zz"}}
9352 }]);
9353 connection
9354 .execute(
9355 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9356 rusqlite::params![planted.to_string(), commit_id],
9357 )
9358 .expect("plant legacy intent");
9359 }
9360 drop(connection);
9361
9362 let reopened = open(&path);
9363 assert_eq!(reopened.pending_commit_ids(), vec![successor.clone()]);
9364 assert_eq!(
9365 reopened
9366 .rejections()
9367 .iter()
9368 .map(|rejection| rejection.client_commit_id.clone())
9369 .collect::<Vec<_>>(),
9370 vec![older.clone(), newer.clone()]
9371 );
9372 assert_eq!(
9375 reopened.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9376 Ok(vec![Map::from_iter([
9377 ("id".to_owned(), json!("t1")),
9378 ("title".to_owned(), json!("successor")),
9379 ("_syncular_version".to_owned(), json!(1)),
9380 ])])
9381 );
9382
9383 drop(reopened);
9386 let connection = Connection::open(&path).expect("raw connection");
9387 connection
9388 .execute(
9389 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9390 rusqlite::params![
9391 json!([{
9392 "op": "upsert",
9393 "table": "tasks",
9394 "rowId": "t1",
9395 "baseVersion": null,
9396 "values": {"id": "t1", "project_id": "p1", "title": "legacy",
9397 "payload": {"$bytes": "zz"}}
9398 }])
9399 .to_string(),
9400 successor
9401 ],
9402 )
9403 .expect("plant successor intent");
9404 drop(connection);
9405 let late = open(&path);
9406 assert!(late.pending_commit_ids().is_empty());
9407 assert_eq!(
9408 late.query("SELECT id,title,_syncular_version FROM tasks", &[]),
9409 Ok(vec![Map::from_iter([
9410 ("id".to_owned(), json!("t1")),
9411 ("title".to_owned(), json!("authoritative")),
9412 ("_syncular_version".to_owned(), json!(1)),
9413 ])])
9414 );
9415 drop(late);
9416 let _ = std::fs::remove_file(&path);
9417 }
9418
9419 #[test]
9420 fn a_failed_value_recovery_commit_restores_every_side_effect() {
9421 let path = std::env::temp_dir().join(format!(
9422 "syncular-value-recovery-commit-{}.db",
9423 uuid::Uuid::new_v4()
9424 ));
9425 let path_str = path.to_str().expect("path");
9426 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
9427 {"name":"id","type":"string","nullable":false},
9428 {"name":"project_id","type":"string","nullable":false},
9429 {"name":"payload","type":"bytes","nullable":true}],
9430 "scopes":[{"pattern":"project:{project_id}"}]}]});
9431 let conn = Connection::open(path_str).expect("replica connection");
9434 let mut client = SyncClient::with_connection(
9435 "value-recovery-commit".into(),
9436 &schema,
9437 ClientLimits::default(),
9438 conn,
9439 )
9440 .expect("replica");
9441 assert_eq!(
9442 client
9443 .conn
9444 .query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
9445 .expect("journal mode"),
9446 "delete"
9447 );
9448 client.create_synced_tables().expect("create synced tables");
9449 let stale = client
9450 .mutate(vec![Mutation::Upsert {
9451 table: "tasks".into(),
9452 values: Map::from_iter([
9453 ("id".to_owned(), json!("t1")),
9454 ("project_id".to_owned(), json!("p1")),
9455 ("payload".to_owned(), json!({"$bytes": "0a"})),
9456 ]),
9457 base_version: None,
9458 }])
9459 .expect("queued write");
9460 client.drain_change_batches();
9461 let affected = client
9462 .conn
9463 .execute(
9464 "UPDATE _syncular_outbox SET ops_json=? WHERE commit_id=?",
9465 rusqlite::params![
9466 json!([{
9467 "op": "upsert",
9468 "table": "tasks",
9469 "rowId": "t1",
9470 "baseVersion": null,
9471 "values": {"id": "t1", "project_id": "p1",
9472 "payload": {"$bytes": "zz"}}
9473 }])
9474 .to_string(),
9475 stale
9476 ],
9477 )
9478 .expect("plant legacy intent");
9479 assert_eq!(affected, 1, "the planted row must exist");
9480 let revision = client.local_revision();
9481 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
9482 let prior_outbox = client.pending_commit_ids();
9483 let prior_rejections = client.rejections().len();
9484 let prior_changes = client.change_queue.len();
9485 let prior_intents = client.sync_intent_queue.len();
9486 let prior_failed = client.failed_commits.len();
9487 let reader = Connection::open(path_str).expect("reader");
9490 reader
9491 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9492 .expect("reader snapshot");
9493 client
9494 .conn
9495 .busy_timeout(std::time::Duration::ZERO)
9496 .expect("busy timeout");
9497 let error = client
9498 .recreate_with_schema(&schema)
9499 .expect_err("a blocked COMMIT must refuse the recovery");
9500 assert!(error.starts_with("client.storage_busy:"), "{error}");
9501 assert!(client.conn.is_autocommit(), "the transaction is released");
9502 assert_eq!(client.local_revision(), revision);
9503 assert_eq!(client.pending_commit_ids(), prior_outbox);
9504 assert_eq!(client.rejections().len(), prior_rejections);
9505 assert_eq!(client.change_queue.len(), prior_changes);
9506 assert_eq!(client.sync_intent_queue.len(), prior_intents);
9507 assert_eq!(client.failed_commits.len(), prior_failed);
9508 assert_eq!(
9509 client.query("SELECT * FROM tasks", &[]).expect("rows"),
9510 rows
9511 );
9512 assert!(
9513 client
9514 .commit_outcome(&stale)
9515 .expect("outcome read")
9516 .is_none(),
9517 "the rolled-back recovery kept no outcome"
9518 );
9519 reader.execute_batch("ROLLBACK").expect("release reader");
9521 client
9522 .recreate_with_schema(&schema)
9523 .expect("retry after the blocked commit");
9524 assert!(client.pending_commit_ids().is_empty());
9525 assert_eq!(client.rejections().len(), prior_rejections + 1);
9526 assert!(client.local_revision() > revision);
9527 let batches = client.drain_change_batches();
9528 assert!(
9529 batches.iter().any(|batch| batch.rejections_changed
9530 && batch.outcomes_changed
9531 && batch.tables.iter().any(|change| change.table == "tasks")),
9532 "{batches:?}"
9533 );
9534 drop(client);
9535 let _ = std::fs::remove_file(&path);
9536 }
9537
9538 #[cfg(feature = "e2ee")]
9539 #[test]
9540 fn a_failed_recovery_restores_retained_failed_commits() {
9541 let path = std::env::temp_dir().join(format!(
9542 "syncular-value-recovery-retention-{}.db",
9543 uuid::Uuid::new_v4()
9544 ));
9545 let path_str = path.to_str().expect("path");
9546 let schema = json!({"version":1,"tables":[{"name":"secrets","primaryKey":"id","columns":[
9547 {"name":"id","type":"string","nullable":false},
9548 {"name":"project_id","type":"string","nullable":false},
9549 {"name":"note","type":"bytes","nullable":true,"encrypted":true,"declaredType":"string"}],
9550 "scopes":[{"pattern":"project:{project_id}"}]}]});
9551 let conn = Connection::open(path_str).expect("replica connection");
9552 let mut client = SyncClient::with_connection(
9553 "value-recovery-retention".into(),
9554 &schema,
9555 ClientLimits::default(),
9556 conn,
9557 )
9558 .expect("replica");
9559 client.create_synced_tables().expect("create synced tables");
9560 client.set_retain_failed_commits(true).expect("retention");
9561 client.set_encryption(crate::values::EncryptionConfig::default());
9563 client
9564 .mutate(vec![Mutation::Upsert {
9565 table: "secrets".into(),
9566 values: Map::from_iter([
9567 ("id".to_owned(), json!("s1")),
9568 ("project_id".to_owned(), json!("p1")),
9569 ("note".to_owned(), json!("secret")),
9570 ]),
9571 base_version: None,
9572 }])
9573 .expect("queued write");
9574 client.drain_change_batches();
9575 let revision = client.local_revision();
9576 let prior_outbox = client.pending_commit_ids();
9577 let prior_rejections = client.rejections().len();
9578 let prior_failed = client.failed_commits.len();
9579 let reader = Connection::open(path_str).expect("reader");
9580 reader
9581 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
9582 .expect("reader snapshot");
9583 client
9584 .conn
9585 .busy_timeout(std::time::Duration::ZERO)
9586 .expect("busy timeout");
9587 let error = client
9588 .drop_unencodable_outbox(true, None)
9589 .expect_err("a blocked COMMIT must refuse the recovery");
9590 assert!(error.starts_with("client.storage_busy:"), "{error}");
9591 assert_eq!(client.failed_commits.len(), prior_failed);
9592 assert_eq!(client.pending_commit_ids(), prior_outbox);
9593 assert_eq!(client.rejections().len(), prior_rejections);
9594 assert_eq!(client.local_revision(), revision);
9595 reader.execute_batch("ROLLBACK").expect("release reader");
9597 client
9598 .drop_unencodable_outbox(true, None)
9599 .expect("retry after the blocked commit");
9600 assert_eq!(client.failed_commits.len(), prior_failed + 1);
9601 assert!(client.pending_commit_ids().is_empty());
9602 assert_eq!(client.rejections().len(), prior_rejections + 1);
9603 drop(client);
9604 let _ = std::fs::remove_file(&path);
9605 }
9606
9607 #[test]
9608 fn client_error_maps_legacy_codes_and_static_messages() {
9609 let code_only = ClientError::from("sync.unknown_table".to_owned());
9610 assert_eq!(code_only.code, "sync.unknown_table");
9611 assert_eq!(code_only.message, "the commit targets an unknown table");
9612 assert!(code_only.details.is_none());
9613
9614 let dynamic = ClientError::from(
9615 "sync.invalid_request: table \"tasks\": patch cannot write scope column".to_owned(),
9616 );
9617 assert_eq!(dynamic.code, "sync.invalid_request");
9618 assert_eq!(dynamic.message, "the authoring request is invalid");
9619 assert!(dynamic.details.as_ref().unwrap()["legacyCause"]
9620 .as_str()
9621 .unwrap()
9622 .contains("patch cannot write scope column"));
9623
9624 let unclassified =
9625 ClientError::from("a commit must contain at least one operation".to_owned());
9626 assert_eq!(unclassified.code, "client.failed");
9627 assert_eq!(unclassified.message, "the local authoring operation failed");
9628 assert!(unclassified.details.as_ref().unwrap()["legacyCause"]
9629 .as_str()
9630 .unwrap()
9631 .contains("at least one operation"));
9632
9633 let metadata = ClientError::from(QueryReadFailure {
9635 code: None,
9636 sqlite_code: Some(1),
9637 sqlite_message: Some("generic SQLite prose".to_owned()),
9638 rollback_failure: Some(Box::new(QueryReadFailure::from(
9639 rusqlite::Error::SqliteFailure(rusqlite::ffi::Error::new(1), None),
9640 ))),
9641 message: "unclassified read".to_owned(),
9642 });
9643 assert_eq!(metadata.code, "client.failed");
9644 assert_eq!(metadata.message, "the local authoring operation failed");
9645 let details = metadata.details.as_ref().unwrap();
9646 assert_eq!(details["sqliteCode"], 1);
9647 assert_eq!(details["sqliteMessage"], "generic SQLite prose");
9648 assert_eq!(details["rollbackFailure"]["sqliteCode"], 1);
9649 assert_eq!(details["legacyCause"], "unclassified read");
9650 }
9651
9652 #[test]
9653 fn schema_marker_read_keeps_storage_classification() {
9654 let busy = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9655 rusqlite::ffi::Error::new(5),
9656 Some("database is locked".to_owned()),
9657 ));
9658 assert!(busy.starts_with("client.storage_busy:"), "{busy}");
9659 let invalid = schema_marker_read_failure(rusqlite::Error::SqliteFailure(
9660 rusqlite::ffi::Error::new(1),
9661 Some("unreadable".to_owned()),
9662 ));
9663 assert_eq!(
9664 invalid,
9665 "sync.local_corrupt: persisted local schema marker is unreadable or invalid"
9666 );
9667
9668 let path =
9671 std::env::temp_dir().join(format!("syncular-marker-busy-{}.db", uuid::Uuid::new_v4()));
9672 let setup = Connection::open(&path).expect("setup connection");
9673 setup
9674 .execute_batch(
9675 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value TEXT);\n INSERT INTO _syncular_meta VALUES ('localSchemaVersion','1');",
9676 )
9677 .unwrap();
9678 let locker = Connection::open(&path).expect("locker connection");
9679 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9680 let reader = Connection::open(&path).expect("reader connection");
9681 reader.busy_timeout(std::time::Duration::ZERO).unwrap();
9682 let locked = read_local_schema_version(&reader, 1).expect_err("locked marker read");
9683 assert!(locked.starts_with("client.storage_busy:"), "{locked}");
9684 locker.execute_batch("ROLLBACK").unwrap();
9685 assert_eq!(read_local_schema_version(&reader, 1).unwrap(), Some(1));
9686 let rows = setup
9687 .query_row("SELECT count(*) FROM _syncular_meta", [], |row| {
9688 row.get::<_, i64>(0)
9689 })
9690 .unwrap();
9691 assert_eq!(rows, 1, "the guard performed no writes");
9692 drop(reader);
9693 drop(locker);
9694 drop(setup);
9695 let _ = std::fs::remove_file(&path);
9696 }
9697
9698 #[cfg(feature = "crdt-yjs")]
9699 #[test]
9700 fn crdt_authoring_pre_read_preserves_storage_classification() {
9701 let path =
9702 std::env::temp_dir().join(format!("syncular-crdt-busy-{}.db", uuid::Uuid::new_v4()));
9703 let schema = json!({"version":1,"tables":[{"name":"notes","primaryKey":"id","columns":[
9704 {"name":"id","type":"string","nullable":false},
9705 {"name":"project_id","type":"string","nullable":false},
9706 {"name":"doc","type":"crdt","nullable":true,"crdtType":"yjs-doc"}],
9707 "scopes":[{"pattern":"project:{project_id}"}]}]});
9708 let mut client = SyncClient::open_path(
9709 "crdt-busy".into(),
9710 &schema,
9711 ClientLimits::default(),
9712 path.to_str().unwrap(),
9713 )
9714 .expect("open client");
9715 client.create_synced_tables().expect("create synced tables");
9716 client.conn.busy_timeout(std::time::Duration::ZERO).unwrap();
9717
9718 let locker = Connection::open(&path).expect("locker connection");
9719 locker.execute_batch("BEGIN EXCLUSIVE").unwrap();
9720 let busy = client
9721 .crdt_apply_update("notes", "n1", "doc", &[])
9722 .expect_err("busy crdt pre-read");
9723 assert_eq!(busy.code, "client.storage_busy");
9724 assert!(busy.retryable);
9725 assert_eq!(busy.details.as_ref().unwrap()["sqliteCode"], 5);
9726 assert!(client.pending_commit_ids().is_empty());
9727 assert_eq!(client.local_revision(), 0);
9728
9729 locker.execute_batch("ROLLBACK").unwrap();
9730 drop(locker);
9731 drop(client);
9732 let _ = std::fs::remove_file(&path);
9733 }
9734
9735 #[test]
9736 fn large_unique_import_copies_no_unrelated_tables() {
9737 let (mut client, image, table) = unique_import_fixture(10240);
9738 let pending = client.pending_commit_ids();
9739 client.conn.execute_batch("CREATE TRIGGER unrelated_insert BEFORE INSERT ON notes BEGIN SELECT RAISE(ABORT,'unrelated insert'); END;
9740 CREATE TRIGGER unrelated_delete BEFORE DELETE ON notes BEGIN SELECT RAISE(ABORT,'unrelated delete'); END;").unwrap();
9741 assert!(matches!(
9742 client.apply_sqlite_image(
9743 &image,
9744 &table,
9745 true,
9746 &[("project_id".into(), vec!["p1".into()])],
9747 10240,
9748 7,
9749 "digest"
9750 ),
9751 Ok(10240)
9752 ));
9753 assert_eq!(client.overlay_rebuild_count.get(), 0);
9754 assert_eq!(client.pending_commit_ids(), pending);
9755 assert_eq!(
9756 client
9757 .query("SELECT count(*) AS n FROM tasks", &[])
9758 .unwrap()[0]["n"],
9759 10241
9760 );
9761 assert_eq!(
9762 client
9763 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9764 .unwrap()[0]["n"],
9765 10241
9766 );
9767 assert_eq!(
9768 client
9769 .query("SELECT count(*) AS n FROM notes_fts", &[])
9770 .unwrap()[0]["n"],
9771 8192
9772 );
9773 }
9774
9775 #[test]
9776 #[ignore = "manual scaling benchmark; see bench/README.md"]
9777 fn benchmark_unique_image_import() {
9778 let mut samples = Vec::new();
9779 for rows in [10240, 51200, 102400] {
9780 for trial in 0..4 {
9781 let (mut client, image, table) = unique_import_fixture(rows);
9782 let start = std::time::Instant::now();
9783 assert!(
9784 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)
9785 );
9786 let elapsed = start.elapsed().as_secs_f64() * 1000.0;
9787 assert_eq!(
9788 client
9789 .query("SELECT count(*) AS n FROM tasks", &[])
9790 .unwrap()[0]["n"],
9791 rows + 1
9792 );
9793 assert_eq!(
9794 client
9795 .query("SELECT count(*) AS n FROM tasks_fts", &[])
9796 .unwrap()[0]["n"],
9797 rows + 1
9798 );
9799 assert_eq!(
9800 client
9801 .query("SELECT count(*) AS n FROM notes_fts", &[])
9802 .unwrap()[0]["n"],
9803 8192
9804 );
9805 assert_eq!(client.pending_commit_ids().len(), 1);
9806 if trial > 0 {
9807 samples.push(json!({"rows":rows,"trial":trial,"elapsedMs":elapsed,"fullRebuilds":client.overlay_rebuild_count.get()}));
9808 }
9809 }
9810 }
9811 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});
9812 let output = serde_json::to_string_pretty(&artifact).unwrap();
9813 if let Ok(path) = std::env::var("SYNCULAR_IMPORT_BENCH_OUTPUT") {
9814 use std::io::Write;
9815 let mut file = std::fs::OpenOptions::new()
9816 .write(true)
9817 .create_new(true)
9818 .open(path)
9819 .unwrap();
9820 file.write_all(output.as_bytes()).unwrap();
9821 } else {
9822 println!("{output}");
9823 }
9824 }
9825
9826 #[test]
9827 fn local_fts_projection_tracks_optimistic_overlay_rebuilds() {
9828 let schema = json!({
9829 "version": 1,
9830 "tables": [{
9831 "name": "catalogue_codes",
9832 "primaryKey": "id",
9833 "columns": [
9834 { "name": "id", "type": "string", "nullable": false },
9835 { "name": "release_id", "type": "string", "nullable": false },
9836 { "name": "code", "type": "string", "nullable": false },
9837 { "name": "title", "type": "string", "nullable": false }
9838 ],
9839 "scopes": [{ "pattern": "release:{release_id}" }],
9840 "ftsIndexes": [{
9841 "name": "catalogue_codes_fts",
9842 "columns": ["code", "title"],
9843 "tokenize": "unicode61 remove_diacritics 2"
9844 }]
9845 }]
9846 });
9847 let mut client = SyncClient::new("fts-test".to_owned(), &schema, ClientLimits::default())
9848 .expect("FTS5 client");
9849 let insert_trigger: String = client
9850 .conn
9851 .query_row(
9852 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_ai'",
9853 [],
9854 |row| row.get(0),
9855 )
9856 .expect("insert trigger");
9857 let replace_guard: String = client
9858 .conn
9859 .query_row(
9860 "SELECT sql FROM sqlite_master WHERE type='trigger' AND name='catalogue_codes_fts_bi'",
9861 [],
9862 |row| row.get(0),
9863 )
9864 .expect("replace guard");
9865 assert!(!insert_trigger.contains("DELETE FROM"));
9866 assert!(replace_guard.contains("BEFORE INSERT"));
9867 assert!(replace_guard.contains("WHEN EXISTS"));
9868 let search = |client: &SyncClient, query: &str| {
9869 client
9870 .query(
9871 "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",
9872 &[Value::from(query)],
9873 )
9874 .expect("FTS query")
9875 };
9876
9877 client
9878 .mutate(vec![Mutation::Upsert {
9879 table: "catalogue_codes".to_owned(),
9880 values: Map::from_iter([
9881 ("id".to_owned(), Value::from("c1")),
9882 ("release_id".to_owned(), Value::from("r1")),
9883 ("code".to_owned(), Value::from("A01")),
9884 ("title".to_owned(), Value::from("Cholera")),
9885 ]),
9886 base_version: None,
9887 }])
9888 .expect("insert code");
9889 assert_eq!(search(&client, "cholera").len(), 1);
9890 let prior = client
9891 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9892 .unwrap();
9893 client
9894 .conn
9895 .execute_batch("DROP TABLE _syncular_fts_catalogue_codes_fts")
9896 .unwrap();
9897 client.create_synced_tables().unwrap();
9898 assert_eq!(
9899 client
9900 .query("SELECT rowid, * FROM catalogue_codes_fts", &[])
9901 .unwrap(),
9902 prior
9903 );
9904 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();
9905 let rendered = serde_json::to_string(&plan).unwrap();
9906 assert!(rendered.contains("COVERING INDEX"));
9907 assert!(rendered.contains("INDEX 0:="));
9908 client.conn.execute_batch("SAVEPOINT test_mapping; DELETE FROM catalogue_codes WHERE id='c1'; ROLLBACK TO test_mapping; RELEASE test_mapping").unwrap();
9909 assert_eq!(search(&client, "cholera").len(), 1);
9910
9911 client
9912 .mutate(vec![Mutation::Upsert {
9913 table: "catalogue_codes".to_owned(),
9914 values: Map::from_iter([
9915 ("id".to_owned(), Value::from("c1")),
9916 ("release_id".to_owned(), Value::from("r1")),
9917 ("code".to_owned(), Value::from("A01")),
9918 ("title".to_owned(), Value::from("Enteric infection")),
9919 ]),
9920 base_version: None,
9921 }])
9922 .expect("update code");
9923 assert!(search(&client, "cholera").is_empty());
9924 assert_eq!(search(&client, "enteric").len(), 1);
9925
9926 client
9927 .mutate(vec![Mutation::Delete {
9928 table: "catalogue_codes".to_owned(),
9929 row_id: "c1".to_owned(),
9930 base_version: None,
9931 }])
9932 .expect("delete code");
9933 assert!(search(&client, "enteric").is_empty());
9934 }
9935
9936 #[test]
9937 fn application_authorized_local_purge_is_exact_atomic_and_idempotent() {
9938 let schema = json!({
9939 "version": 1,
9940 "tables": [{
9941 "name": "patient_notes",
9942 "primaryKey": "id",
9943 "columns": [
9944 { "name": "id", "type": "string", "nullable": false },
9945 { "name": "practice_id", "type": "string", "nullable": false },
9946 { "name": "encryption_key_id", "type": "string", "nullable": false },
9947 { "name": "title", "type": "string", "nullable": false }
9948 ],
9949 "scopes": [{ "pattern": "practice:{practice_id}" }],
9950 "ftsIndexes": [{
9951 "name": "patient_notes_fts",
9952 "columns": ["title"],
9953 "tokenize": "unicode61 remove_diacritics 2"
9954 }]
9955 }]
9956 });
9957 let mut client = SyncClient::new(
9958 "local-purge-test".to_owned(),
9959 &schema,
9960 ClientLimits::default(),
9961 )
9962 .expect("local purge client");
9963 client
9964 .conn
9965 .execute(
9966 "INSERT INTO _syncular_base_patient_notes(id, practice_id, encryption_key_id, title, _syncular_version) VALUES
9967 ('target', 'practice-1', 'key-revoked', 'Target original', 1),
9968 ('unrelated', 'practice-1', 'key-held', 'Unrelated original', 1)",
9969 [],
9970 )
9971 .expect("seed base rows");
9972 client.overlay_dirty.set(true);
9973 client.rebuild_overlay_if_dirty().expect("rebuild overlay");
9974
9975 let note = |id: &str, key_id: &str, title: &str| {
9976 Map::from_iter([
9977 ("id".to_owned(), Value::from(id)),
9978 ("practice_id".to_owned(), Value::from("practice-1")),
9979 ("encryption_key_id".to_owned(), Value::from(key_id)),
9980 ("title".to_owned(), Value::from(title)),
9981 ])
9982 };
9983 let doomed = client
9984 .mutate(vec![
9985 Mutation::Upsert {
9986 table: "patient_notes".to_owned(),
9987 values: note("target", "key-revoked", "Target changed"),
9988 base_version: None,
9989 },
9990 Mutation::Upsert {
9991 table: "patient_notes".to_owned(),
9992 values: note("unrelated", "key-held", "Unrelated changed"),
9993 base_version: None,
9994 },
9995 ])
9996 .expect("doomed commit");
9997 let kept = client
9998 .mutate(vec![Mutation::Upsert {
9999 table: "patient_notes".to_owned(),
10000 values: note("kept", "key-held", "Kept optimistic"),
10001 base_version: None,
10002 }])
10003 .expect("kept commit");
10004 client.drain_change_batches();
10005
10006 let input = LocalDataPurgeInput {
10007 purge_id: "purge-001".to_owned(),
10008 targets: vec![LocalDataPurgeTarget {
10009 table: "patient_notes".to_owned(),
10010 selectors: BTreeMap::from([(
10011 "encryption_key_id".to_owned(),
10012 vec!["key-revoked".to_owned()],
10013 )]),
10014 }],
10015 };
10016 assert_eq!(
10017 client.purge_local_data(&input).expect("apply purge"),
10018 LocalDataPurgeResult {
10019 already_applied: false,
10020 purged_rows: 1,
10021 dropped_commits: 1,
10022 }
10023 );
10024 let rows = client
10025 .query("SELECT id, title FROM patient_notes ORDER BY id", &[])
10026 .expect("visible rows");
10027 assert_eq!(rows.len(), 2);
10028 assert_eq!(rows[0]["id"], "kept");
10029 assert_eq!(rows[1]["id"], "unrelated");
10030 assert_eq!(rows[1]["title"], "Unrelated original");
10031 let fts = client
10032 .query(
10033 "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'",
10034 &[],
10035 )
10036 .expect("fts query");
10037 assert!(fts.is_empty());
10038 assert_eq!(client.outbox.len(), 1);
10039 assert_eq!(client.outbox[0].client_commit_id, kept);
10040 let outcome = client
10041 .commit_outcome(&doomed)
10042 .expect("read doomed outcome")
10043 .expect("doomed outcome");
10044 assert_eq!(outcome.status, CommitOutcomeStatus::Rejected);
10045 match &outcome.results[0] {
10046 CommitOperationOutcome::Error { rejection } => {
10047 assert_eq!(rejection.code, "client.local_data_purged");
10048 assert_eq!(rejection.client_commit_id, doomed);
10049 }
10050 other => panic!("expected local purge rejection, got {other:?}"),
10051 }
10052 assert_eq!(client.drain_change_batches().len(), 1);
10053 assert_eq!(
10054 client.purge_local_data(&input).expect("retry purge"),
10055 LocalDataPurgeResult {
10056 already_applied: true,
10057 purged_rows: 0,
10058 dropped_commits: 0,
10059 }
10060 );
10061 let conflicting = LocalDataPurgeInput {
10062 purge_id: input.purge_id.clone(),
10063 targets: vec![LocalDataPurgeTarget {
10064 table: "patient_notes".to_owned(),
10065 selectors: BTreeMap::from([(
10066 "encryption_key_id".to_owned(),
10067 vec!["key-held".to_owned()],
10068 )]),
10069 }],
10070 };
10071 assert!(client
10072 .purge_local_data(&conflicting)
10073 .expect_err("id collision must fail")
10074 .contains("already used with a different plan"));
10075 }
10076
10077 fn sync_split(client: &mut SyncClient, transport: &mut dyn Transport) -> SyncOutcome {
10079 let mut next = match client.prepare_sync_round(transport.supports_url_fetch()) {
10080 Ok(prepared) => prepared,
10081 Err(outcome) => return *outcome,
10082 };
10083 loop {
10084 let completed = crate::round::test_support::drive_round(next, transport);
10085 match client.apply_sync_round(completed) {
10086 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
10087 crate::AppliedSyncRound::Complete {
10088 outcome, controls, ..
10089 } => {
10090 if client.transport_enabled() {
10091 for text in controls {
10092 let _ = transport.realtime_send(&text);
10093 }
10094 } else {
10095 client.disconnect_realtime(transport);
10096 }
10097 return outcome;
10098 }
10099 }
10100 }
10101 }
10102
10103 #[derive(Clone)]
10104 enum RoundReplyScript {
10105 Response(Message),
10106 PushAck,
10108 Error(TransportError),
10109 }
10110
10111 struct SplitParityTransport {
10114 script: RoundReplyScript,
10115 segments: BTreeMap<String, Vec<u8>>,
10116 requests: Vec<Message>,
10117 batches: Vec<Vec<String>>,
10118 uploads: Vec<String>,
10119 sync_calls: usize,
10120 realtime_sync_calls: usize,
10121 }
10122
10123 impl SplitParityTransport {
10124 fn new(script: RoundReplyScript) -> Self {
10125 Self {
10126 script,
10127 segments: BTreeMap::new(),
10128 requests: Vec::new(),
10129 batches: Vec::new(),
10130 uploads: Vec::new(),
10131 sync_calls: 0,
10132 realtime_sync_calls: 0,
10133 }
10134 }
10135
10136 fn with_segments(script: RoundReplyScript, segments: BTreeMap<String, Vec<u8>>) -> Self {
10137 Self {
10138 segments,
10139 ..Self::new(script)
10140 }
10141 }
10142
10143 fn respond(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10144 let message =
10145 decode_message(request).expect("the client encodes a legal round request");
10146 self.requests.push(message.clone());
10147 match &self.script {
10148 RoundReplyScript::Response(response) => Ok(encode_message(response)),
10149 RoundReplyScript::Error(error) => Err(error.clone()),
10150 RoundReplyScript::PushAck => {
10151 let mut frames = vec![Frame::RespHeader {
10152 required_schema_version: None,
10153 latest_schema_version: None,
10154 log_epoch: Some("epoch-1".into()),
10155 reset_required: Some(false),
10156 }];
10157 let mut commit_seq = 0;
10158 for frame in &message.frames {
10159 if let Frame::PushCommit {
10160 client_commit_id,
10161 operations,
10162 } = frame
10163 {
10164 commit_seq += 1;
10165 frames.push(Frame::PushResult {
10166 client_commit_id: client_commit_id.clone(),
10167 status: PushStatus::Applied,
10168 commit_seq: Some(commit_seq),
10169 results: (0..operations.len())
10170 .map(|op_index| OpResult::Applied {
10171 op_index: op_index as i32,
10172 })
10173 .collect(),
10174 });
10175 }
10176 }
10177 frames.push(Frame::SubStart {
10178 id: "tasks".into(),
10179 status: SubStatus::Active,
10180 reason_code: String::new(),
10181 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10182 bootstrap: false,
10183 });
10184 frames.push(Frame::SubEnd {
10185 next_cursor: 0,
10186 bootstrap_state: None,
10187 });
10188 Ok(encode_message(&Message {
10189 wire_version: WIRE_VERSION,
10190 msg_kind: MsgKind::Response,
10191 frames,
10192 }))
10193 }
10194 }
10195 }
10196 }
10197
10198 impl Transport for SplitParityTransport {
10199 fn sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10200 self.sync_calls += 1;
10201 self.respond(request)
10202 }
10203
10204 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
10205 self.realtime_sync_calls += 1;
10206 self.respond(request)
10207 }
10208 fn download_segment(
10209 &mut self,
10210 _request: &SegmentRequest,
10211 _on_progress: &mut dyn FnMut(u64),
10212 ) -> Result<Vec<u8>, TransportError> {
10213 Err(TransportError::new(
10214 "sync.transport_failed",
10215 "per-descriptor fetch",
10216 ))
10217 }
10218
10219 fn download_segments(
10220 &mut self,
10221 fetches: &[crate::SegmentFetch],
10222 on_progress: &mut (dyn FnMut(usize, u64) + Send),
10223 ) -> Vec<Result<Vec<u8>, TransportError>> {
10224 self.batches.push(
10225 fetches
10226 .iter()
10227 .map(|fetch| match fetch {
10228 crate::SegmentFetch::Direct(request) => request.segment_id.clone(),
10229 crate::SegmentFetch::Url(url) => url.clone(),
10230 })
10231 .collect(),
10232 );
10233 fetches
10234 .iter()
10235 .enumerate()
10236 .map(|(index, fetch)| {
10237 let crate::SegmentFetch::Direct(request) = fetch else {
10238 panic!("the descriptors are direct-endpoint requests")
10239 };
10240 let bytes =
10241 self.segments
10242 .get(&request.segment_id)
10243 .cloned()
10244 .ok_or_else(|| {
10245 TransportError::new("transport.not_found", "missing segment body")
10246 })?;
10247 on_progress(index, bytes.len() as u64);
10248 Ok(bytes)
10249 })
10250 .collect()
10251 }
10252
10253 fn blob_upload_grant(
10254 &mut self,
10255 _blob_id: &str,
10256 _byte_length: u64,
10257 _media_type: Option<&str>,
10258 ) -> Result<BlobUploadGrant, TransportError> {
10259 Ok(BlobUploadGrant::None)
10260 }
10261
10262 fn blob_upload(
10263 &mut self,
10264 blob_id: &str,
10265 _bytes: &[u8],
10266 _media_type: Option<&str>,
10267 ) -> Result<(), TransportError> {
10268 self.uploads.push(blob_id.to_owned());
10269 Ok(())
10270 }
10271
10272 fn realtime_connect(&mut self) -> Result<(), TransportError> {
10273 Ok(())
10274 }
10275
10276 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
10277 Ok(())
10278 }
10279
10280 fn realtime_close(&mut self) -> Result<(), TransportError> {
10281 Ok(())
10282 }
10283 }
10284
10285 fn parity_clients() -> (SyncClient, SyncClient) {
10286 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10287 {"name":"id","type":"string","nullable":false},
10288 {"name":"project_id","type":"string","nullable":false}],
10289 "scopes":[{"pattern":"project:{project_id}"}]}]});
10290 let [exchange, split] = ["parity-exchange", "parity-split"].map(|id| {
10291 let mut client =
10292 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10293 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10294 client
10295 .subscribe(
10296 "tasks".into(),
10297 "tasks".into(),
10298 vec![("project_id".into(), vec!["p1".into()])],
10299 None,
10300 )
10301 .unwrap();
10302 client.subs[0].cursor = 0;
10303 client.subs[0].synced_once = true;
10304 client.subs[0].effective = Some(vec![("project_id".into(), vec!["p1".into()])]);
10305 client.persist_sub(&client.subs[0]).unwrap();
10306 client.drain_change_batches();
10307 client
10308 });
10309 (exchange, split)
10310 }
10311
10312 fn pull_response(client: &SyncClient) -> Message {
10313 let row = encode_row_json(
10314 client.schema.table("tasks").unwrap(),
10315 "kept",
10316 &Map::from_iter([
10317 ("id".into(), json!("kept")),
10318 ("project_id".into(), json!("p1")),
10319 ]),
10320 &client.encryption,
10321 )
10322 .unwrap();
10323 Message {
10324 wire_version: WIRE_VERSION,
10325 msg_kind: MsgKind::Response,
10326 frames: vec![
10327 Frame::RespHeader {
10328 required_schema_version: None,
10329 latest_schema_version: None,
10330 log_epoch: Some("epoch-1".into()),
10331 reset_required: Some(false),
10332 },
10333 Frame::SubStart {
10334 id: "tasks".into(),
10335 status: SubStatus::Active,
10336 reason_code: String::new(),
10337 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10338 bootstrap: false,
10339 },
10340 Frame::Commit {
10341 commit_seq: 1,
10342 created_at_ms: 1,
10343 actor_id: "writer".into(),
10344 tables: vec!["tasks".into()],
10345 changes: vec![ssp2::model::Change {
10346 table_index: 0,
10347 row_id: "kept".into(),
10348 op: Op::Upsert,
10349 row_version: Some(1),
10350 scopes: vec![("project_id".into(), "p1".into())],
10351 row: Some(row),
10352 }],
10353 },
10354 Frame::SubEnd {
10355 next_cursor: 1,
10356 bootstrap_state: None,
10357 },
10358 ],
10359 }
10360 }
10361
10362 #[test]
10363 fn split_round_matches_exchange_for_a_pull() {
10364 let (mut exchange, mut split) = parity_clients();
10365 let response = pull_response(&exchange);
10366 let mut exchange_transport =
10367 SplitParityTransport::new(RoundReplyScript::Response(response.clone()));
10368 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Response(response));
10369
10370 let exchange_outcome = exchange.sync(&mut exchange_transport);
10371 let split_outcome = sync_split(&mut split, &mut split_transport);
10372 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10373 assert_eq!(
10374 exchange
10375 .query("SELECT id, project_id FROM tasks", &[])
10376 .unwrap(),
10377 split
10378 .query("SELECT id, project_id FROM tasks", &[])
10379 .unwrap()
10380 );
10381 assert_eq!(exchange_transport.requests.len(), 1);
10382 assert_eq!(split_transport.requests.len(), 1);
10383 }
10384
10385 #[test]
10386 fn split_round_matches_exchange_for_a_push_with_an_upload() {
10387 let schema = json!({"version":1,"tables":[{"name":"attachments","primaryKey":"id","columns":[
10388 {"name":"id","type":"string","nullable":false},
10389 {"name":"project_id","type":"string","nullable":false},
10390 {"name":"file","type":"blob_ref","nullable":true}],
10391 "scopes":[{"pattern":"project:{project_id}"}]}]});
10392 let [mut exchange, mut split] = ["parity-push-exchange", "parity-push-split"].map(|id| {
10393 let mut client =
10394 SyncClient::new(id.to_owned(), &schema, ClientLimits::default()).unwrap();
10395 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
10396 client
10397 .subscribe(
10398 "attachments".into(),
10399 "attachments".into(),
10400 vec![("project_id".into(), vec!["p1".into()])],
10401 None,
10402 )
10403 .unwrap();
10404 client
10405 });
10406 let bytes = b"split upload";
10407 let mut mutations = Vec::new();
10408 for client in [&mut exchange, &mut split] {
10409 client
10410 .upload_blob(bytes, Some("text/plain".into()), None)
10411 .unwrap();
10412 mutations.push(
10413 client
10414 .mutate(vec![Mutation::Upsert {
10415 table: "attachments".into(),
10416 values: Map::from_iter([
10417 ("id".into(), Value::from("a1")),
10418 ("project_id".into(), Value::from("p1")),
10419 (
10420 "file".into(),
10421 Value::from(format!(
10422 "{{\"blobId\":\"{}\",\"byteLength\":{}}}",
10423 blob_id_for(bytes),
10424 bytes.len()
10425 )),
10426 ),
10427 ]),
10428 base_version: None,
10429 }])
10430 .unwrap(),
10431 );
10432 }
10433 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10434 let mut split_transport = SplitParityTransport::new(RoundReplyScript::PushAck);
10435 let exchange_outcome = exchange.sync(&mut exchange_transport);
10436 let split_outcome = sync_split(&mut split, &mut split_transport);
10437 let SyncOutcome::Ok(exchange_report) = exchange_outcome else {
10438 panic!("the exchange round must succeed: {exchange_outcome:?}")
10439 };
10440 let SyncOutcome::Ok(split_report) = split_outcome else {
10441 panic!("the split round must succeed: {split_outcome:?}")
10442 };
10443 assert_eq!(exchange_report.pushed, 1);
10444 assert_eq!(split_report.pushed, 1);
10445 assert_eq!(
10446 exchange_report.commits_applied,
10447 split_report.commits_applied
10448 );
10449 assert_eq!(
10450 exchange_transport.uploads,
10451 [blob_id_for(bytes)],
10452 "one upload before the metadata request"
10453 );
10454 assert_eq!(split_transport.uploads, exchange_transport.uploads);
10455 assert!(exchange.pending_commit_ids().is_empty());
10456 assert!(split.pending_commit_ids().is_empty());
10457 assert_eq!(
10458 exchange
10459 .query("SELECT id, project_id FROM attachments", &[])
10460 .unwrap(),
10461 split
10462 .query("SELECT id, project_id FROM attachments", &[])
10463 .unwrap()
10464 );
10465 }
10466
10467 #[test]
10468 fn split_round_matches_exchange_for_a_segment_download() {
10469 let (mut exchange, mut split) = parity_clients();
10470 let columns = exchange.schema.table("tasks").unwrap().wire_columns.clone();
10471 let page = ssp2::segment::encode_rows_segment(&RowsSegment {
10472 table: "tasks".into(),
10473 schema_version: 1,
10474 columns,
10475 blocks: vec![vec![ssp2::segment::SegmentRow {
10476 server_version: 1,
10477 values: vec![
10478 Some(ColumnValue::String("page".into())),
10479 Some(ColumnValue::String("p1".into())),
10480 ],
10481 }]],
10482 });
10483 let segment_id = format!("sha256:{}", bytes_to_hex(&Sha256::digest(&page)));
10484 let response = Message {
10485 wire_version: WIRE_VERSION,
10486 msg_kind: MsgKind::Response,
10487 frames: vec![
10488 Frame::RespHeader {
10489 required_schema_version: None,
10490 latest_schema_version: None,
10491 log_epoch: Some("epoch-1".into()),
10492 reset_required: Some(false),
10493 },
10494 Frame::SubStart {
10495 id: "tasks".into(),
10496 status: SubStatus::Active,
10497 reason_code: String::new(),
10498 effective_scopes: vec![("project_id".into(), vec!["p1".into()])],
10499 bootstrap: true,
10500 },
10501 Frame::SegmentRef {
10502 segment_id: segment_id.clone(),
10503 media_type: MediaType::Rows,
10504 table: "tasks".into(),
10505 byte_length: page.len() as i64,
10506 row_count: 1,
10507 as_of_commit_seq: 1,
10508 scope_digest: "digest".into(),
10509 row_cursor: None,
10510 next_row_cursor: None,
10511 url: None,
10512 url_expires_at_ms: None,
10513 },
10514 Frame::SubEnd {
10515 next_cursor: 1,
10516 bootstrap_state: None,
10517 },
10518 ],
10519 };
10520 let segments = BTreeMap::from([(segment_id.clone(), page)]);
10521 let mut exchange_transport = SplitParityTransport::with_segments(
10522 RoundReplyScript::Response(response.clone()),
10523 segments.clone(),
10524 );
10525 let mut split_transport =
10526 SplitParityTransport::with_segments(RoundReplyScript::Response(response), segments);
10527 let exchange_outcome = exchange.sync(&mut exchange_transport);
10528 let split_outcome = sync_split(&mut split, &mut split_transport);
10529 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10530 assert_eq!(exchange_transport.batches, [vec![segment_id.clone()]]);
10531 assert_eq!(split_transport.batches, exchange_transport.batches);
10532 assert_eq!(
10533 exchange
10534 .query("SELECT id, project_id FROM tasks", &[])
10535 .unwrap(),
10536 split
10537 .query("SELECT id, project_id FROM tasks", &[])
10538 .unwrap()
10539 );
10540 }
10541
10542 #[test]
10543 fn split_round_matches_exchange_for_a_transport_failure() {
10544 let (mut exchange, mut split) = parity_clients();
10545 let error = TransportError::new("transport.timeout", "injected");
10546 let mut exchange_transport =
10547 SplitParityTransport::new(RoundReplyScript::Error(error.clone()));
10548 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10549 let exchange_outcome = exchange.sync(&mut exchange_transport);
10550 let split_outcome = sync_split(&mut split, &mut split_transport);
10551 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10552 assert!(matches!(
10553 split_outcome,
10554 SyncOutcome::Failed { ref error_code, .. } if error_code == "transport.timeout"
10555 ));
10556 assert_eq!(
10557 exchange.query("SELECT id FROM tasks", &[]).unwrap(),
10558 split.query("SELECT id FROM tasks", &[]).unwrap()
10559 );
10560 }
10561
10562 #[test]
10563 fn split_round_matches_exchange_for_a_failed_realtime_round() {
10564 let (mut exchange, mut split) = parity_clients();
10565 let error = TransportError::new("transport.timeout", "socket round failed");
10566 let mut exchange_transport = SplitParityTransport::new(RoundReplyScript::Error(error));
10567 let mut split_transport = SplitParityTransport::new(RoundReplyScript::Error(
10568 TransportError::new("transport.timeout", "socket round failed"),
10569 ));
10570 exchange.connect_realtime(&mut exchange_transport).unwrap();
10571 split.connect_realtime(&mut split_transport).unwrap();
10572 let exchange_outcome = exchange.sync(&mut exchange_transport);
10573 let split_outcome = sync_split(&mut split, &mut split_transport);
10574 assert_eq!(exchange_outcome.to_json(), split_outcome.to_json());
10575 assert_eq!(exchange_transport.realtime_sync_calls, 1);
10576 assert_eq!(exchange_transport.sync_calls, 0);
10577 assert_eq!(split_transport.realtime_sync_calls, 1);
10578 assert_eq!(split_transport.sync_calls, 0);
10579 assert_eq!(exchange.realtime_state(), RealtimeState::Lost);
10580 assert_eq!(split.realtime_state(), RealtimeState::Lost);
10581 }
10582}
10583
10584impl SubState {
10585 fn name(self) -> &'static str {
10586 match self {
10587 SubState::Active => "active",
10588 SubState::Revoked => "revoked",
10589 SubState::Failed => "failed",
10590 }
10591 }
10592
10593 fn parse(value: &str) -> Self {
10594 match value {
10595 "revoked" => Self::Revoked,
10596 "failed" => Self::Failed,
10597 _ => Self::Active,
10598 }
10599 }
10600}
10601
10602#[derive(Debug, Clone)]
10603struct Subscription {
10604 id: String,
10605 table: String,
10606 requested: Vec<(String, Vec<String>)>,
10607 params: Option<String>,
10608 cursor: i64,
10609 bootstrap_state: Option<String>,
10611 state: SubState,
10612 reason_code: Option<String>,
10613 effective: Option<Vec<(String, Vec<String>)>>,
10616 synced_once: bool,
10617}
10618
10619#[cfg(test)]
10620mod subscription_rearm_tests {
10621 use super::*;
10622 use serde_json::json;
10623
10624 fn client() -> SyncClient {
10625 let schema = json!({"version":1,"tables":[{"name":"tasks","primaryKey":"id","columns":[
10626 {"name":"id","type":"string","nullable":false},
10627 {"name":"project_id","type":"string","nullable":false}],
10628 "scopes":[{"pattern":"project:{project_id}"}],"indexes":[],"ftsIndexes":[]}]});
10629 SyncClient::new("rearm".into(), &schema, ClientLimits::default()).expect("client")
10630 }
10631
10632 fn scopes() -> Vec<(String, Vec<String>)> {
10633 vec![("project_id".to_owned(), vec!["p1".to_owned()])]
10634 }
10635
10636 fn store_revoked(client: &mut SyncClient) {
10638 client
10639 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10640 .expect("subscribe");
10641 let sub = &mut client.subs[0];
10642 sub.cursor = 41;
10643 sub.bootstrap_state = Some("resume".to_owned());
10644 sub.effective = Some(scopes());
10645 sub.state = SubState::Revoked;
10646 sub.reason_code = Some("sync.scope_revoked".to_owned());
10647 client.persist_sub(&client.subs[0]).expect("persist");
10648 client.sync_needed = false;
10649 client.sync_intent_queue.clear();
10650 }
10651
10652 #[test]
10653 fn resubscribing_the_same_intent_rearms_a_revoked_registration() {
10654 let mut client = client();
10655 store_revoked(&mut client);
10656
10657 client
10658 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10659 .expect("re-arm");
10660
10661 let view = client.subscription_state("sub").expect("state");
10662 assert_eq!(view.status, "active");
10663 assert_eq!(view.cursor, -1);
10664 assert!(!view.has_resume_token);
10665 assert_eq!(view.effective_scopes, None);
10666 assert_eq!(view.reason_code, None);
10667 assert!(client.sync_needed, "a re-arm asks for a pull");
10668 assert!(matches!(
10669 client.drain_sync_intents().as_slice(),
10670 [SyncIntent::Interactive]
10671 ));
10672 let stored: String = client
10673 .conn
10674 .query_row(
10675 "SELECT state_json FROM _syncular_subscriptions WHERE id = 'sub'",
10676 [],
10677 |row| row.get(0),
10678 )
10679 .expect("persisted row");
10680 let stored: Value = serde_json::from_str(&stored).expect("stored json");
10681 assert_eq!(stored["status"], json!("active"));
10682 assert_eq!(stored["cursor"], json!(-1));
10683 assert_eq!(stored["bootstrapState"], Value::Null);
10684 assert_eq!(stored["effectiveScopes"], Value::Null);
10685 assert_eq!(stored["reasonCode"], Value::Null);
10686 }
10687
10688 #[test]
10689 fn a_failed_registration_is_not_rearmed() {
10690 let mut client = client();
10691 client
10692 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10693 .expect("subscribe");
10694 client.subs[0].state = SubState::Failed;
10695 client.subs[0].reason_code = Some("sync.scope_revoked".to_owned());
10696 client.persist_sub(&client.subs[0]).expect("persist");
10697 client.sync_needed = false;
10698 client.sync_intent_queue.clear();
10699
10700 client
10701 .subscribe("sub".into(), "tasks".into(), scopes(), None)
10702 .expect("subscribe");
10703
10704 let view = client.subscription_state("sub").expect("state");
10705 assert_eq!(view.status, "failed");
10706 assert_eq!(view.reason_code.as_deref(), Some("sync.scope_revoked"));
10707 assert!(!client.sync_needed);
10708 assert!(client.drain_sync_intents().is_empty());
10709 }
10710}
10711
10712#[derive(Debug, Clone)]
10713struct OutboxOp {
10714 upsert: bool,
10715 table: String,
10716 row_id: String,
10717 base_version: Option<i64>,
10718 values: Option<Map<String, Value>>,
10722 insert: bool,
10726}
10727
10728impl OutboxOp {
10729 fn presented_values(&self, table: &TableSchema) -> Option<Cow<'_, Map<String, Value>>> {
10734 let values = self.values.as_ref()?;
10735 if !self.insert || table.columns.iter().all(|c| values.contains_key(&c.name)) {
10736 return Some(Cow::Borrowed(values));
10737 }
10738 full_row_values(table, values.clone()).ok().map(Cow::Owned)
10739 }
10740}
10741
10742impl From<&OutboxOp> for CommitOperation {
10743 fn from(operation: &OutboxOp) -> Self {
10744 Self {
10745 table: operation.table.clone(),
10746 row_id: operation.row_id.clone(),
10747 op: if operation.upsert { "upsert" } else { "delete" }.to_owned(),
10748 base_version: operation.base_version,
10749 values: operation.values.clone(),
10750 insert: operation.insert,
10751 }
10752 }
10753}
10754
10755#[derive(Debug, Clone)]
10756struct OutboxCommit {
10757 client_commit_id: String,
10758 ops: Vec<OutboxOp>,
10759}
10760
10761fn commit_audit_operations(commit: &OutboxCommit) -> Vec<(&str, Vec<&str>, bool)> {
10765 commit
10766 .ops
10767 .iter()
10768 .filter(|operation| operation.upsert)
10769 .map(|operation| {
10770 (
10771 operation.table.as_str(),
10772 operation
10773 .values
10774 .as_ref()
10775 .map(|values| values.keys().map(String::as_str).collect())
10776 .unwrap_or_default(),
10777 operation.insert,
10778 )
10779 })
10780 .collect()
10781}
10782
10783type FailedCommit = (OutboxCommit, Map<String, Value>);
10784
10785#[derive(Debug, Clone)]
10786struct CompiledLocalDataPurgeTarget {
10787 table: String,
10788 selectors: Vec<(String, Vec<String>)>,
10789}
10790
10791struct StoredCommitOutcomeRow {
10792 sequence: i64,
10793 client_commit_id: String,
10794 status: String,
10795 recorded_at_ms: i64,
10796 results_json: String,
10797 operations_json: Option<String>,
10798 resolution: String,
10799 resolved_at_ms: Option<i64>,
10800 replacement_client_commit_id: Option<String>,
10801}
10802
10803impl StoredCommitOutcomeRow {
10804 fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Self> {
10805 Ok(Self {
10806 sequence: row.get(0)?,
10807 client_commit_id: row.get(1)?,
10808 status: row.get(2)?,
10809 recorded_at_ms: row.get(3)?,
10810 results_json: row.get(4)?,
10811 operations_json: row.get(5)?,
10812 resolution: row.get(6)?,
10813 resolved_at_ms: row.get(7)?,
10814 replacement_client_commit_id: row.get(8)?,
10815 })
10816 }
10817}
10818
10819enum SectionError {
10822 FailClosed,
10823 Abort(String, String),
10824}
10825
10826#[derive(Debug)]
10827pub(crate) struct RequestMeta {
10828 pushed_ids: Vec<String>,
10829 fresh: Vec<(String, bool)>,
10832 pub(crate) accept: u8,
10833 deferred_commits: usize,
10837}
10838
10839#[derive(Default)]
10840struct ChangeAccumulator {
10841 tables: BTreeMap<String, Option<BTreeSet<String>>>,
10843 windows: BTreeMap<(String, String), BTreeSet<String>>,
10844 status: bool,
10845 conflicts: bool,
10846 rejections: bool,
10847 outcomes: bool,
10848}
10849
10850impl ChangeAccumulator {
10851 fn table(&mut self, table: &str) {
10852 self.tables.insert(table.to_owned(), None);
10853 }
10854
10855 fn scope(&mut self, table: &str, key: String) {
10856 match self.tables.get_mut(table) {
10857 Some(None) => {}
10858 Some(Some(keys)) => {
10859 keys.insert(key);
10860 }
10861 None => {
10862 self.tables
10863 .insert(table.to_owned(), Some(BTreeSet::from([key])));
10864 }
10865 }
10866 }
10867
10868 fn window(&mut self, base_key: &str, table: &str, unit: &str) {
10869 self.windows
10870 .entry((base_key.to_owned(), table.to_owned()))
10871 .or_default()
10872 .insert(unit.to_owned());
10873 }
10874
10875 fn touched(&self) -> bool {
10876 !self.tables.is_empty()
10877 || !self.windows.is_empty()
10878 || self.status
10879 || self.conflicts
10880 || self.rejections
10881 || self.outcomes
10882 }
10883}
10884
10885const PUSH_OPS_PER_REQUEST: usize = 500;
10891const MAX_LOCAL_PURGE_TARGETS: usize = 64;
10892const MAX_LOCAL_PURGE_SELECTORS: usize = 8;
10893const MAX_LOCAL_PURGE_VALUES: usize = 128;
10894const MAX_LOCAL_PURGE_VALUE_LENGTH: usize = 256;
10895pub const SECURITY_PREFLIGHT_REQUIRED_CODE: &str = "client.security_preflight_required";
10896
10897struct OverlayDirty(RefCell<Option<BTreeSet<String>>>);
10899
10900impl OverlayDirty {
10901 fn clean() -> Self {
10902 Self(RefCell::new(Some(BTreeSet::new())))
10903 }
10904 fn get(&self) -> bool {
10905 self.0
10906 .borrow()
10907 .as_ref()
10908 .is_none_or(|tables| !tables.is_empty())
10909 }
10910 fn set(&self, dirty: bool) {
10911 *self.0.borrow_mut() = if dirty { None } else { Some(BTreeSet::new()) };
10912 }
10913 fn table(&self, table: &str) {
10914 if let Some(tables) = self.0.borrow_mut().as_mut() {
10915 tables.insert(table.to_owned());
10916 }
10917 }
10918 fn snapshot(&self) -> Option<BTreeSet<String>> {
10919 self.0.borrow().clone()
10920 }
10921 fn restore(&self, tables: Option<BTreeSet<String>>) {
10922 *self.0.borrow_mut() = tables;
10923 }
10924}
10925
10926pub struct SyncClient {
10927 #[cfg(feature = "bench-internals")]
10928 benchmark_phases: Recorder,
10929 conn: Connection,
10930 schema: ClientSchema,
10931 client_id: String,
10932 limits: ClientLimits,
10933 subs: Vec<Subscription>,
10934 outbox: Vec<OutboxCommit>,
10935 failed_commits: Vec<FailedCommit>,
10936 retain_failed_commits: bool,
10937 conflicts: Vec<ConflictRecord>,
10938 rejections: Vec<RejectionRecord>,
10939 schema_floor: Option<SchemaFloor>,
10940 lease_state: Option<LeaseState>,
10942 stopped: bool,
10944 upgrading: bool,
10946 sync_needed: bool,
10948 realtime_policy: RealtimePolicy,
10951 realtime_state: RealtimeState,
10952 realtime_reason_code: Option<String>,
10953 realtime_retry_delay_ms: Option<u64>,
10954 presence: HashMap<String, HashMap<String, PresencePeer>>,
10956 now_ms: Option<i64>,
10959 encryption: crate::values::EncryptionConfig,
10963 security_preflight: bool,
10966 transport_enabled: bool,
10967 previous_version: Option<PreviousVersionContextConfig>,
10971 insert_sql: RefCell<HashMap<String, String>>,
10977 overlay_dirty: OverlayDirty,
10980 storage_failure: RefCell<Option<QueryReadFailure>>,
10982 #[cfg(test)]
10985 overlay_rebuild_count: Cell<usize>,
10986 #[cfg(test)]
10987 acknowledged_replay_count: Cell<usize>,
10988 #[cfg(test)]
10991 outcome_prune_count: Cell<usize>,
10992 progress: ProgressObserver,
10994 change_queue: VecDeque<ClientChangeBatch>,
10995 sync_intent_queue: VecDeque<SyncIntent>,
10996 retry_delay_ms: u64,
10998 round_retry_delay_ms: Option<u64>,
11000 round_failure: Option<String>,
11005 active_round: Option<uuid::Uuid>,
11006 last_round: Option<DiagnosticLastRound>,
11007 last_change: Option<DiagnosticLastChange>,
11008 query_failures: Vec<DiagnosticQueryFailure>,
11010 coalesced: Option<CoalescedApply>,
11012 coalesced_failure: Option<String>,
11015}
11016
11017struct CoalescedApply {
11021 rows: usize,
11022 subs: Vec<Subscription>,
11023 outbox: Vec<OutboxCommit>,
11024 failed_commits: Vec<FailedCommit>,
11025 conflicts: Vec<ConflictRecord>,
11026 rejections: Vec<RejectionRecord>,
11027 schema_floor: Option<SchemaFloor>,
11028 lease_state: Option<LeaseState>,
11029 stopped: bool,
11030 upgrading: bool,
11031 sync_needed: bool,
11032 overlay_dirty: Option<BTreeSet<String>>,
11033 change_queue: usize,
11034 sync_intent_queue: usize,
11035 last_change: Option<DiagnosticLastChange>,
11036}
11037
11038const COALESCED_APPLY_ROWS: usize = 1024;
11039
11040pub(crate) fn quote_ident(name: &str) -> String {
11041 format!("\"{}\"", name.replace('"', "\"\""))
11042}
11043pub(crate) fn meta_get(conn: &Connection, key: &str) -> Option<String> {
11047 conn.query_row(
11048 "SELECT value FROM _syncular_meta WHERE key = ?1",
11049 rusqlite::params![key],
11050 |row| row.get::<_, String>(0),
11051 )
11052 .ok()
11053}
11054
11055pub(crate) fn meta_set(conn: &Connection, key: &str, value: &str) {
11056 let _ = conn.execute(
11057 "INSERT OR REPLACE INTO _syncular_meta (key, value) VALUES (?1, ?2)",
11058 rusqlite::params![key, value],
11059 );
11060}
11061
11062pub(crate) fn meta_delete(conn: &Connection, key: &str) {
11063 let _ = conn.execute(
11064 "DELETE FROM _syncular_meta WHERE key = ?1",
11065 rusqlite::params![key],
11066 );
11067}
11068
11069fn row_id_predicate(table: &TableSchema) -> String {
11071 let key = quote_ident(&table.primary_key);
11072 let text_match = format!("CAST({key} AS TEXT) = ?1");
11073 match table.columns[table.pk_index].ty {
11074 ColumnType::String | ColumnType::Json => {
11075 format!("{key} = ?1 AND {text_match}")
11076 }
11077 ColumnType::Integer | ColumnType::Boolean => {
11081 format!("{key} = +CAST(?1 AS INTEGER) AND {text_match}")
11082 }
11083 _ => text_match,
11086 }
11087}
11088
11089fn is_local_operation_code_like(value: &str) -> bool {
11090 let bytes = value.as_bytes();
11091 bytes.first().is_some_and(u8::is_ascii_alphanumeric)
11092 && bytes
11093 .iter()
11094 .all(|byte| byte.is_ascii_alphanumeric() || matches!(*byte, b'.' | b'_' | b':' | b'-'))
11095}
11096
11097fn base_table(name: &str) -> String {
11098 quote_ident(&format!("_syncular_base_{name}"))
11099}
11100
11101type PendingEvict = (String, String, Vec<(String, Vec<String>)>);
11106
11107fn window_base_key(base: &WindowBase) -> String {
11108 format!(
11109 "{}\0{}\0{}",
11110 base.table,
11111 base.variable,
11112 canonical_scope_json(&base.fixed_scopes)
11113 )
11114}
11115
11116fn unit_scopes(base: &WindowBase, unit: &str) -> Vec<(String, Vec<String>)> {
11118 let mut scopes = base.fixed_scopes.clone();
11119 scopes.retain(|(k, _)| k != &base.variable);
11120 scopes.push((base.variable.clone(), vec![unit.to_owned()]));
11121 scopes
11122}
11123
11124fn derive_sub_id(base: &WindowBase, unit: &str) -> String {
11128 let canonical = canonical_scope_json(&unit_scopes(base, unit));
11129 let digest = Sha256::digest(canonical.as_bytes());
11130 let hex = bytes_to_hex(&digest);
11131 format!("w:{}:{}", base.table, &hex[..16])
11132}
11133
11134fn visible_table(name: &str) -> String {
11135 quote_ident(name)
11136}
11137
11138const FTS_SOURCE_ID_COLUMN: &str = "_syncular_source_id";
11139
11140fn is_synced_table_name(name: &str) -> bool {
11143 if name.starts_with("sqlite_") {
11144 return false;
11145 }
11146 if name.starts_with("_syncular_base_") {
11147 return true; }
11149 !name.starts_with("_syncular_")
11151}
11152
11153fn blob_id_for(bytes: &[u8]) -> String {
11155 let digest = Sha256::digest(bytes);
11156 format!("sha256:{}", bytes_to_hex(&digest))
11157}
11158
11159enum RowParam<'a> {
11163 Cell(&'a Option<ColumnValue>),
11164 Version(i64),
11165}
11166
11167impl rusqlite::ToSql for RowParam<'_> {
11168 fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
11169 Ok(match self {
11170 RowParam::Version(v) => ToSqlOutput::Owned(SqlValue::Integer(*v)),
11171 RowParam::Cell(cell) => match cell {
11172 None => ToSqlOutput::Owned(SqlValue::Null),
11173 Some(ColumnValue::String(s)) => ToSqlOutput::Borrowed(ValueRef::Text(s.as_bytes())),
11174 Some(ColumnValue::Integer(i)) => ToSqlOutput::Owned(SqlValue::Integer(*i)),
11175 Some(ColumnValue::Float(f)) => ToSqlOutput::Owned(SqlValue::Real(*f)),
11176 Some(ColumnValue::Boolean(b)) => {
11177 ToSqlOutput::Owned(SqlValue::Integer(i64::from(*b)))
11178 }
11179 Some(ColumnValue::Json(raw)) | Some(ColumnValue::BlobRef(raw)) => {
11180 ToSqlOutput::Borrowed(ValueRef::Text(raw.0.as_bytes()))
11181 }
11182 Some(ColumnValue::Bytes(b)) | Some(ColumnValue::Crdt(b)) => {
11184 ToSqlOutput::Borrowed(ValueRef::Blob(b))
11185 }
11186 },
11187 })
11188 }
11189}
11190
11191fn owned_sql_value(param: impl rusqlite::ToSql) -> Result<SqlValue, String> {
11193 match param.to_sql().map_err(|error| error.to_string())? {
11194 ToSqlOutput::Owned(value) => Ok(value),
11195 ToSqlOutput::Borrowed(value) => {
11196 SqlValue::try_from(value).map_err(|error| error.to_string())
11197 }
11198 _ => Err("sync.invalid_request: unsupported SQLite key parameter".into()),
11199 }
11200}
11201
11202fn image_cell_param<'a>(column: &Column, value: ValueRef<'a>) -> Result<ToSqlOutput<'a>, String> {
11210 use ssp2::segment::ColumnType;
11211 let mismatch = || {
11212 Err(format!(
11213 "image column {:?} holds a value of the wrong type",
11214 column.name
11215 ))
11216 };
11217 match value {
11218 ValueRef::Null => {
11219 if !column.nullable {
11220 return Err(format!(
11221 "image column {:?} is NULL but not nullable",
11222 column.name
11223 ));
11224 }
11225 Ok(ToSqlOutput::Owned(SqlValue::Null))
11226 }
11227 ValueRef::Integer(i) => match column.ty {
11228 ColumnType::Integer => Ok(ToSqlOutput::Borrowed(value)),
11229 ColumnType::Boolean => Ok(ToSqlOutput::Owned(SqlValue::Integer(i64::from(i != 0)))),
11230 ColumnType::Float => Ok(ToSqlOutput::Owned(SqlValue::Real(i as f64))),
11231 _ => mismatch(),
11232 },
11233 ValueRef::Real(_) => match column.ty {
11234 ColumnType::Float => Ok(ToSqlOutput::Borrowed(value)),
11235 _ => mismatch(),
11236 },
11237 ValueRef::Text(t) => {
11238 std::str::from_utf8(t)
11239 .map_err(|_| format!("image column {:?} is not UTF-8", column.name))?;
11240 match column.ty {
11241 ColumnType::String | ColumnType::Json | ColumnType::BlobRef => {
11242 Ok(ToSqlOutput::Borrowed(value))
11243 }
11244 _ => mismatch(),
11245 }
11246 }
11247 ValueRef::Blob(_) => match column.ty {
11248 ColumnType::Bytes | ColumnType::Crdt => Ok(ToSqlOutput::Borrowed(value)),
11250 _ => mismatch(),
11251 },
11252 }
11253}
11254
11255pub(crate) fn sql_ref_to_json(column: &Column, value: rusqlite::types::ValueRef<'_>) -> Value {
11256 use rusqlite::types::ValueRef;
11257 match value {
11258 ValueRef::Null => Value::Null,
11259 ValueRef::Integer(i) => match column.ty {
11260 ssp2::segment::ColumnType::Boolean => Value::Bool(i != 0),
11261 ssp2::segment::ColumnType::Float => {
11262 serde_json::Number::from_f64(i as f64).map_or(Value::Null, Value::Number)
11263 }
11264 _ => Value::from(i),
11265 },
11266 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11267 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11268 ValueRef::Blob(b) => {
11269 let mut map = Map::new();
11270 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11271 Value::Object(map)
11272 }
11273 }
11274}
11275
11276fn json_param_to_sql(value: &Value) -> Result<SqlValue, String> {
11280 Ok(match value {
11281 Value::Null => SqlValue::Null,
11282 Value::Bool(b) => SqlValue::Integer(i64::from(*b)),
11283 Value::Number(n) => {
11284 if let Some(i) = n.as_i64() {
11285 SqlValue::Integer(i)
11286 } else if let Some(f) = n.as_f64() {
11287 SqlValue::Real(f)
11288 } else {
11289 return Err(format!("query param number {n} is out of range"));
11290 }
11291 }
11292 Value::String(s) => SqlValue::Text(s.clone()),
11293 Value::Object(_) => {
11294 if let Some(hex) = value.get("$bytes").and_then(Value::as_str) {
11295 SqlValue::Blob(crate::values::hex_to_bytes(hex)?)
11296 } else if let Some(decimal) = value.get("$bigint").and_then(Value::as_str) {
11297 SqlValue::Integer(decimal.parse::<i64>().map_err(|_| {
11298 format!("query bigint param {decimal:?} is outside SQLite's i64 range")
11299 })?)
11300 } else {
11301 return Err(
11302 "query object param must be a {$bytes: hex} or {$bigint: decimal} value"
11303 .to_owned(),
11304 );
11305 }
11306 }
11307 Value::Array(_) => return Err("query array params are not supported".to_owned()),
11308 })
11309}
11310
11311fn sql_ref_to_json_dynamic(value: rusqlite::types::ValueRef<'_>) -> Value {
11316 use rusqlite::types::ValueRef;
11317 match value {
11318 ValueRef::Null => Value::Null,
11319 ValueRef::Integer(i) => {
11320 const MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991;
11324 if (-MAX_SAFE_INTEGER..=MAX_SAFE_INTEGER).contains(&i) {
11325 Value::from(i)
11326 } else {
11327 let mut map = Map::new();
11328 map.insert("$bigint".to_owned(), Value::from(i.to_string()));
11329 Value::Object(map)
11330 }
11331 }
11332 ValueRef::Real(f) => serde_json::Number::from_f64(f).map_or(Value::Null, Value::Number),
11333 ValueRef::Text(t) => Value::from(String::from_utf8_lossy(t).into_owned()),
11334 ValueRef::Blob(b) => {
11335 let mut map = Map::new();
11336 map.insert("$bytes".to_owned(), Value::from(bytes_to_hex(b)));
11337 Value::Object(map)
11338 }
11339 }
11340}
11341
11342fn query_connection(
11344 conn: &Connection,
11345 sql: &str,
11346 params: &[QueryValue],
11347) -> Result<Vec<QueryRow>, QueryReadFailure> {
11348 crate::query_guard::assert_read_only_query(sql)?;
11349 let lowered_sql = crate::query_guard::lower_public_query_sql(sql);
11350 let bound: Vec<SqlValue> = params
11351 .iter()
11352 .map(json_param_to_sql)
11353 .collect::<Result<_, _>>()?;
11354 let mut stmt = conn.prepare(&lowered_sql)?;
11355 let column_names: Vec<String> = stmt.column_names().into_iter().map(str::to_owned).collect();
11356 let bound_refs: Vec<&dyn rusqlite::ToSql> =
11357 bound.iter().map(|v| v as &dyn rusqlite::ToSql).collect();
11358 let mut sql_rows = stmt.query(bound_refs.as_slice())?;
11359 let mut out = Vec::new();
11360 while let Some(row) = sql_rows.next()? {
11361 let mut record = Map::new();
11362 for (i, name) in column_names.iter().enumerate() {
11363 let value = row.get_ref(i)?;
11364 record.insert(name.clone(), sql_ref_to_json_dynamic(value));
11365 }
11366 out.push(record);
11367 }
11368 Ok(out)
11369}
11370
11371fn persisted_window_state(
11372 conn: &Connection,
11373 base: &WindowBase,
11374) -> Result<WindowState, QueryReadFailure> {
11375 let mut stmt = conn.prepare(
11376 "SELECT windows.unit, subscriptions.state_json
11377 FROM _syncular_windows AS windows
11378 JOIN _syncular_subscriptions AS subscriptions
11379 ON subscriptions.id = windows.sub_id
11380 WHERE windows.base = ?1
11381 ORDER BY windows.unit ASC",
11382 )?;
11383 let rows = stmt.query_map(rusqlite::params![window_base_key(base)], |row| {
11384 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
11385 })?;
11386 let mut units = Vec::new();
11387 let mut pending = Vec::new();
11388 for row in rows {
11389 let (unit, raw) = row?;
11390 let state: Value = serde_json::from_str(&raw)
11391 .map_err(|error| format!("invalid persisted window subscription: {error}"))?;
11392 let is_pending = state.get("status").and_then(Value::as_str) != Some("active")
11393 || state.get("cursor").and_then(Value::as_i64).unwrap_or(-1) < 0
11394 || state
11395 .get("bootstrapState")
11396 .is_some_and(|value| !value.is_null());
11397 if is_pending {
11398 pending.push(unit.clone());
11399 }
11400 units.push(unit);
11401 }
11402 Ok(WindowState { units, pending })
11403}
11404
11405#[derive(Debug, Clone, Copy)]
11407pub struct SnapshotStatement<'a> {
11408 pub sql: &'a str,
11409 pub params: &'a [Value],
11410}
11411
11412#[derive(Debug, Clone, Default)]
11416pub struct SnapshotReadRequest<'a> {
11417 pub statements: Vec<SnapshotStatement<'a>>,
11418 pub coverage: &'a [WindowCoverage],
11419 pub subscriptions: Vec<String>,
11420 pub commit_ids: Vec<String>,
11421}
11422
11423#[cfg(test)]
11424thread_local! {
11425 static SNAPSHOT_READ_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
11429 std::cell::RefCell::new(None);
11430}
11431
11432#[cfg(test)]
11433fn run_snapshot_read_hook() {
11434 SNAPSHOT_READ_HOOK.with(|slot| {
11435 if let Some(hook) = slot.borrow_mut().take() {
11436 hook();
11437 }
11438 });
11439}
11440
11441fn snapshot_read_connection(
11446 conn: &Connection,
11447 request: &SnapshotReadRequest<'_>,
11448) -> Result<SnapshotRead, QueryReadFailure> {
11449 conn.execute_batch("SAVEPOINT syncular_snapshot_read")?;
11450 let corrupt = |message: &str| QueryReadFailure {
11451 code: Some("sync.local_corrupt"),
11452 sqlite_code: None,
11453 sqlite_message: None,
11454 rollback_failure: None,
11455 message: message.to_owned(),
11456 };
11457 let stored_failure = |error: rusqlite::Error, message: &str| match error {
11458 rusqlite::Error::Utf8Error(..)
11459 | rusqlite::Error::InvalidColumnType(..)
11460 | rusqlite::Error::FromSqlConversionFailure(..)
11461 | rusqlite::Error::IntegralValueOutOfRange(..) => corrupt(message),
11462 _ => QueryReadFailure::from(error),
11463 };
11464 let result: Result<SnapshotRead, QueryReadFailure> = (|| {
11465 let revision: Option<String> = conn
11468 .query_row(
11469 "SELECT value FROM _syncular_meta WHERE key = ?1",
11470 rusqlite::params![LOCAL_REVISION_KEY],
11471 |row| row.get(0),
11472 )
11473 .optional()
11474 .map_err(|error| stored_failure(error, "persisted local revision is invalid"))?;
11475 let revision = match revision {
11476 None => "0".to_owned(),
11477 Some(value)
11478 if value == "0"
11479 || (matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11480 && value.bytes().all(|b| b.is_ascii_digit())
11481 && value.parse::<u64>().is_ok()) =>
11482 {
11483 value
11484 }
11485 Some(_) => return Err(corrupt("persisted local revision is invalid")),
11486 };
11487 let mut queries = Vec::with_capacity(request.statements.len());
11488 for statement in &request.statements {
11489 queries.push(query_connection(conn, statement.sql, statement.params)?);
11490 #[cfg(test)]
11491 run_snapshot_read_hook();
11492 }
11493 let mut pending = Vec::new();
11494 let mut missing = Vec::new();
11495 for requested in request.coverage {
11496 let base_key = window_base_key(&requested.base);
11497 let state = persisted_window_state(conn, &requested.base)?;
11498 for unit in BTreeSet::from_iter(requested.units.iter().cloned()) {
11499 let reference = WindowUnitRef {
11500 base_key: base_key.clone(),
11501 unit: unit.clone(),
11502 };
11503 if !state.units.iter().any(|held| held == &unit) {
11504 missing.push(reference);
11505 } else if state.pending.iter().any(|held| held == &unit) {
11506 pending.push(reference);
11507 }
11508 }
11509 }
11510 let mut subscriptions = Vec::with_capacity(request.subscriptions.len());
11511 for id in &request.subscriptions {
11512 let row: Option<(String, String)> = conn
11513 .query_row(
11514 "SELECT tbl, state_json FROM _syncular_subscriptions WHERE id = ?1",
11515 rusqlite::params![id],
11516 |row| Ok((row.get(0)?, row.get(1)?)),
11517 )
11518 .optional()
11519 .map_err(|error| {
11520 stored_failure(error, "persisted subscription state is invalid")
11521 })?;
11522 let Some((table, raw)) = row else {
11523 subscriptions.push(SubscriptionCatchup::Unknown { id: id.clone() });
11524 continue;
11525 };
11526 let state: Value = serde_json::from_str(&raw)
11529 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11530 let status = match state.get("status").and_then(Value::as_str) {
11531 Some(value @ ("active" | "revoked" | "failed")) => value,
11532 _ => return Err(corrupt("persisted subscription state is invalid")),
11533 };
11534 let Some(cursor) = state.get("cursor").and_then(Value::as_i64) else {
11535 return Err(corrupt("persisted subscription state is invalid"));
11536 };
11537 if cursor.unsigned_abs() > MAX_JS_SAFE_INTEGER {
11538 return Err(corrupt("persisted subscription state is invalid"));
11539 }
11540 let effective_scopes = state
11541 .get("effectiveScopes")
11542 .filter(|value| !value.is_null());
11543 if let Some(scopes) = effective_scopes {
11544 json_to_scope_map(scopes)
11545 .map_err(|_| corrupt("persisted subscription state is invalid"))?;
11546 }
11547 let has_resume_token = match state.get("bootstrapState") {
11548 None | Some(Value::Null) => false,
11549 Some(Value::String(_)) => true,
11550 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11551 };
11552 let reason_code = match state.get("reasonCode") {
11555 None | Some(Value::Null) => None,
11556 Some(Value::String(value)) => Some(value.clone()),
11557 Some(_) => return Err(corrupt("persisted subscription state is invalid")),
11558 };
11559 let active = status == "active";
11560 subscriptions.push(SubscriptionCatchup::Known {
11561 id: id.clone(),
11562 table,
11563 status: status.to_owned(),
11564 cursor,
11565 has_resume_token,
11566 bootstrap_complete: active && cursor >= 0 && !has_resume_token,
11567 known_pending_pages: active && (has_resume_token || cursor < 0),
11568 effective_scopes: effective_scopes.cloned(),
11569 reason_code,
11570 });
11571 }
11572 let mut deliveries = Vec::with_capacity(request.commit_ids.len());
11573 for id in &request.commit_ids {
11574 let pending: bool = conn.query_row(
11575 "SELECT EXISTS(SELECT 1 FROM _syncular_outbox WHERE commit_id = ?1)",
11576 rusqlite::params![id],
11577 |row| row.get(0),
11578 )?;
11579 if pending {
11580 deliveries.push(CommitDelivery::Pending {
11581 client_commit_id: id.clone(),
11582 });
11583 continue;
11584 }
11585 let row = conn
11586 .query_row(
11587 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
11588 resolution, resolved_at_ms, replacement_client_commit_id
11589 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
11590 rusqlite::params![id],
11591 StoredCommitOutcomeRow::from_row,
11592 )
11593 .optional()
11594 .map_err(|error| stored_failure(error, "persisted commit outcome is invalid"))?;
11595 deliveries.push(match row {
11596 Some(row) => CommitDelivery::Known {
11597 client_commit_id: id.clone(),
11598 outcome: SyncClient::outcome_from_row(row)
11599 .map_err(|_| corrupt("persisted commit outcome is invalid"))?,
11600 },
11601 None => CommitDelivery::Unknown {
11602 client_commit_id: id.clone(),
11603 },
11604 });
11605 }
11606 Ok(SnapshotRead {
11607 revision,
11608 queries,
11609 coverage: CoverageSnapshot {
11610 complete: pending.is_empty() && missing.is_empty(),
11611 pending,
11612 missing,
11613 },
11614 subscriptions,
11615 deliveries,
11616 })
11617 })();
11618 match result {
11619 Ok(read) => {
11620 conn.execute_batch("RELEASE syncular_snapshot_read")?;
11621 Ok(read)
11622 }
11623 Err(mut error) => {
11624 if let Err(rollback) = conn
11625 .execute_batch("ROLLBACK TO syncular_snapshot_read; RELEASE syncular_snapshot_read")
11626 {
11627 error.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
11628 }
11629 Err(error)
11630 }
11631 }
11632}
11633
11634fn snapshot_connection(
11635 conn: &Connection,
11636 sql: &str,
11637 params: &[Value],
11638 coverage: &[WindowCoverage],
11639) -> Result<QuerySnapshot, QueryReadFailure> {
11640 let read = snapshot_read_connection(
11641 conn,
11642 &SnapshotReadRequest {
11643 statements: vec![SnapshotStatement { sql, params }],
11644 coverage,
11645 subscriptions: Vec::new(),
11646 commit_ids: Vec::new(),
11647 },
11648 )?;
11649 Ok(QuerySnapshot {
11650 revision: read.revision,
11651 rows: read
11652 .queries
11653 .into_iter()
11654 .next()
11655 .expect("single-query snapshot returns one result"),
11656 coverage: read.coverage,
11657 })
11658}
11659
11660pub struct FileQuerySnapshotReader {
11665 path: String,
11666 conn: Option<Connection>,
11667}
11668
11669impl FileQuerySnapshotReader {
11670 #[must_use]
11671 pub fn new(path: impl Into<String>) -> Self {
11672 Self {
11673 path: path.into(),
11674 conn: None,
11675 }
11676 }
11677
11678 fn connection(&mut self) -> Result<&Connection, QueryReadFailure> {
11679 if self.conn.is_none() {
11680 let conn = Connection::open_with_flags(
11681 &self.path,
11682 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
11683 )
11684 .map_err(QueryReadFailure::from)?;
11685 conn.busy_timeout(std::time::Duration::from_millis(250))
11686 .map_err(QueryReadFailure::from)?;
11687 self.conn = Some(conn);
11688 }
11689 self.conn
11690 .as_ref()
11691 .ok_or_else(|| QueryReadFailure::from("read sidecar connection missing".to_owned()))
11692 }
11693
11694 pub fn snapshot_read(
11697 &mut self,
11698 request: &SnapshotReadRequest<'_>,
11699 ) -> Result<SnapshotRead, QueryReadFailure> {
11700 snapshot_read_connection(self.connection()?, request)
11701 }
11702
11703 pub fn local_revision(&mut self) -> Result<String, QueryReadFailure> {
11706 Ok(self
11707 .snapshot_read(&SnapshotReadRequest::default())?
11708 .revision)
11709 }
11710
11711 pub fn subscription_catchup(
11713 &mut self,
11714 id: &str,
11715 ) -> Result<SubscriptionCatchup, QueryReadFailure> {
11716 let read = self.snapshot_read(&SnapshotReadRequest {
11717 subscriptions: vec![id.to_owned()],
11718 ..Default::default()
11719 })?;
11720 Ok(read
11721 .subscriptions
11722 .into_iter()
11723 .next()
11724 .expect("one requested catch-up"))
11725 }
11726
11727 pub fn commit_delivery(&mut self, id: &str) -> Result<CommitDelivery, QueryReadFailure> {
11730 let read = self.snapshot_read(&SnapshotReadRequest {
11731 commit_ids: vec![id.to_owned()],
11732 ..Default::default()
11733 })?;
11734 Ok(read
11735 .deliveries
11736 .into_iter()
11737 .next()
11738 .expect("one requested delivery"))
11739 }
11740
11741 pub fn query_snapshot(
11744 &mut self,
11745 sql: &str,
11746 params: &[Value],
11747 coverage: &[WindowCoverage],
11748 ) -> Result<QuerySnapshot, QueryReadFailure> {
11749 snapshot_connection(self.connection()?, sql, params, coverage)
11750 }
11751}
11752
11753fn validate_authority_reads(
11754 schema: &ClientSchema,
11755 declarations: &[crate::api::AuthorityReadDeclaration],
11756) -> Result<(), String> {
11757 let mut seen = BTreeSet::new();
11758 for read in declarations {
11759 let table = schema
11760 .table(&read.table)
11761 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
11762 let plain = |name: &str| {
11763 table.columns.iter().enumerate().any(|(i, c)| {
11764 c.name == name
11765 && name != "authority_version"
11766 && !name.starts_with("_sync")
11767 && !table.encrypted_columns.iter().any(|e| e.index == i)
11768 && matches!(
11769 c.ty,
11770 ssp2::segment::ColumnType::String
11771 | ssp2::segment::ColumnType::Integer
11772 | ssp2::segment::ColumnType::Float
11773 | ssp2::segment::ColumnType::Boolean
11774 | ssp2::segment::ColumnType::Json
11775 )
11776 })
11777 };
11778 if !seen.insert(&read.table)
11779 || read.columns.is_empty()
11780 || read.columns.iter().collect::<BTreeSet<_>>().len() != read.columns.len()
11781 || !read.columns.contains(&table.primary_key)
11782 || !read.columns.iter().all(|c| plain(c))
11783 || read.scopes.is_empty()
11784 || !read.scopes.iter().all(|(v, values)| {
11785 table
11786 .scope_column(v)
11787 .is_some_and(|c| read.columns.iter().any(|column| column == c) && plain(c))
11788 && !values.is_empty()
11789 && values.iter().all(|v| !v.is_empty() && v != "*")
11790 })
11791 {
11792 return Err("client.authority_read_forbidden: authority reads require plain columns, a primary key and nonempty plain scope selectors".into());
11793 }
11794 }
11795 Ok(())
11796}
11797
11798fn schema_marker_read_failure(error: rusqlite::Error) -> String {
11803 let failure = QueryReadFailure::from(error);
11804 if failure.code.is_some() {
11805 failure.to_string()
11806 } else {
11807 "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned()
11808 }
11809}
11810
11811fn validate_client_limits(limits: &crate::api::ClientLimits) -> Result<(), String> {
11815 if limits.outcome_retention_max_entries == Some(0) {
11816 return Err("sync.invalid_request: outcomeRetentionMaxEntries must be positive".to_owned());
11817 }
11818 if [
11819 limits.max_push_commits_per_request,
11820 limits.max_push_operations_per_request,
11821 limits.max_push_request_bytes,
11822 ]
11823 .into_iter()
11824 .flatten()
11825 .any(|value| value == 0 || value > u32::MAX as usize)
11826 {
11827 return Err(
11828 "sync.invalid_request: push limit must be an integer in 1..=4294967295".to_owned(),
11829 );
11830 }
11831 Ok(())
11832}
11833
11834fn read_local_schema_version(conn: &Connection, requested: i32) -> Result<Option<i32>, String> {
11835 let corrupt =
11836 || "sync.local_corrupt: persisted local schema marker is unreadable or invalid".to_owned();
11837 let table_type: Option<String> = conn
11838 .query_row(
11839 "SELECT type FROM sqlite_master WHERE name = '_syncular_meta'",
11840 [],
11841 |row| row.get(0),
11842 )
11843 .optional()
11844 .map_err(schema_marker_read_failure)?;
11845 let Some(table_type) = table_type else {
11846 return Ok(None);
11847 };
11848 if table_type != "table" {
11849 return Err(corrupt());
11850 }
11851 let mut statement = conn
11855 .prepare("SELECT value FROM _syncular_meta WHERE key = ?1")
11856 .map_err(schema_marker_read_failure)?;
11857 let mut rows = statement
11858 .query([LOCAL_SCHEMA_VERSION_KEY])
11859 .map_err(schema_marker_read_failure)?;
11860 let value: Option<String> = rows
11861 .next()
11862 .map_err(schema_marker_read_failure)?
11863 .map(|row| row.get(0))
11864 .transpose()
11865 .map_err(schema_marker_read_failure)?;
11866 if rows.next().map_err(schema_marker_read_failure)?.is_some() {
11867 return Err(corrupt());
11868 }
11869 let Some(value) = value else {
11870 let has_descriptor = conn
11871 .query_row(
11872 "SELECT 1 FROM _syncular_meta WHERE key = ?1 LIMIT 1",
11873 [crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY],
11874 |_| Ok(()),
11875 )
11876 .optional()
11877 .map_err(schema_marker_read_failure)?
11878 .is_some();
11879 if has_descriptor {
11880 return Err(corrupt());
11881 }
11882 return Ok(None);
11883 };
11884 if !matches!(value.as_bytes().first(), Some(b'1'..=b'9'))
11885 || !value.bytes().all(|b| b.is_ascii_digit())
11886 {
11887 return Err(corrupt());
11888 }
11889 let version = value.parse::<i32>().map_err(|_| corrupt())?;
11890 if version > requested {
11891 return Err(
11892 "client.schema_downgrade: persisted local schema is newer than the requested schema"
11893 .to_owned(),
11894 );
11895 }
11896 Ok(Some(version))
11897}
11898
11899#[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
11903fn prepare_wasm_replica(path: &str) -> Result<(), String> {
11904 if path.is_empty() || path == ":memory:" {
11905 return Ok(());
11906 }
11907 crate::wasm_vfs::disable_foreign_lock_check()
11908}
11909
11910#[cfg(not(all(target_arch = "wasm32", target_os = "unknown")))]
11911fn prepare_wasm_replica(_path: &str) -> Result<(), String> {
11912 Ok(())
11913}
11914
11915impl SyncClient {
11916 pub fn new_with_identity(
11917 client_id: Option<String>,
11918 schema_json: &Value,
11919 limits: ClientLimits,
11920 ) -> Result<Self, String> {
11921 let resolved = client_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
11922 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11923 Self::with_connection(resolved, schema_json, limits, conn)
11924 }
11925
11926 pub fn new(
11927 client_id: String,
11928 schema_json: &Value,
11929 limits: ClientLimits,
11930 ) -> Result<Self, String> {
11931 let conn = Connection::open_in_memory().map_err(|e| e.to_string())?;
11932 Self::with_connection(client_id, schema_json, limits, conn)
11933 }
11934
11935 pub fn open_path(
11941 client_id: String,
11942 schema_json: &Value,
11943 limits: ClientLimits,
11944 path: &str,
11945 ) -> Result<Self, String> {
11946 validate_client_limits(&limits)?;
11947 parse_schema_json(schema_json)?;
11948 prepare_wasm_replica(path)?;
11949 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11950 Self::with_connection(client_id, schema_json, limits, conn)
11951 }
11952
11953 pub fn open_path_with_identity(
11957 client_id: Option<String>,
11958 schema_json: &Value,
11959 limits: ClientLimits,
11960 path: &str,
11961 ) -> Result<Self, String> {
11962 Self::open_path_with_identity_journal(
11963 client_id,
11964 schema_json,
11965 limits,
11966 path,
11967 !cfg!(all(target_arch = "wasm32", target_os = "unknown")),
11968 )
11969 }
11970
11971 fn open_path_with_identity_journal(
11972 client_id: Option<String>,
11973 schema_json: &Value,
11974 limits: ClientLimits,
11975 path: &str,
11976 require_wal: bool,
11977 ) -> Result<Self, String> {
11978 validate_client_limits(&limits)?;
11979 parse_schema_json(schema_json)?;
11982 prepare_wasm_replica(path)?;
11983 let conn = Connection::open(path).map_err(|e| format!("open db {path:?}: {e}"))?;
11984 conn.busy_timeout(std::time::Duration::from_millis(250))
11992 .map_err(|error| format!("configure db {path:?} busy timeout: {error}"))?;
11993 conn.pragma_update(None, "synchronous", "FULL")
11998 .map_err(|error| format!("configure db {path:?} synchronous: {error}"))?;
11999 let client = Self::with_connection_identity(client_id, schema_json, limits, conn)?;
12000 if require_wal {
12004 let journal_mode: String = client
12005 .conn
12006 .pragma_update_and_check(None, "journal_mode", "WAL", |row| row.get(0))
12007 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12008 if journal_mode != "wal" {
12009 return Err(
12010 "sync.invalid_request: file-backed client requires WAL journal mode".to_owned(),
12011 );
12012 }
12013 }
12014 Ok(client)
12015 }
12016
12017 pub fn reset_replica(path: &str) -> Result<(), String> {
12029 if path.is_empty() || path == ":memory:" {
12030 return Err("sync.invalid_request: a memory replica has nothing to reset".to_owned());
12031 }
12032 let vfs = unsafe { rusqlite::ffi::sqlite3_vfs_find(std::ptr::null()) };
12036 if vfs.is_null() {
12037 return Err("client.storage_io: no SQLite VFS is registered".to_owned());
12038 }
12039 let delete = unsafe { (*vfs).xDelete }
12040 .ok_or_else(|| "client.storage_io: the SQLite VFS cannot delete files".to_owned())?;
12041 for suffix in ["-wal", "-shm", "-journal", ""] {
12042 let file_name = format!("{path}{suffix}");
12043 let c_name = std::ffi::CString::new(file_name.as_str()).map_err(|_| {
12044 format!("sync.invalid_request: replica path {file_name:?} contains a NUL byte")
12045 })?;
12046 let code = unsafe { delete(vfs, c_name.as_ptr(), 1) };
12049 if code != rusqlite::ffi::SQLITE_OK {
12050 return Err(format!(
12051 "client.storage_io: deleting replica file {file_name:?} failed with SQLite code {code}"
12052 ));
12053 }
12054 }
12055 Ok(())
12056 }
12057
12058 #[must_use]
12059 pub fn client_id(&self) -> &str {
12060 &self.client_id
12061 }
12062
12063 pub fn reset_client_id(&mut self, transport: &mut dyn Transport) -> String {
12070 self.disconnect_realtime(transport);
12071 let client_id = uuid::Uuid::new_v4().to_string();
12072 self.client_id.clone_from(&client_id);
12073 self.set_meta(CLIENT_ID_KEY, &client_id);
12074 self.round_failure = None;
12075 client_id
12076 }
12077
12078 pub fn with_connection(
12085 client_id: String,
12086 schema_json: &Value,
12087 limits: ClientLimits,
12088 conn: Connection,
12089 ) -> Result<Self, String> {
12090 Self::with_connection_identity(Some(client_id), schema_json, limits, conn)
12091 }
12092
12093 fn with_connection_identity(
12096 client_id: Option<String>,
12097 schema_json: &Value,
12098 limits: ClientLimits,
12099 conn: Connection,
12100 ) -> Result<Self, String> {
12101 validate_client_limits(&limits)?;
12102 let schema = parse_schema_json(schema_json)?;
12103 validate_authority_reads(&schema, &limits.authority_reads)?;
12104 let previous_version = match limits.previous_version_context {
12107 Some(config) => {
12108 config.validate()?;
12109 Some(config)
12110 }
12111 None => None,
12112 };
12113 let mut client = SyncClient {
12114 #[cfg(feature = "bench-internals")]
12115 benchmark_phases: Recorder::default(),
12116 conn,
12117 schema,
12118 client_id: client_id
12119 .clone()
12120 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
12121 limits,
12122 subs: Vec::new(),
12123 outbox: Vec::new(),
12124 failed_commits: Vec::new(),
12125 retain_failed_commits: false,
12126 conflicts: Vec::new(),
12127 rejections: Vec::new(),
12128 schema_floor: None,
12129 lease_state: None,
12130 stopped: false,
12131 upgrading: false,
12132 sync_needed: false,
12133 realtime_policy: RealtimePolicy::Optional,
12134 realtime_state: RealtimeState::Disconnected,
12135 realtime_reason_code: None,
12136 realtime_retry_delay_ms: None,
12137 presence: HashMap::new(),
12138 now_ms: None,
12139 encryption: crate::values::EncryptionConfig::default(),
12140 security_preflight: false,
12141 transport_enabled: true,
12142 previous_version,
12143 insert_sql: RefCell::new(HashMap::new()),
12144 overlay_dirty: OverlayDirty::clean(),
12145 storage_failure: RefCell::new(None),
12146 #[cfg(test)]
12147 overlay_rebuild_count: Cell::new(0),
12148 #[cfg(test)]
12149 acknowledged_replay_count: Cell::new(0),
12150 #[cfg(test)]
12151 outcome_prune_count: Cell::new(0),
12152 progress: ProgressObserver::default(),
12153 change_queue: VecDeque::new(),
12154 sync_intent_queue: VecDeque::new(),
12155 retry_delay_ms: 250,
12156 round_retry_delay_ms: None,
12157 round_failure: None,
12158 active_round: None,
12159 last_round: None,
12160 last_change: None,
12161 query_failures: Vec::new(),
12162 coalesced: None,
12163 coalesced_failure: None,
12164 };
12165 client
12169 .conn
12170 .set_prepared_statement_cache_capacity(64.max(client.schema.tables.len() * 4));
12171 client
12177 .conn
12178 .execute_batch("BEGIN IMMEDIATE")
12179 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12180 let startup = (|| -> Result<(), String> {
12181 let marker = read_local_schema_version(&client.conn, client.schema.version)?;
12184 client.create_bookkeeping_tables()?;
12187 match client.get_meta(CLIENT_ID_KEY) {
12188 Some(existing) => match client_id {
12189 Some(requested) if existing != requested => {
12190 return Err(format!(
12191 "client.identity_mismatch: this database belongs to {existing:?}; refusing to rebind it to {requested:?}"
12192 ))
12193 }
12194 _ => client.client_id = existing,
12195 },
12196 None => client.set_meta(CLIENT_ID_KEY, &client.client_id),
12197 }
12198 client.restore_persisted_state()?;
12199 client.failed_commits = client.load_failed_commits()?;
12200 match marker {
12201 None => {
12202 client.create_synced_tables()?;
12203 client.persist_schema_version()?;
12204 client.save_subscription_scope_schema();
12205 }
12206 Some(version) if version == client.schema.version => {
12207 client.create_synced_tables()?;
12208 set_local_schema_descriptor(&client.conn, &client.schema)
12212 .map_err(|error| Self::sqlite_failure(&client.storage_failure, error))?;
12213 client.save_subscription_scope_schema();
12214 }
12215 Some(_) => client.run_schema_reset()?,
12216 }
12217 client.reconcile_previous_version_at_boot()?;
12223 client.clear_satisfied_persisted_schema_floor();
12224 client.prune_unknown_subscriptions(false)?;
12225 client.drop_unencodable_outbox(false, None)?;
12234 if marker == Some(client.schema.version)
12235 && (!client.outbox.is_empty()
12236 || !client.failed_commits.is_empty()
12237 || client.has_acknowledged_rows())
12238 {
12239 client.overlay_dirty.set(true);
12243 client.rebuild_overlay()?;
12244 }
12245 client.enqueue_startup_sync_if_needed()?;
12252 Ok(())
12253 })();
12254 match startup {
12255 Ok(()) => match client.conn.execute_batch("COMMIT") {
12256 Ok(()) => Ok(client),
12257 Err(error) => {
12258 let failure = Self::sqlite_failure(&client.storage_failure, error);
12259 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12260 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12262 }
12263 Err(failure)
12264 }
12265 },
12266 Err(error) => {
12267 if let Err(rollback) = client.conn.execute_batch("ROLLBACK") {
12268 let _ = Self::sqlite_failure(&client.storage_failure, rollback);
12270 }
12271 Err(error)
12272 }
12273 }
12274 }
12275
12276 pub fn set_now_ms(&mut self, now_ms: i64) {
12279 self.now_ms = Some(now_ms);
12280 }
12281
12282 pub fn set_encryption(&mut self, encryption: crate::values::EncryptionConfig) {
12286 self.encryption = encryption;
12287 }
12288
12289 #[must_use]
12290 pub fn security_lifecycle(&self) -> &'static str {
12291 if self.security_preflight {
12292 "preflight"
12293 } else {
12294 "active"
12295 }
12296 }
12297
12298 #[must_use]
12299 pub fn security_preflight(&self) -> bool {
12300 self.security_preflight
12301 }
12302
12303 pub fn set_transport_enabled(&mut self, transport: &mut dyn Transport, enabled: bool) {
12305 if self.transport_enabled != enabled {
12306 self.transport_enabled = enabled;
12307 self.sync_intent_queue.clear();
12308 self.sync_intent_queue
12309 .push_back(if enabled && !self.security_preflight {
12310 SyncIntent::Interactive
12311 } else {
12312 SyncIntent::None
12313 });
12314 }
12315 if !enabled && self.active_round.is_none() {
12317 self.disconnect_realtime(transport);
12318 }
12319 }
12320
12321 pub fn transport_enabled(&self) -> bool {
12322 self.transport_enabled
12323 }
12324
12325 pub fn begin_security_preflight(&mut self) {
12332 self.seal_security_on_teardown();
12333 self.set_meta(SECURITY_PREFLIGHT_PENDING_KEY, "1");
12336 }
12337
12338 pub fn seal_security_on_teardown(&mut self) {
12348 self.cancel_sync_round();
12349 self.security_preflight = true;
12350 self.encryption = crate::values::EncryptionConfig::default();
12351 self.sync_intent_queue.clear();
12352 }
12353
12354 pub fn activate_security(
12356 &mut self,
12357 encryption: crate::values::EncryptionConfig,
12358 ) -> Result<(), String> {
12359 if !self.security_preflight {
12360 return Err(
12361 "sync.invalid_request: activateSecurity requires security preflight".to_owned(),
12362 );
12363 }
12364 self.enqueue_startup_sync_if_needed()?;
12365 self.encryption = encryption;
12366 self.security_preflight = false;
12367 self.delete_meta(SECURITY_PREFLIGHT_PENDING_KEY);
12368 Ok(())
12369 }
12370
12371 fn clock_now_ms(&self) -> i64 {
12372 self.now_ms.unwrap_or_else(crate::wall_clock_ms)
12373 }
12374
12375 fn create_bookkeeping_tables(&self) -> Result<(), String> {
12376 self.conn
12378 .execute_batch(
12379 "CREATE TABLE IF NOT EXISTS _syncular_outbox (
12380 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12381 commit_id TEXT NOT NULL UNIQUE, ops_json TEXT NOT NULL);
12382 CREATE TABLE IF NOT EXISTS _syncular_row_deliveries (
12383 tbl TEXT NOT NULL, id TEXT NOT NULL, commit_seq INTEGER NOT NULL, PRIMARY KEY(tbl,id));
12384 CREATE TABLE IF NOT EXISTS _syncular_acked_rows (
12385 commit_id TEXT NOT NULL, idx INTEGER NOT NULL, tbl TEXT NOT NULL, id TEXT NOT NULL,
12386 commit_seq INTEGER NOT NULL, op_json TEXT NOT NULL, intent_json TEXT,
12387 PRIMARY KEY(commit_id, idx));
12388 CREATE TABLE IF NOT EXISTS _syncular_failed_commits (
12389 seq INTEGER PRIMARY KEY AUTOINCREMENT, client_commit_id TEXT NOT NULL UNIQUE,
12390 operations_json TEXT NOT NULL, initial_json TEXT NOT NULL);
12391 CREATE TABLE IF NOT EXISTS _syncular_commit_outcomes (
12392 seq INTEGER PRIMARY KEY AUTOINCREMENT,
12393 client_commit_id TEXT NOT NULL UNIQUE,
12394 status TEXT NOT NULL CHECK(status IN ('applied', 'cached', 'conflict', 'rejected')),
12395 recorded_at_ms INTEGER NOT NULL,
12396 results_json TEXT NOT NULL,
12397 operations_json TEXT,
12398 resolution TEXT NOT NULL DEFAULT 'active'
12399 CHECK(resolution IN ('active', 'resolved_keep_server', 'superseded', 'dismissed')),
12400 resolved_at_ms INTEGER,
12401 replacement_client_commit_id TEXT);
12402 CREATE INDEX IF NOT EXISTS _syncular_commit_outcomes_resolution_seq
12403 ON _syncular_commit_outcomes(resolution, seq);
12404 CREATE TABLE IF NOT EXISTS _syncular_subscriptions (
12405 id TEXT PRIMARY KEY, tbl TEXT NOT NULL, state_json TEXT NOT NULL);
12406 CREATE TABLE IF NOT EXISTS _syncular_meta (
12407 key TEXT PRIMARY KEY, value TEXT NOT NULL);
12408 CREATE TABLE IF NOT EXISTS _syncular_windows (
12409 base TEXT NOT NULL, unit TEXT NOT NULL, sub_id TEXT NOT NULL,
12410 PRIMARY KEY (base, unit));
12411 CREATE TABLE IF NOT EXISTS _syncular_window_pending_evict (
12412 sub_id TEXT PRIMARY KEY, tbl TEXT NOT NULL,
12413 effective_scopes TEXT NOT NULL);
12414 CREATE TABLE IF NOT EXISTS _syncular_blob_commit_refs(
12415 commit_id TEXT NOT NULL, blob_id TEXT NOT NULL,
12416 PRIMARY KEY(commit_id, blob_id));
12417 CREATE INDEX IF NOT EXISTS _syncular_blob_commit_refs_body
12418 ON _syncular_blob_commit_refs(blob_id);",
12419 )
12420 .map_err(|e| e.to_string())?;
12421 let _ = self
12424 .conn
12425 .execute_batch("ALTER TABLE _syncular_commit_outcomes ADD COLUMN operations_json TEXT");
12426 if self.get_meta(LOCAL_REVISION_KEY).is_none() {
12427 self.set_meta(LOCAL_REVISION_KEY, "0");
12428 }
12429 if self.schema_has_blobs() {
12432 let exists = self
12433 .conn
12434 .query_row(
12435 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = '_syncular_blobs')",
12436 [],
12437 |row| row.get::<_, bool>(0),
12438 )
12439 .map_err(|e| e.to_string())?;
12440 if exists {
12441 let mut statement = self
12442 .conn
12443 .prepare("PRAGMA table_info(_syncular_blobs)")
12444 .map_err(|e| e.to_string())?;
12445 let mut columns = statement
12446 .query_map([], |row| row.get::<_, String>(1))
12447 .map_err(|e| e.to_string())?
12448 .collect::<Result<Vec<_>, _>>()
12449 .map_err(|e| e.to_string())?;
12450 columns.sort();
12451 let mut expected = vec![
12452 "blob_id",
12453 "byte_length",
12454 "bytes",
12455 "created_at_ms",
12456 "media_type",
12457 ];
12458 expected.sort_unstable();
12459 if columns != expected {
12460 return Err(
12461 "sync.schema_mismatch: Local blob schema is incompatible".to_owned()
12462 );
12463 }
12464 } else {
12465 self.conn
12466 .execute_batch(
12467 "CREATE TABLE _syncular_blobs(
12468 blob_id TEXT PRIMARY KEY,
12469 bytes BLOB NOT NULL,
12470 byte_length INTEGER NOT NULL,
12471 media_type TEXT,
12472 created_at_ms INTEGER NOT NULL);",
12473 )
12474 .map_err(|e| e.to_string())?;
12475 }
12476 self.conn
12477 .execute_batch(
12478 "CREATE TABLE IF NOT EXISTS _syncular_blob_uploads(
12479 blob_id TEXT PRIMARY KEY,
12480 media_type TEXT,
12481 created_at_ms INTEGER NOT NULL);",
12482 )
12483 .map_err(|e| e.to_string())?;
12484 }
12485 Ok(())
12486 }
12487
12488 fn schema_has_blobs(&self) -> bool {
12490 self.schema
12491 .tables
12492 .iter()
12493 .any(|t| t.columns.iter().any(|c| c.ty == ColumnType::BlobRef))
12494 }
12495
12496 fn get_meta(&self, key: &str) -> Option<String> {
12499 meta_get(&self.conn, key)
12500 }
12501
12502 fn get_meta_strict(&self, key: &str) -> Result<Option<String>, String> {
12503 self.conn
12504 .query_row(
12505 "SELECT value FROM _syncular_meta WHERE key = ?1",
12506 rusqlite::params![key],
12507 |row| row.get::<_, String>(0),
12508 )
12509 .optional()
12510 .map_err(|_| {
12511 "sync.local_corrupt: persisted local rebootstrap receipt is unreadable".to_owned()
12512 })
12513 }
12514
12515 fn persist_schema_version(&self) -> Result<(), String> {
12518 self.conn
12519 .execute(
12520 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
12521 rusqlite::params![LOCAL_SCHEMA_VERSION_KEY, self.schema.version.to_string()],
12522 )
12523 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
12524 set_local_schema_descriptor(&self.conn, &self.schema)
12525 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
12526 }
12527
12528 pub(crate) fn set_meta(&self, key: &str, value: &str) {
12529 meta_set(&self.conn, key, value);
12530 }
12531
12532 fn delete_meta(&self, key: &str) {
12533 meta_delete(&self.conn, key);
12534 }
12535
12536 fn load_outbox(&self) -> Result<Vec<OutboxCommit>, String> {
12538 let mut stmt = self
12539 .conn
12540 .prepare("SELECT commit_id, ops_json FROM _syncular_outbox ORDER BY seq ASC")
12541 .map_err(|error| error.to_string())?;
12542 let rows = stmt
12543 .query_map([], |row| {
12544 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
12545 })
12546 .map_err(|error| error.to_string())?;
12547 let mut commits = Vec::new();
12548 for row in rows {
12549 let (client_commit_id, raw) = row.map_err(|error| error.to_string())?;
12550 let entries: Vec<Value> = serde_json::from_str(&raw).map_err(|error| {
12551 format!("invalid persisted outbox {client_commit_id:?}: {error}")
12552 })?;
12553 let mut ops = Vec::with_capacity(entries.len());
12554 for entry in entries {
12555 let op = entry.get("op").and_then(Value::as_str).unwrap_or("delete");
12556 ops.push(OutboxOp {
12557 upsert: op == "upsert",
12558 table: entry
12559 .get("table")
12560 .and_then(Value::as_str)
12561 .ok_or_else(|| "persisted outbox operation missing table".to_owned())?
12562 .to_owned(),
12563 row_id: entry
12564 .get("rowId")
12565 .and_then(Value::as_str)
12566 .ok_or_else(|| "persisted outbox operation missing rowId".to_owned())?
12567 .to_owned(),
12568 base_version: entry.get("baseVersion").and_then(Value::as_i64),
12569 values: entry.get("values").and_then(Value::as_object).cloned(),
12572 insert: entry.get("insert") == Some(&Value::Bool(true)),
12573 });
12574 }
12575 commits.push(OutboxCommit {
12576 client_commit_id,
12577 ops,
12578 });
12579 }
12580 Ok(commits)
12581 }
12582
12583 fn restore_persisted_state(&mut self) -> Result<(), String> {
12584 self.subs = {
12585 let mut stmt = self
12586 .conn
12587 .prepare("SELECT id, tbl, state_json FROM _syncular_subscriptions ORDER BY id ASC")
12588 .map_err(|error| error.to_string())?;
12589 let rows = stmt
12590 .query_map([], |row| {
12591 Ok((
12592 row.get::<_, String>(0)?,
12593 row.get::<_, String>(1)?,
12594 row.get::<_, String>(2)?,
12595 ))
12596 })
12597 .map_err(|error| error.to_string())?;
12598 let mut subscriptions = Vec::new();
12599 for row in rows {
12600 let (id, table, raw) = row.map_err(|error| error.to_string())?;
12601 let state: Value = serde_json::from_str(&raw)
12602 .map_err(|error| format!("invalid persisted subscription {id:?}: {error}"))?;
12603 let requested = json_to_scope_map(
12604 state.get("requested").unwrap_or(&Value::Object(Map::new())),
12605 )?;
12606 let effective = state
12607 .get("effectiveScopes")
12608 .filter(|value| !value.is_null())
12609 .map(json_to_scope_map)
12610 .transpose()?;
12611 subscriptions.push(Subscription {
12612 id,
12613 table,
12614 requested,
12615 params: state
12616 .get("params")
12617 .and_then(Value::as_str)
12618 .map(str::to_owned),
12619 cursor: state.get("cursor").and_then(Value::as_i64).unwrap_or(-1),
12620 bootstrap_state: state
12621 .get("bootstrapState")
12622 .and_then(Value::as_str)
12623 .map(str::to_owned),
12624 state: SubState::parse(
12625 state
12626 .get("status")
12627 .and_then(Value::as_str)
12628 .unwrap_or("active"),
12629 ),
12630 reason_code: state
12631 .get("reasonCode")
12632 .and_then(Value::as_str)
12633 .map(str::to_owned),
12634 effective,
12635 synced_once: state
12636 .get("syncedOnce")
12637 .and_then(Value::as_bool)
12638 .unwrap_or(false),
12639 });
12640 }
12641 subscriptions
12642 };
12643
12644 self.outbox = self.load_outbox()?;
12645
12646 self.prune_commit_outcomes()?;
12647 let active = self.commit_outcomes(CommitOutcomeQuery {
12648 active_only: true,
12649 ..CommitOutcomeQuery::default()
12650 })?;
12651 self.conflicts = active
12652 .iter()
12653 .flat_map(|outcome| outcome.results.iter())
12654 .filter_map(|result| match result {
12655 CommitOperationOutcome::Conflict { conflict } => Some(conflict.clone()),
12656 _ => None,
12657 })
12658 .collect();
12659 self.rejections = active
12660 .iter()
12661 .flat_map(|outcome| outcome.results.iter())
12662 .filter_map(|result| match result {
12663 CommitOperationOutcome::Error { rejection } => Some(rejection.clone()),
12664 _ => None,
12665 })
12666 .collect();
12667
12668 self.lease_state = self
12669 .get_meta(LEASE_STATE_KEY)
12670 .map(|raw| serde_json::from_str(&raw))
12671 .transpose()
12672 .map_err(|error| format!("invalid persisted lease state: {error}"))?;
12673 self.schema_floor = self
12674 .get_meta(SCHEMA_FLOOR_KEY)
12675 .map(|raw| serde_json::from_str(&raw))
12676 .transpose()
12677 .map_err(|error| format!("invalid persisted schema floor: {error}"))?;
12678 self.stopped = self.schema_floor.is_some();
12679 if self.get_meta(SECURITY_PREFLIGHT_PENDING_KEY).as_deref() == Some("1") {
12682 self.security_preflight = true;
12683 }
12684 Ok(())
12685 }
12686
12687 fn save_subscription_scope_schema(&self) {
12688 let declarations: BTreeMap<_, BTreeMap<_, _>> = self
12689 .schema
12690 .tables
12691 .iter()
12692 .map(|table| {
12693 (
12694 table.name.clone(),
12695 table
12696 .scope_variables
12697 .iter()
12698 .map(|scope| {
12699 (
12700 scope.variable.clone(),
12701 [scope.prefix.clone(), scope.column.clone()],
12702 )
12703 })
12704 .collect(),
12705 )
12706 })
12707 .collect();
12708 self.set_meta(
12709 "subscriptionScopeSchema",
12710 &serde_json::to_string(&declarations).expect("scope schema serialization"),
12711 );
12712 }
12713
12714 fn prune_unknown_subscriptions(&mut self, bump: bool) -> Result<(), String> {
12716 let previous: Option<BTreeMap<String, BTreeMap<String, [String; 2]>>> = if bump {
12717 self.get_meta("subscriptionScopeSchema")
12718 .map(|raw| serde_json::from_str(&raw))
12719 .transpose()
12720 .map_err(|error| format!("invalid persisted scope schema: {error}"))?
12721 } else {
12722 None
12723 };
12724 let compatible = |table_name: &str, requested: &[(String, Vec<String>)]| {
12725 let Some(table) = self.schema.table(table_name) else {
12726 return false;
12727 };
12728 if requested
12729 .iter()
12730 .any(|(variable, _)| table.scope_column(variable).is_none())
12731 {
12732 return false;
12733 }
12734 if !bump {
12735 return true;
12736 }
12737 let Some(old) = previous.as_ref().and_then(|tables| tables.get(table_name)) else {
12738 return false;
12739 };
12740 let variables: BTreeSet<_> = if requested.is_empty() {
12741 old.keys()
12742 .cloned()
12743 .chain(
12744 table
12745 .scope_variables
12746 .iter()
12747 .map(|scope| scope.variable.clone()),
12748 )
12749 .collect()
12750 } else {
12751 requested
12752 .iter()
12753 .map(|(variable, _)| variable.clone())
12754 .collect()
12755 };
12756 variables.iter().all(|variable| {
12757 let current = table
12758 .scope_variables
12759 .iter()
12760 .find(|scope| &scope.variable == variable);
12761 match (old.get(variable), current) {
12762 (Some(old), Some(current)) => {
12763 old == &[current.prefix.clone(), current.column.clone()]
12764 }
12765 _ => false,
12766 }
12767 })
12768 };
12769 let mut stale_ids: BTreeSet<_> = self
12770 .subs
12771 .iter()
12772 .filter(|sub| !compatible(&sub.table, &sub.requested))
12773 .map(|sub| sub.id.clone())
12774 .collect();
12775 for (id, table, effective) in self.load_pending_evictions()? {
12776 if !compatible(&table, &effective) {
12777 stale_ids.insert(id);
12778 }
12779 }
12780 for id in &stale_ids {
12781 for sql in [
12782 "DELETE FROM _syncular_windows WHERE sub_id = ?1",
12783 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
12784 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
12785 ] {
12786 self.conn
12787 .execute(sql, rusqlite::params![id])
12788 .map_err(|error| error.to_string())?;
12789 }
12790 }
12791 self.subs.retain(|sub| !stale_ids.contains(&sub.id));
12792 Ok(())
12793 }
12794
12795 #[must_use]
12796 pub fn local_revision(&self) -> u64 {
12797 self.get_meta(LOCAL_REVISION_KEY)
12798 .and_then(|value| value.parse().ok())
12799 .unwrap_or(0)
12800 }
12801
12802 pub fn snapshot_read(
12808 &mut self,
12809 request: &SnapshotReadRequest<'_>,
12810 owner: Option<QueryOwner<'_>>,
12811 ) -> Result<SnapshotRead, QueryReadFailure> {
12812 let result = snapshot_read_connection(&self.conn, request);
12813 if let Some(owner) = owner {
12814 self.record_query_read(owner, result.as_ref().err());
12815 }
12816 result
12817 }
12818
12819 #[must_use]
12820 pub fn status_snapshot(&self) -> SyncStatusSnapshot {
12821 SyncStatusSnapshot {
12822 current_schema_version: self.schema.version,
12823 outbox: self.outbox.len(),
12824 upgrading: self.upgrading,
12825 lease_state: self.lease_state.clone(),
12826 schema_floor: self.schema_floor.clone(),
12827 sync_needed: self.sync_needed,
12828 previous_version_context: self.previous_version_status(),
12829 }
12830 }
12831
12832 fn previous_version_status(&self) -> PreviousVersionStatus {
12835 let Some(replica_path) = self.previous_version_replica_path() else {
12836 return PreviousVersionStatus {
12837 present: false,
12838 created_at_ms: None,
12839 };
12840 };
12841 match crate::previous_version::read_previous_version_record(&replica_path) {
12842 Ok(Some(record)) => PreviousVersionStatus {
12843 present: true,
12844 created_at_ms: Some(record.created_at_ms),
12845 },
12846 _ => PreviousVersionStatus {
12847 present: std::path::Path::new(
12848 &crate::previous_version::previous_version_container_path(&replica_path),
12849 )
12850 .exists(),
12851 created_at_ms: None,
12852 },
12853 }
12854 }
12855
12856 pub fn previous_version_snapshot(
12862 &self,
12863 spec: &PreviousVersionReadSpec,
12864 ) -> Result<PreviousVersionSnapshot, String> {
12865 let now_ms = self.clock_now_ms();
12866 crate::previous_version::previous_version_snapshot(
12867 &self.conn,
12868 self.previous_version_replica_path().as_deref(),
12869 self.previous_version.as_ref(),
12870 PreviousVersionLifecycle {
12871 lease_inactive: self.previous_version_lease_inactive(now_ms),
12872 scope_revoked: self.subs.iter().any(|sub| sub.state == SubState::Revoked),
12873 coverage_complete: self.previous_version_coverage_complete(),
12874 },
12875 now_ms,
12876 self.schema.version,
12877 spec,
12878 )
12879 }
12880
12881 #[must_use]
12883 pub fn previous_version_audit(&self) -> Option<crate::previous_version::PreviousVersionAudit> {
12884 crate::previous_version::stored_previous_version_audit(&self.conn)
12885 }
12886
12887 pub fn previous_version_discard(
12891 &self,
12892 ) -> crate::previous_version::PreviousVersionDiscardOutcome {
12893 self.drop_previous_version()
12894 }
12895
12896 fn drop_previous_version(&self) -> crate::previous_version::PreviousVersionDiscardOutcome {
12900 let absent = crate::previous_version::PreviousVersionDiscardOutcome {
12901 present: false,
12902 discarded: false,
12903 };
12904 let Some(replica_path) = self.previous_version_replica_path() else {
12905 return absent;
12906 };
12907 crate::previous_version::discard_previous_version(&self.conn, &replica_path)
12908 .unwrap_or(absent)
12909 }
12910
12911 fn previous_version_lease_inactive(&self, now_ms: i64) -> bool {
12913 let Some(lease) = &self.lease_state else {
12914 return false;
12915 };
12916 if lease.error_code.is_some() {
12917 return true;
12918 }
12919 lease.expires_at_ms.is_some_and(|expires| expires <= now_ms)
12920 }
12921
12922 fn previous_version_coverage_complete(&self) -> bool {
12926 let active = self
12927 .subs
12928 .iter()
12929 .filter(|sub| sub.state == SubState::Active)
12930 .collect::<Vec<_>>();
12931 if active.is_empty() {
12932 return false;
12933 }
12934 active
12935 .iter()
12936 .all(|sub| sub.cursor >= 0 && sub.bootstrap_state.is_none())
12937 }
12938
12939 fn previous_version_lifetime_check(&self) {
12943 let Some(config) = self.previous_version.filter(|config| config.enabled) else {
12944 return;
12945 };
12946 let now_ms = self.clock_now_ms();
12947 if self.previous_version_lease_inactive(now_ms) {
12948 let _ = self.drop_previous_version();
12949 return;
12950 }
12951 let Some(replica_path) = self.previous_version_replica_path() else {
12952 return;
12953 };
12954 let Ok(Some(record)) = crate::previous_version::read_previous_version_record(&replica_path)
12955 else {
12956 return;
12957 };
12958 if self.subs.iter().any(|sub| sub.state == SubState::Revoked) {
12959 let _ = self.drop_previous_version();
12960 return;
12961 }
12962 if now_ms - record.created_at_ms > config.max_age_ms {
12963 let _ = self.drop_previous_version();
12964 return;
12965 }
12966 if self.previous_version_coverage_complete() {
12967 let _ = self.drop_previous_version();
12968 }
12969 }
12970
12971 pub fn diagnostics_snapshot(
12972 &self,
12973 request: &ClientDiagnosticsRequest,
12974 ) -> Result<ClientDiagnosticsSnapshot, String> {
12975 if request.expected_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS {
12976 return Err(format!(
12977 "sync.invalid_request: diagnosticsSnapshot accepts at most {MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS} expected subscriptions"
12978 ));
12979 }
12980 let mut subscriptions = BTreeMap::<String, DiagnosticSubscription>::new();
12981 for sub in &self.subs {
12982 let reset = sub.cursor < 0 && sub.reason_code.as_deref() == Some("sync.cursor_expired");
12983 let complete =
12984 sub.state == SubState::Active && sub.cursor >= 0 && sub.bootstrap_state.is_none();
12985 let state = match sub.state {
12986 SubState::Revoked => "revoked",
12987 SubState::Failed => "failed",
12988 SubState::Active if reset => "reset",
12989 SubState::Active if complete => "complete",
12990 SubState::Active => "bootstrapping",
12991 };
12992 subscriptions.insert(
12993 sub.id.clone(),
12994 DiagnosticSubscription {
12995 id: sub.id.clone(),
12996 table: sub.table.clone(),
12997 state: state.to_owned(),
12998 complete,
12999 cursor: Some(sub.cursor),
13000 reason_code: sub.reason_code.as_deref().map(Self::diagnostic_code),
13001 },
13002 );
13003 }
13004 for expected in &request.expected_subscriptions {
13005 if expected.id.is_empty() || expected.table.is_empty() {
13006 return Err("sync.invalid_request: diagnosticsSnapshot expected subscriptions require non-empty id and table strings".to_owned());
13007 }
13008 if subscriptions
13009 .get(&expected.id)
13010 .is_some_and(|registered| registered.table != expected.table)
13011 {
13012 subscriptions.insert(
13013 expected.id.clone(),
13014 DiagnosticSubscription {
13015 id: expected.id.clone(),
13016 table: expected.table.clone(),
13017 state: "failed".to_owned(),
13018 complete: false,
13019 cursor: None,
13020 reason_code: Some("client.subscription_intent_mismatch".to_owned()),
13021 },
13022 );
13023 } else {
13024 subscriptions.entry(expected.id.clone()).or_insert_with(|| {
13025 DiagnosticSubscription {
13026 id: expected.id.clone(),
13027 table: expected.table.clone(),
13028 state: "unregistered".to_owned(),
13029 complete: false,
13030 cursor: None,
13031 reason_code: None,
13032 }
13033 });
13034 }
13035 }
13036 let mut ordered_subscriptions = Vec::new();
13037 let mut included = BTreeSet::new();
13038 for expected in &request.expected_subscriptions {
13039 if included.insert(expected.id.clone()) {
13040 if let Some(subscription) = subscriptions.get(&expected.id) {
13041 ordered_subscriptions.push(subscription.clone());
13042 }
13043 }
13044 }
13045 for (id, subscription) in &subscriptions {
13046 if included.insert(id.clone()) {
13047 ordered_subscriptions.push(subscription.clone());
13048 }
13049 }
13050 let subscriptions_truncated =
13051 ordered_subscriptions.len() > MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS;
13052 ordered_subscriptions.truncate(MAX_DIAGNOSTIC_EXPECTED_SUBSCRIPTIONS);
13053 let captured_at_ms = self.clock_now_ms();
13054 let lease = if let Some(error_code) = self
13055 .lease_state
13056 .as_ref()
13057 .and_then(|state| state.error_code.clone())
13058 {
13059 ClientDiagnosticsLease {
13060 state: "stopped".to_owned(),
13061 expires_at_ms: self
13062 .lease_state
13063 .as_ref()
13064 .and_then(|state| state.expires_at_ms),
13065 error_code: Some(Self::diagnostic_code(&error_code)),
13066 }
13067 } else if let Some(expires_at_ms) = self
13068 .lease_state
13069 .as_ref()
13070 .and_then(|state| state.expires_at_ms)
13071 {
13072 ClientDiagnosticsLease {
13073 state: if expires_at_ms <= captured_at_ms {
13074 "expired".to_owned()
13075 } else {
13076 "active".to_owned()
13077 },
13078 expires_at_ms: Some(expires_at_ms),
13079 error_code: None,
13080 }
13081 } else {
13082 ClientDiagnosticsLease {
13083 state: "none".to_owned(),
13084 expires_at_ms: None,
13085 error_code: None,
13086 }
13087 };
13088 let connectivity = match self.last_round.as_ref() {
13089 _ if !self.transport_enabled && self.active_round.is_none() => "offline",
13090 Some(round) if round.status == "succeeded" => "online",
13091 Some(round)
13092 if round.status == "failed"
13093 && round
13094 .error_code
13095 .as_deref()
13096 .is_some_and(Self::retryable_transport_code) =>
13097 {
13098 "offline"
13099 }
13100 _ => "unknown",
13101 };
13102 Ok(ClientDiagnosticsSnapshot {
13103 version: CLIENT_DIAGNOSTICS_VERSION,
13104 captured_at_ms,
13105 host: ClientDiagnosticsHost {
13106 kind: "direct".to_owned(),
13107 role: "single".to_owned(),
13108 connectivity: connectivity.to_owned(),
13109 realtime: self.realtime_state,
13110 realtime_policy: self.realtime_policy,
13111 realtime_reason_code: self.realtime_reason_code.clone(),
13112 realtime_retry_delay_ms: self.realtime_retry_delay_ms,
13113 },
13114 security_lifecycle: self.security_lifecycle().to_owned(),
13115 schema: ClientDiagnosticsSchema {
13116 current_version: self.schema.version,
13117 upgrading: self.upgrading,
13118 required_version: self
13119 .schema_floor
13120 .as_ref()
13121 .and_then(|floor| floor.required_schema_version),
13122 latest_version: self
13123 .schema_floor
13124 .as_ref()
13125 .and_then(|floor| floor.latest_schema_version),
13126 },
13127 replica: ClientDiagnosticsReplica {
13128 local_revision: self.local_revision().to_string(),
13129 sync_needed: self.sync_needed,
13130 pending_outbox: self.outbox.len(),
13131 },
13132 lease,
13133 subscriptions: ordered_subscriptions,
13134 subscriptions_truncated,
13135 last_round: self.last_round.clone(),
13136 last_change: self.last_change.clone(),
13137 storage: self.diagnostics_storage(),
13138 query_failures: self.query_failures.clone(),
13139 })
13140 }
13141
13142 fn diagnostics_storage(&self) -> ClientDiagnosticsStorage {
13143 let read = || -> Result<ClientDiagnosticsStorage, rusqlite::Error> {
13144 let page_count: i64 = self
13145 .conn
13146 .query_row("PRAGMA page_count", [], |row| row.get(0))?;
13147 let page_size: i64 = self
13148 .conn
13149 .query_row("PRAGMA page_size", [], |row| row.get(0))?;
13150 let outbox_bytes: i64 = self
13152 .conn
13153 .prepare_cached("SELECT COALESCE(SUM(LENGTH(ops_json)), 0) FROM _syncular_outbox")?
13154 .query_row([], |row| row.get(0))?;
13155 let (outcome_entries, outcome_bytes): (i64, i64) = self.conn.prepare_cached(
13156 "SELECT COUNT(*), COALESCE(SUM(LENGTH(results_json) + COALESCE(LENGTH(operations_json), 0)), 0) FROM _syncular_commit_outcomes",
13157 )?.query_row([], |row| Ok((row.get(0)?, row.get(1)?)))?;
13158 let blob_bytes = if self.schema_has_blobs() {
13159 self.conn
13160 .prepare_cached("SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs")?
13161 .query_row([], |row| row.get(0))?
13162 } else {
13163 0
13164 };
13165 let pressure = self
13166 .limits
13167 .blob_cache_max_bytes
13168 .is_some_and(|limit| blob_bytes > limit);
13169 Ok(ClientDiagnosticsStorage {
13170 status: if pressure { "pressure" } else { "healthy" }.to_owned(),
13171 database_bytes_approx: Some(page_count.saturating_mul(page_size).max(0)),
13172 pending_outbox_bytes_approx: Some(outbox_bytes.max(0)),
13173 retained_outcome_bytes_approx: Some(outcome_bytes.max(0)),
13174 retained_outcome_entries: Some(outcome_entries.max(0)),
13175 blob_cache_bytes_approx: Some(blob_bytes.max(0)),
13176 pressure_reason_code: pressure.then(|| "client.blob_cache_over_limit".to_owned()),
13177 })
13178 };
13179 read().unwrap_or_else(|_| ClientDiagnosticsStorage {
13180 status: "unreadable".to_owned(),
13181 database_bytes_approx: None,
13182 pending_outbox_bytes_approx: None,
13183 retained_outcome_bytes_approx: None,
13184 retained_outcome_entries: None,
13185 blob_cache_bytes_approx: None,
13186 pressure_reason_code: None,
13187 })
13188 }
13189
13190 pub fn drain_change_batches(&mut self) -> Vec<ClientChangeBatch> {
13191 self.change_queue.drain(..).collect()
13192 }
13193
13194 pub fn drain_sync_intents(&mut self) -> Vec<SyncIntent> {
13195 let intents = self.sync_intent_queue.drain(..).collect();
13196 if self.transport_enabled {
13197 intents
13198 } else {
13199 vec![SyncIntent::None]
13200 }
13201 }
13202
13203 fn schedule_background_retry(&mut self) -> u64 {
13204 let delay_ms = self.retry_delay_ms;
13205 self.sync_intent_queue
13206 .push_back(SyncIntent::Background { delay_ms });
13207 self.retry_delay_ms = (self.retry_delay_ms * 2).min(30_000);
13208 self.round_retry_delay_ms = Some(delay_ms);
13209 delay_ms
13210 }
13211
13212 fn reset_background_retry(&mut self) {
13213 self.retry_delay_ms = 250;
13214 }
13215
13216 fn retryable_transport_code(code: &str) -> bool {
13217 code == "transport.failed"
13218 || code == "transport.timeout"
13219 || code == "transport.unavailable"
13220 || code == "sync.transport_failed"
13221 }
13222
13223 fn unreachable_transport(details: &Option<serde_json::Value>) -> bool {
13228 matches!(
13229 details
13230 .as_ref()
13231 .and_then(|details| details.get("causeKind"))
13232 .and_then(serde_json::Value::as_str),
13233 Some("connect" | "network")
13234 )
13235 }
13236
13237 fn segment_fetch_failed(&mut self, error: TransportError) -> SectionError {
13242 if ssp2::model::valid_round_failure_code(&error.code) {
13243 self.round_failure = Some(error.code.clone());
13244 }
13245 SectionError::Abort(error.code, error.message)
13246 }
13247
13248 fn retryable_failure_code(code: &str) -> bool {
13253 Self::retryable_transport_code(code)
13254 || matches!(
13255 code,
13256 "sync.auth_required"
13257 | "sync.auth_lease_required"
13258 | "sync.auth_lease_revoked"
13259 | "sync.internal_error"
13260 | "sync.idempotency_cache_miss"
13261 | "sync.schema_not_ready"
13262 | "sync.segment_expired"
13263 | "sync.rate_limited"
13264 | "sync.websocket_connection_limit"
13265 )
13266 }
13267
13268 fn diagnostic_code(code: &str) -> String {
13269 let valid = !code.is_empty()
13270 && code.len() <= 96
13271 && code.contains('.')
13272 && code
13273 .bytes()
13274 .next()
13275 .is_some_and(|byte| byte.is_ascii_lowercase())
13276 && code.bytes().all(|byte| {
13277 byte.is_ascii_lowercase()
13278 || byte.is_ascii_digit()
13279 || matches!(byte, b'.' | b'_' | b'-')
13280 });
13281 if valid {
13282 code.to_owned()
13283 } else {
13284 "client.unknown_failure".to_owned()
13285 }
13286 }
13287
13288 fn set_sync_needed(&mut self, value: bool, interactive: bool) {
13289 if self.sync_needed != value {
13290 if self.begin_observation("syncular_status").is_ok() {
13291 self.sync_needed = value;
13292 let batch = ChangeAccumulator {
13293 status: true,
13294 ..ChangeAccumulator::default()
13295 };
13296 if self.finish_observation("syncular_status", batch).is_err() {
13297 self.rollback_observation("syncular_status");
13298 }
13299 } else {
13300 self.sync_needed = value;
13301 }
13302 }
13303 if value && interactive {
13304 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13305 }
13306 }
13307
13308 fn sqlite_failure(
13309 storage_failure: &RefCell<Option<QueryReadFailure>>,
13310 error: rusqlite::Error,
13311 ) -> String {
13312 let message = error.to_string();
13313 let failure = QueryReadFailure::from(error);
13314 let code = failure.code;
13315 if code.is_some() && storage_failure.borrow().is_none() {
13316 *storage_failure.borrow_mut() = Some(failure);
13317 }
13318 match code {
13319 Some(code) => format!("{code}: {message}"),
13320 None => message,
13321 }
13322 }
13323
13324 fn begin_observation(&self, name: &str) -> Result<(), String> {
13325 if let Some(failure) = &self.coalesced_failure {
13326 return Err(failure.clone());
13327 }
13328 if self.coalesced.is_some() && self.conn.is_autocommit() {
13331 return Err("the coalesced apply transaction was rolled back".to_owned());
13332 }
13333 self.conn
13334 .execute_batch(&format!("SAVEPOINT {name}"))
13335 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13336 }
13337
13338 fn begin_coalesced_apply(&mut self) -> Result<(), String> {
13341 if self.coalesced.is_some() || !self.conn.is_autocommit() {
13342 return Ok(());
13343 }
13344 self.conn
13345 .execute_batch("BEGIN")
13346 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13347 self.coalesced = Some(CoalescedApply {
13348 rows: 0,
13349 subs: self.subs.clone(),
13350 outbox: self.outbox.clone(),
13351 failed_commits: self.failed_commits.clone(),
13352 conflicts: self.conflicts.clone(),
13353 rejections: self.rejections.clone(),
13354 schema_floor: self.schema_floor.clone(),
13355 lease_state: self.lease_state.clone(),
13356 stopped: self.stopped,
13357 upgrading: self.upgrading,
13358 sync_needed: self.sync_needed,
13359 overlay_dirty: self.overlay_dirty.snapshot(),
13360 change_queue: self.change_queue.len(),
13361 sync_intent_queue: self.sync_intent_queue.len(),
13362 last_change: self.last_change.clone(),
13363 });
13364 Ok(())
13365 }
13366
13367 fn coalesce_unit(&mut self, rows: usize) -> Result<(), String> {
13370 let Some(open) = &self.coalesced else {
13371 return Ok(());
13372 };
13373 if open.rows > 0 && open.rows + rows > COALESCED_APPLY_ROWS {
13374 self.commit_coalesced_apply()?;
13375 self.begin_coalesced_apply()?;
13376 }
13377 if let Some(open) = &mut self.coalesced {
13378 open.rows += rows;
13379 }
13380 Ok(())
13381 }
13382
13383 fn commit_coalesced_apply(&mut self) -> Result<(), String> {
13388 let Some(open) = self.coalesced.take() else {
13389 return Ok(());
13390 };
13391 let committed = if self.conn.is_autocommit() {
13392 Err("the coalesced apply transaction was rolled back".to_owned())
13393 } else {
13394 self.conn
13395 .execute_batch("COMMIT")
13396 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
13397 };
13398 let Err(error) = committed else {
13399 return Ok(());
13400 };
13401 if !self.conn.is_autocommit() {
13402 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13403 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13404 }
13405 }
13406 self.subs = open.subs;
13407 self.outbox = open.outbox;
13408 self.failed_commits = open.failed_commits;
13409 self.conflicts = open.conflicts;
13410 self.rejections = open.rejections;
13411 self.schema_floor = open.schema_floor;
13412 self.lease_state = open.lease_state;
13413 self.stopped = open.stopped;
13414 self.upgrading = open.upgrading;
13415 self.sync_needed = open.sync_needed;
13416 self.overlay_dirty.restore(open.overlay_dirty);
13417 self.change_queue.truncate(open.change_queue);
13418 self.sync_intent_queue.truncate(open.sync_intent_queue);
13419 self.last_change = open.last_change;
13420 self.coalesced_failure = Some(error.clone());
13421 Err(error)
13422 }
13423
13424 fn finish_coalesced_apply(&mut self) -> Result<(), String> {
13426 let committed = self.commit_coalesced_apply();
13427 match self.coalesced_failure.take() {
13428 Some(failure) => Err(failure),
13429 None => committed,
13430 }
13431 }
13432
13433 fn rollback_observation(&self, name: &str) {
13434 if let Err(error) = self
13435 .conn
13436 .execute_batch(&format!("ROLLBACK TO {name}; RELEASE {name}"))
13437 {
13438 if let Some(failure) = self.storage_failure.borrow_mut().as_mut() {
13439 if failure.rollback_failure.is_none() {
13440 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(error)));
13441 }
13442 }
13443 }
13444 }
13445
13446 fn finish_observation(
13447 &mut self,
13448 name: &str,
13449 mut batch: ChangeAccumulator,
13450 ) -> Result<(), String> {
13451 #[cfg(feature = "bench-internals")]
13452 let _phase = self.benchmark_phases.start(Phase::ObservationCommit);
13453 if self.failed_commits.iter().any(|(commit, _)| {
13454 commit
13455 .ops
13456 .iter()
13457 .any(|operation| batch.tables.contains_key(&operation.table))
13458 }) {
13459 batch.outcomes = true;
13460 }
13461 if !batch.touched() {
13462 self.conn
13463 .execute_batch(&format!("RELEASE {name}"))
13464 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13465 return Ok(());
13466 }
13467 let revision = self
13468 .local_revision()
13469 .checked_add(1)
13470 .ok_or_else(|| "local revision exhausted u64".to_owned())?;
13471 self.conn
13472 .execute(
13473 "INSERT OR REPLACE INTO _syncular_meta(key, value) VALUES (?1, ?2)",
13474 rusqlite::params![LOCAL_REVISION_KEY, revision.to_string()],
13475 )
13476 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13477 let status = batch.status.then(|| self.status_snapshot());
13478 let event = ClientChangeBatch {
13479 revision: revision.to_string(),
13480 tables: batch
13481 .tables
13482 .into_iter()
13483 .map(|(table, scope_keys)| TableChange {
13484 table,
13485 scope_keys: scope_keys.map(|keys| keys.into_iter().collect()),
13486 })
13487 .collect(),
13488 windows: batch
13489 .windows
13490 .into_iter()
13491 .map(|((base_key, table), units)| WindowChange {
13492 base_key,
13493 table,
13494 units: units.into_iter().collect(),
13495 })
13496 .collect(),
13497 status,
13498 conflicts_changed: batch.conflicts,
13499 rejections_changed: batch.rejections,
13500 outcomes_changed: batch.outcomes,
13501 };
13502 self.conn
13503 .execute_batch(&format!("RELEASE {name}"))
13504 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13505 let mut diagnostic_tables = event
13506 .tables
13507 .iter()
13508 .map(|entry| entry.table.clone())
13509 .collect::<BTreeSet<_>>()
13510 .into_iter()
13511 .collect::<Vec<_>>();
13512 let mut diagnostic_windows = event
13513 .windows
13514 .iter()
13515 .map(|entry| entry.table.clone())
13516 .collect::<BTreeSet<_>>()
13517 .into_iter()
13518 .collect::<Vec<_>>();
13519 let domains_truncated = diagnostic_tables.len() > MAX_DIAGNOSTIC_DOMAINS
13520 || diagnostic_windows.len() > MAX_DIAGNOSTIC_DOMAINS;
13521 diagnostic_tables.truncate(MAX_DIAGNOSTIC_DOMAINS);
13522 diagnostic_windows.truncate(MAX_DIAGNOSTIC_DOMAINS);
13523 self.last_change = Some(DiagnosticLastChange {
13524 revision: event.revision.clone(),
13525 recorded_at_ms: self.clock_now_ms(),
13526 tables: diagnostic_tables,
13527 windows: diagnostic_windows,
13528 domains_truncated,
13529 status_changed: event.status.is_some(),
13530 conflicts_changed: event.conflicts_changed,
13531 rejections_changed: event.rejections_changed,
13532 outcomes_changed: event.outcomes_changed,
13533 });
13534 self.change_queue.push_back(event);
13535 Ok(())
13536 }
13537
13538 fn record_scope_map(
13539 &self,
13540 batch: &mut ChangeAccumulator,
13541 table_name: &str,
13542 scopes: &[(String, Vec<String>)],
13543 ) {
13544 let Some(table) = self.schema.table(table_name) else {
13545 return;
13546 };
13547 for (variable, values) in scopes {
13548 let Some(scope) = table
13549 .scope_variables
13550 .iter()
13551 .find(|scope| &scope.variable == variable)
13552 else {
13553 continue;
13554 };
13555 for value in values {
13556 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13557 }
13558 }
13559 }
13560
13561 fn record_row_scopes(
13563 &self,
13564 batch: &mut ChangeAccumulator,
13565 table_name: &str,
13566 row_id: &str,
13567 base: bool,
13568 ) -> bool {
13569 let Some(table) = self.schema.table(table_name) else {
13570 return false;
13571 };
13572 if table.scope_variables.is_empty() {
13573 return false;
13574 }
13575 let columns = table
13576 .scope_variables
13577 .iter()
13578 .map(|scope| quote_ident(&scope.column))
13579 .collect::<Vec<_>>()
13580 .join(", ");
13581 let full_table = if base {
13582 base_table(table_name)
13583 } else {
13584 visible_table(table_name)
13585 };
13586 let sql = format!(
13587 "SELECT {columns} FROM {full_table} WHERE {} LIMIT 1",
13588 row_id_predicate(table)
13589 );
13590 let Ok(mut stmt) = self.conn.prepare_cached(&sql) else {
13591 return false;
13592 };
13593 let values = stmt.query_row(rusqlite::params![row_id], |row| {
13594 let mut values = Vec::with_capacity(table.scope_variables.len());
13595 for index in 0..table.scope_variables.len() {
13596 values.push(row.get::<_, Option<String>>(index)?);
13597 }
13598 Ok(values)
13599 });
13600 let Ok(values) = values else {
13601 return false;
13602 };
13603 let mut recorded = false;
13604 for (scope, value) in table.scope_variables.iter().zip(values) {
13605 if let Some(value) = value {
13606 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13607 recorded = true;
13608 }
13609 }
13610 recorded
13611 }
13612
13613 fn record_commit_changes(
13614 &self,
13615 batch: &mut ChangeAccumulator,
13616 tables: &[String],
13617 changes: &[ssp2::model::Change],
13618 ) {
13619 #[cfg(feature = "bench-internals")]
13620 let _phase = self.benchmark_phases.start(Phase::ObservationPrepare);
13621 for change in changes {
13622 let Some(table_name) = tables.get(change.table_index as usize) else {
13623 continue;
13624 };
13625 let mut precise = self.record_row_scopes(batch, table_name, &change.row_id, true);
13626 if let Some(table) = self.schema.table(table_name) {
13627 for (variable, value) in &change.scopes {
13628 if let Some(scope) = table
13629 .scope_variables
13630 .iter()
13631 .find(|scope| &scope.variable == variable)
13632 {
13633 batch.scope(table_name, format!("{}:{value}", scope.prefix));
13634 precise = true;
13635 }
13636 }
13637 }
13638 if !precise {
13639 batch.table(table_name);
13640 }
13641 }
13642 }
13643
13644 fn scoped_rows_exist(&self, table_name: &str, effective: &[(String, Vec<String>)]) -> bool {
13645 if effective.is_empty() {
13646 return false;
13647 }
13648 let Some(table) = self.schema.table(table_name) else {
13649 return false;
13650 };
13651 let mut clauses = Vec::new();
13652 let mut params = Vec::new();
13653 for (variable, values) in effective {
13654 let Some(column) = table.scope_column(variable) else {
13655 return false;
13656 };
13657 if values.is_empty() {
13658 return false;
13659 }
13660 let placeholders = values
13661 .iter()
13662 .map(|value| {
13663 params.push(SqlValue::Text(value.clone()));
13664 "?"
13665 })
13666 .collect::<Vec<_>>()
13667 .join(", ");
13668 clauses.push(format!("{} IN ({placeholders})", quote_ident(column)));
13669 }
13670 let sql = format!(
13671 "SELECT 1 FROM {} WHERE {} LIMIT 1",
13672 base_table(table_name),
13673 clauses.join(" AND ")
13674 );
13675 self.conn
13676 .query_row(&sql, rusqlite::params_from_iter(params), |_| Ok(()))
13677 .is_ok()
13678 }
13679
13680 pub fn upgrading(&self) -> bool {
13682 self.upgrading
13683 }
13684
13685 pub fn recreate_with_schema(&mut self, schema_json: &Value) -> Result<(), String> {
13693 let new_schema = parse_schema_json(schema_json)?;
13694 validate_authority_reads(&new_schema, &self.limits.authority_reads)?;
13695 self.conn
13696 .execute_batch("BEGIN IMMEDIATE")
13697 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13698 let prior_schema = std::mem::replace(&mut self.schema, new_schema);
13699 let prior_subs = self.subs.clone();
13705 let prior_outbox = self.outbox.clone();
13706 let prior_rejections = self.rejections.clone();
13707 let prior_upgrading = self.upgrading;
13708 let prior_stopped = self.stopped;
13709 let prior_schema_floor = self.schema_floor.clone();
13710 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13711 let prior_sync_needed = self.sync_needed;
13712 let prior_active_round = self.active_round;
13713 let prior_changes = self.change_queue.len();
13714 let prior_intents = self.sync_intent_queue.len();
13715 let prior_last_change = self.last_change.clone();
13716 let guarded = (|| -> Result<(), String> {
13717 let marker = read_local_schema_version(&self.conn, self.schema.version)?;
13722 self.create_bookkeeping_tables()?;
13723 match marker {
13724 Some(version) if version < self.schema.version => self.run_schema_reset()?,
13725 None => {
13726 self.create_synced_tables()?;
13727 self.persist_schema_version()?;
13728 }
13729 _ => {}
13730 }
13731 self.outbox = self.load_outbox()?;
13736 self.drop_unencodable_outbox(false, None)?;
13741 if marker == Some(self.schema.version)
13742 && (!self.outbox.is_empty()
13743 || !self.failed_commits.is_empty()
13744 || self.has_acknowledged_rows())
13745 {
13746 self.overlay_dirty.set(true);
13747 self.rebuild_overlay()?;
13748 }
13749 self.prune_unknown_subscriptions(false)?;
13750 self.save_subscription_scope_schema();
13751 self.enqueue_startup_sync_if_needed()?;
13755 Ok(())
13756 })();
13757 let outcome = match guarded {
13758 Ok(()) => self
13759 .conn
13760 .execute_batch("COMMIT")
13761 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error)),
13762 Err(error) => Err(error),
13763 };
13764 if let Err(error) = outcome {
13765 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
13766 let _ = Self::sqlite_failure(&self.storage_failure, rollback);
13768 }
13769 self.schema = prior_schema;
13770 self.subs = prior_subs;
13771 self.outbox = prior_outbox;
13772 self.rejections = prior_rejections;
13773 self.upgrading = prior_upgrading;
13774 self.stopped = prior_stopped;
13775 self.schema_floor = prior_schema_floor;
13776 self.overlay_dirty.restore(prior_overlay_dirty);
13777 self.sync_needed = prior_sync_needed;
13778 self.active_round = prior_active_round;
13779 self.change_queue.truncate(prior_changes);
13780 self.sync_intent_queue.truncate(prior_intents);
13781 self.last_change = prior_last_change;
13782 self.insert_sql.borrow_mut().clear();
13784 return Err(error);
13785 }
13786 Ok(())
13787 }
13788
13789 fn enqueue_startup_sync_if_needed(&mut self) -> Result<(), String> {
13790 let startup_work = !self.stopped
13791 && (!self.outbox.is_empty()
13792 || self.has_acknowledged_rows()
13793 || !self.load_pending_evictions()?.is_empty()
13794 || self.subs.iter().any(|sub| sub.state == SubState::Active));
13795 if startup_work {
13796 self.sync_needed = true;
13797 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13798 }
13799 Ok(())
13800 }
13801
13802 fn clear_satisfied_persisted_schema_floor(&mut self) {
13809 let satisfied = self
13810 .schema_floor
13811 .as_ref()
13812 .and_then(|floor| floor.required_schema_version)
13813 .is_some_and(|required| self.schema.version >= required);
13814 if !satisfied {
13815 return;
13816 }
13817 self.schema_floor = None;
13818 self.stopped = false;
13819 self.delete_meta(SCHEMA_FLOOR_KEY);
13820 }
13821
13822 fn reconcile_previous_version_at_boot(&self) -> Result<(), String> {
13827 let Some(replica_path) = self.previous_version_replica_path() else {
13828 return Ok(());
13829 };
13830 let max_age_ms = self
13831 .previous_version
13832 .filter(|config| config.enabled)
13833 .map(|config| config.max_age_ms);
13834 reconcile_previous_version_at_boot(
13835 &self.conn,
13836 &replica_path,
13837 self.schema.version,
13838 max_age_ms,
13839 self.clock_now_ms(),
13840 )
13841 }
13842
13843 fn previous_version_replica_path(&self) -> Option<String> {
13847 let path = self.conn.path()?;
13848 if path.is_empty() || path.starts_with("file:") {
13849 return None;
13850 }
13851 Some(path.to_owned())
13852 }
13853
13854 fn run_schema_reset(&mut self) -> Result<(), String> {
13860 self.begin_observation("syncular_schema_reset")?;
13861 let prior_subs = self.subs.clone();
13862 let prior_outbox = self.outbox.clone();
13863 let prior_rejections = self.rejections.clone();
13864 let prior_upgrading = self.upgrading;
13865 let prior_stopped = self.stopped;
13866 let prior_schema_floor = self.schema_floor.clone();
13867 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13868 let prior_sync_needed = self.sync_needed;
13869 let prior_active_round = self.active_round;
13870 let mut batch = ChangeAccumulator::default();
13871 let result = self
13872 .run_schema_reset_observed(&mut batch, true)
13873 .and_then(|()| self.finish_observation("syncular_schema_reset", batch));
13874 if let Err(error) = result {
13875 self.rollback_observation("syncular_schema_reset");
13876 self.subs = prior_subs;
13877 self.outbox = prior_outbox;
13878 self.rejections = prior_rejections;
13879 self.upgrading = prior_upgrading;
13880 self.stopped = prior_stopped;
13881 self.schema_floor = prior_schema_floor;
13882 self.overlay_dirty.restore(prior_overlay_dirty);
13883 self.sync_needed = prior_sync_needed;
13884 self.active_round = prior_active_round;
13885 return Err(error);
13886 }
13887 Ok(())
13888 }
13889
13890 fn run_log_epoch_reset(&mut self, log_epoch: &str) -> Result<Vec<String>, String> {
13891 if self.get_meta(LOG_EPOCH_KEY).is_none() {
13892 self.begin_observation("syncular_epoch_acquisition")?;
13893 let previous_sync_needed = self.sync_needed;
13894 if let Err(error) = self.conn.execute(
13895 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13896 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13897 ) {
13898 self.rollback_observation("syncular_epoch_acquisition");
13899 return Err(Self::sqlite_failure(&self.storage_failure, error));
13900 }
13901 self.sync_needed = true;
13902 let batch = ChangeAccumulator {
13903 status: true,
13904 ..ChangeAccumulator::default()
13905 };
13906 if let Err(error) = self.finish_observation("syncular_epoch_acquisition", batch) {
13907 self.rollback_observation("syncular_epoch_acquisition");
13908 self.sync_needed = previous_sync_needed;
13909 return Err(error);
13910 }
13911 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13912 return Ok(Vec::new());
13913 }
13914 let resets = self.subs.iter().map(|sub| sub.id.clone()).collect();
13915 self.begin_observation("syncular_log_epoch_reset")?;
13916 let prior_subs = self.subs.clone();
13917 let prior_outbox = self.outbox.clone();
13918 let prior_rejections = self.rejections.clone();
13919 let prior_upgrading = self.upgrading;
13920 let prior_stopped = self.stopped;
13921 let prior_schema_floor = self.schema_floor.clone();
13922 let prior_overlay_dirty = self.overlay_dirty.snapshot();
13923 let prior_sync_needed = self.sync_needed;
13924 let prior_active_round = self.active_round;
13925 let mut batch = ChangeAccumulator::default();
13926 let result = self
13927 .run_schema_reset_observed(&mut batch, false)
13928 .and_then(|()| {
13929 self.conn
13930 .execute(
13931 "INSERT OR REPLACE INTO _syncular_meta(key,value) VALUES (?1,?2)",
13932 rusqlite::params![LOG_EPOCH_KEY, log_epoch],
13933 )
13934 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13935 self.sync_needed = true;
13936 batch.status = true;
13937 self.finish_observation("syncular_log_epoch_reset", batch)
13938 });
13939 if let Err(error) = result {
13940 self.rollback_observation("syncular_log_epoch_reset");
13941 self.subs = prior_subs;
13942 self.outbox = prior_outbox;
13943 self.rejections = prior_rejections;
13944 self.upgrading = prior_upgrading;
13945 self.stopped = prior_stopped;
13946 self.schema_floor = prior_schema_floor;
13947 self.overlay_dirty.restore(prior_overlay_dirty);
13948 self.sync_needed = prior_sync_needed;
13949 self.active_round = prior_active_round;
13950 return Err(error);
13951 }
13952 self.sync_intent_queue.push_back(SyncIntent::Interactive);
13953 Ok(resets)
13954 }
13955
13956 fn run_schema_reset_observed(
13957 &mut self,
13958 batch: &mut ChangeAccumulator,
13959 capture: bool,
13960 ) -> Result<(), String> {
13961 read_local_schema_version(&self.conn, self.schema.version)?;
13967 self.conn
13968 .execute_batch(
13969 "DELETE FROM _syncular_acked_rows; DELETE FROM _syncular_row_deliveries;",
13970 )
13971 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
13972 self.cancel_sync_round();
13973 self.upgrading = true;
13974 batch.status = true;
13975 if self.previous_version.is_some_and(|config| config.enabled) {
13983 let previous_version = self
13984 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
13985 .and_then(|value| value.parse::<i32>().ok())
13986 .unwrap_or(self.schema.version);
13987 let pending = self
13988 .outbox
13989 .iter()
13990 .map(|commit| PendingCommitAudit {
13991 commit_id: &commit.client_commit_id,
13992 operations: commit_audit_operations(commit),
13993 })
13994 .collect::<Vec<_>>();
13995 crate::previous_version::write_previous_version_audit(
13996 &self.conn,
13997 &crate::previous_version::build_previous_version_audit(
13998 &self.schema,
13999 &pending,
14000 previous_version,
14001 self.schema.version,
14002 self.clock_now_ms(),
14003 ),
14004 );
14005 }
14006 if let Some(replica_path) = self.previous_version_replica_path() {
14011 sweep_previous_version_container(&self.conn, &replica_path)?;
14012 if capture {
14013 if let Some(config) = self.previous_version.filter(|config| config.enabled) {
14014 let previous_version = self
14015 .get_meta(LOCAL_SCHEMA_VERSION_KEY)
14016 .and_then(|value| value.parse::<i32>().ok())
14017 .unwrap_or(self.schema.version);
14018 capture_previous_version_from_replica(
14019 &self.conn,
14020 &replica_path,
14021 previous_version,
14022 self.schema.version,
14023 &config,
14024 self.clock_now_ms(),
14025 )?;
14026 }
14027 }
14028 }
14029 for table in &self.schema.tables {
14030 batch.table(&table.name);
14031 }
14032 for (base_key, unit, table) in self.load_registered_window_units() {
14033 batch.window(&base_key, &table, &unit);
14034 }
14035 if capture {
14036 self.prune_unknown_subscriptions(true)?;
14037 }
14038 self.insert_sql.borrow_mut().clear();
14040 self.overlay_dirty.set(true);
14041 let virtual_tables: Vec<String> = {
14049 let mut stmt = self
14050 .conn
14051 .prepare(
14052 "SELECT name FROM sqlite_master WHERE type = 'table' AND sql LIKE 'CREATE VIRTUAL TABLE%'",
14053 )
14054 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14055 let rows = stmt
14056 .query_map([], |row| row.get::<_, String>(0))
14057 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14058 rows.filter_map(Result::ok)
14059 .filter(|name| is_synced_table_name(name))
14060 .collect()
14061 };
14062 for name in virtual_tables {
14063 self.conn
14064 .execute_batch(&format!(
14065 "DROP TABLE IF EXISTS {}",
14066 quote_ident(&format!("_syncular_fts_{name}"))
14067 ))
14068 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14069 self.conn
14070 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14071 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14072 }
14073 let existing: Vec<String> = {
14074 let mut stmt = self
14075 .conn
14076 .prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
14077 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14078 let rows = stmt
14079 .query_map([], |row| row.get::<_, String>(0))
14080 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14081 rows.filter_map(Result::ok)
14082 .filter(|name| is_synced_table_name(name))
14083 .collect()
14084 };
14085 for name in &existing {
14086 if let Some(table) = name.strip_prefix("_syncular_base_") {
14087 batch.table(table);
14088 } else if !name.starts_with("_syncular_") {
14089 batch.table(name);
14090 }
14091 }
14092 for name in existing {
14093 self.conn
14094 .execute(&format!("DROP TABLE IF EXISTS {}", quote_ident(&name)), [])
14095 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14096 }
14097 self.create_synced_tables()?;
14099 for sub in &mut self.subs {
14101 sub.cursor = -1;
14102 sub.bootstrap_state = None;
14103 sub.effective = None;
14104 sub.state = SubState::Active;
14105 sub.reason_code = None;
14106 sub.synced_once = false;
14107 }
14108 let subs = self.subs.clone();
14109 for sub in &subs {
14110 self.persist_sub(sub)?;
14111 }
14112 self.stopped = false;
14114 self.schema_floor = None;
14115 self.delete_meta(SCHEMA_FLOOR_KEY);
14116 self.persist_schema_version()?;
14118 self.save_subscription_scope_schema();
14119 self.drop_unencodable_outbox(false, Some(batch))?;
14130 self.rebuild_overlay()?;
14132 Ok(())
14133 }
14134
14135 fn drop_incompatible_outbox(&mut self) -> Result<bool, String> {
14144 let incompatible = self
14145 .outbox
14146 .iter()
14147 .filter(|commit| {
14148 first_incompatibility(&self.schema, &commit_audit_operations(commit)).is_some()
14149 })
14150 .cloned()
14151 .collect::<Vec<_>>();
14152 if incompatible.is_empty() {
14153 return Ok(false);
14154 }
14155 let prior_outbox = self.outbox.clone();
14161 let prior_rejections = self.rejections.clone();
14162 let prior_overlay_dirty = self.overlay_dirty.snapshot();
14163 let owns_transaction = self.conn.is_autocommit();
14164 self.begin_observation("syncular_outbox_drop")?;
14165 let mut batch = ChangeAccumulator::default();
14166 let dropped = (|| -> Result<(), String> {
14167 let mut rejections = Vec::new();
14168 for commit in &incompatible {
14169 let results = commit
14170 .ops
14171 .iter()
14172 .enumerate()
14173 .map(|(op_index, operation)| {
14174 let rejection = RejectionRecord {
14175 client_commit_id: commit.client_commit_id.clone(),
14176 op_index: op_index as i32,
14177 code: OUTBOX_INCOMPATIBLE_CODE.to_owned(),
14178 message: "the persisted commit cannot encode under the current schema"
14179 .to_owned(),
14180 retryable: false,
14181 details: None,
14182 operation: Some(CommitOperation::from(operation)),
14183 };
14184 rejections.push(rejection.clone());
14185 CommitOperationOutcome::Error { rejection }
14186 })
14187 .collect::<Vec<_>>();
14188 self.persist_commit_outcome(
14189 &commit.client_commit_id,
14190 CommitOutcomeStatus::Rejected,
14191 &results,
14192 Some(&commit.ops),
14193 )?;
14194 self.delete_outbox_persisted(&commit.client_commit_id)?;
14195 for operation in &commit.ops {
14198 if self.schema.table(&operation.table).is_some() {
14199 batch.table(&operation.table);
14200 }
14201 }
14202 }
14203 self.prune_commit_outcomes()?;
14204 let incompatible_ids = incompatible
14205 .iter()
14206 .map(|commit| commit.client_commit_id.as_str())
14207 .collect::<BTreeSet<_>>();
14208 self.outbox
14209 .retain(|commit| !incompatible_ids.contains(commit.client_commit_id.as_str()));
14210 self.rejections.extend(rejections);
14211 self.overlay_dirty.set(true);
14214 self.rebuild_overlay()?;
14215 batch.status = true;
14216 batch.rejections = true;
14217 batch.outcomes = true;
14218 Ok(())
14219 })();
14220 let result = dropped.and_then(|()| self.finish_observation("syncular_outbox_drop", batch));
14221 if let Err(error) = result {
14222 if owns_transaction {
14226 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
14227 let mut failure = self.storage_failure.borrow_mut();
14228 let failure =
14229 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
14230 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
14231 }
14232 } else {
14233 self.rollback_observation("syncular_outbox_drop");
14234 }
14235 self.outbox = prior_outbox;
14236 self.rejections = prior_rejections;
14237 self.overlay_dirty.restore(prior_overlay_dirty);
14238 return Err(error);
14239 }
14240 Ok(true)
14241 }
14242
14243 fn create_synced_tables(&self) -> Result<(), String> {
14246 for table in &self.schema.tables {
14247 for (full, index_prefix) in [
14251 (base_table(&table.name), "_syncular_base_"),
14252 (visible_table(&table.name), ""),
14253 ] {
14254 let mut cols: Vec<String> =
14255 table.columns.iter().map(|c| quote_ident(&c.name)).collect();
14256 cols.push("\"_syncular_version\" INTEGER NOT NULL".to_owned());
14257 let sql = format!(
14258 "CREATE TABLE IF NOT EXISTS {full} ({} , PRIMARY KEY ({}))",
14259 cols.join(", "),
14260 quote_ident(&table.primary_key)
14261 );
14262 self.conn
14263 .execute(&sql, [])
14264 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14265 for index in &table.indexes {
14269 let unique = if index.unique { "UNIQUE " } else { "" };
14270 let index_name = quote_ident(&format!("{index_prefix}{}", index.name));
14271 let cols_sql = index
14272 .columns
14273 .iter()
14274 .map(|c| quote_ident(c))
14275 .collect::<Vec<_>>()
14276 .join(", ");
14277 let index_sql = format!(
14278 "CREATE {unique}INDEX IF NOT EXISTS {index_name} ON {full} ({cols_sql})"
14279 );
14280 self.conn
14281 .execute(&index_sql, [])
14282 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14283 }
14284 }
14285 }
14286 for table in &self.schema.tables {
14289 for index in &table.fts_indexes {
14290 self.create_fts_projection(table, index)?;
14291 }
14292 }
14293 Ok(())
14294 }
14295
14296 fn fts_projection_exists(&self, index: &FtsIndexSchema) -> Result<bool, String> {
14297 let count: i64 = self
14298 .conn
14299 .query_row(
14300 "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?1",
14301 rusqlite::params![index.name],
14302 |row| row.get(0),
14303 )
14304 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14305 Ok(count > 0)
14306 }
14307
14308 fn drop_fts_triggers(&self, index: &FtsIndexSchema) -> Result<(), String> {
14309 for suffix in ["ai", "ad", "au"] {
14310 self.conn
14311 .execute(
14312 &format!(
14313 "DROP TRIGGER IF EXISTS {}",
14314 quote_ident(&format!("{}_{suffix}", index.name))
14315 ),
14316 [],
14317 )
14318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14319 }
14320 Ok(())
14321 }
14322
14323 fn create_fts_triggers(
14324 &self,
14325 table: &TableSchema,
14326 index: &FtsIndexSchema,
14327 ) -> Result<(), String> {
14328 let fts = quote_ident(&index.name);
14329 let source = visible_table(&table.name);
14330 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14331 let pk = quote_ident(&table.primary_key);
14332 let projection_columns = std::iter::once(source_id.clone())
14333 .chain(index.columns.iter().map(|column| quote_ident(column)))
14334 .collect::<Vec<_>>()
14335 .join(", ");
14336 let new_values = std::iter::once(format!("CAST(new.{pk} AS TEXT)"))
14337 .chain(
14338 index
14339 .columns
14340 .iter()
14341 .map(|column| format!("new.{}", quote_ident(column))),
14342 )
14343 .collect::<Vec<_>>()
14344 .join(", ");
14345 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14346 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)");
14347 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)");
14348 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})");
14349 let bi = quote_ident(&format!("{}_bi", index.name));
14350 let ai = quote_ident(&format!("{}_ai", index.name));
14351 let ad = quote_ident(&format!("{}_ad", index.name));
14352 let au = quote_ident(&format!("{}_au", index.name));
14353 let replacement_exists = format!("EXISTS (SELECT 1 FROM {source} WHERE {pk} = new.{pk})");
14354 let sql = format!(
14355 "DROP TRIGGER IF EXISTS {bi};
14356 DROP TRIGGER IF EXISTS {ai};
14357 DROP TRIGGER IF EXISTS {ad};
14358 DROP TRIGGER IF EXISTS {au};
14359 CREATE TRIGGER {bi} BEFORE INSERT ON {source} WHEN {replacement_exists} BEGIN {delete_new}; END;
14360 CREATE TRIGGER {ai} AFTER INSERT ON {source} BEGIN {insert_new}; END;
14361 CREATE TRIGGER {ad} AFTER DELETE ON {source} BEGIN {delete_old}; END;
14362 CREATE TRIGGER {au} AFTER UPDATE ON {source} BEGIN {delete_old}; {delete_new}; {insert_new}; END;",
14363 );
14364 self.conn
14365 .execute_batch(&sql)
14366 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14367 }
14368
14369 fn rebuild_fts_projection(
14370 &self,
14371 table: &TableSchema,
14372 index: &FtsIndexSchema,
14373 ) -> Result<(), String> {
14374 let fts = quote_ident(&index.name);
14375 let source_id = quote_ident(FTS_SOURCE_ID_COLUMN);
14376 let pk = quote_ident(&table.primary_key);
14377 let indexed_columns = index
14378 .columns
14379 .iter()
14380 .map(|column| quote_ident(column))
14381 .collect::<Vec<_>>();
14382 let projection_columns = std::iter::once(source_id.clone())
14383 .chain(indexed_columns.iter().cloned())
14384 .collect::<Vec<_>>()
14385 .join(", ");
14386 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14387 let sql = format!(
14388 "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};",
14389 columns = indexed_columns.join(", "),
14390 source = visible_table(&table.name),
14391 );
14392 self.conn
14393 .execute_batch(&sql)
14394 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14395 }
14396
14397 fn create_fts_projection(
14398 &self,
14399 table: &TableSchema,
14400 index: &FtsIndexSchema,
14401 ) -> Result<(), String> {
14402 self.begin_observation("syncular_fts_schema")?;
14403 let result = (|| {
14404 let existed = self.fts_projection_exists(index)?;
14405 let tokenizer = index.tokenize.replace('\'', "''");
14406 let columns = index
14407 .columns
14408 .iter()
14409 .map(|column| quote_ident(column))
14410 .collect::<Vec<_>>()
14411 .join(", ");
14412 let sql = format!(
14413 "CREATE VIRTUAL TABLE IF NOT EXISTS {fts} USING fts5({source_id} UNINDEXED, {columns}, tokenize='{tokenizer}')",
14414 fts = quote_ident(&index.name),
14415 source_id = quote_ident(FTS_SOURCE_ID_COLUMN),
14416 );
14417 self.conn.execute(&sql, []).map_err(|error| {
14418 format!(
14419 "cannot create local FTS5 projection {:?}: {error}",
14420 index.name
14421 )
14422 })?;
14423 let mapping = quote_ident(&format!("_syncular_fts_{}", index.name));
14424 let mapped: bool = self
14425 .conn
14426 .query_row(
14427 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name=?1)",
14428 [format!("_syncular_fts_{}", index.name)],
14429 |row| row.get(0),
14430 )
14431 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14432 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))?;
14433 if !mapped && existed {
14434 self.conn
14435 .execute_batch(&format!(
14436 "INSERT INTO {mapping}(id, source_id) SELECT rowid, {} FROM {}",
14437 quote_ident(FTS_SOURCE_ID_COLUMN),
14438 quote_ident(&index.name)
14439 ))
14440 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14441 }
14442 self.create_fts_triggers(table, index)?;
14443 if !existed {
14444 self.rebuild_fts_projection(table, index)?;
14445 }
14446 Ok(())
14447 })();
14448 if let Err(error) = result {
14449 self.rollback_observation("syncular_fts_schema");
14450 return Err(error);
14451 }
14452 if let Err(error) = self.conn.execute_batch("RELEASE syncular_fts_schema") {
14453 self.rollback_observation("syncular_fts_schema");
14454 return Err(error.to_string());
14455 }
14456 Ok(())
14457 }
14458
14459 fn persist_sub(&self, sub: &Subscription) -> Result<(), String> {
14462 let state = serde_json::json!({
14463 "requested": scope_map_to_json(&sub.requested),
14464 "params": sub.params,
14465 "cursor": sub.cursor,
14466 "bootstrapState": sub.bootstrap_state,
14467 "status": sub.state.name(),
14468 "reasonCode": sub.reason_code,
14469 "effectiveScopes": sub.effective.as_ref().map(|e| scope_map_to_json(e)),
14470 "syncedOnce": sub.synced_once,
14471 });
14472 self.conn.execute(
14473 "INSERT OR REPLACE INTO _syncular_subscriptions (id, tbl, state_json) VALUES (?1, ?2, ?3)",
14474 rusqlite::params![sub.id, sub.table, state.to_string()],
14475 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14476 }
14477
14478 fn persist_outbox_insert(&self, commit: &OutboxCommit) -> Result<(), String> {
14479 let ops: Vec<Value> = commit
14480 .ops
14481 .iter()
14482 .map(|op| {
14483 let mut entry = serde_json::json!({
14484 "op": if op.upsert { "upsert" } else { "delete" },
14485 "table": op.table,
14486 "rowId": op.row_id,
14487 "baseVersion": op.base_version,
14488 "values": op.values.clone().map(Value::Object),
14489 });
14490 if op.insert {
14491 entry["insert"] = Value::Bool(true);
14492 }
14493 entry
14494 })
14495 .collect();
14496 self.conn
14497 .execute(
14498 "INSERT OR REPLACE INTO _syncular_outbox (commit_id, ops_json) VALUES (?1, ?2)",
14499 rusqlite::params![commit.client_commit_id, Value::Array(ops).to_string()],
14500 )
14501 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14502 for operation in &commit.ops {
14503 let Some(values) = &operation.values else {
14504 continue;
14505 };
14506 let Some(table) = self.schema.table(&operation.table) else {
14507 continue;
14508 };
14509 for column in table
14510 .columns
14511 .iter()
14512 .filter(|column| column.ty == ColumnType::BlobRef)
14513 {
14514 let Some(Value::String(raw)) = values.get(&column.name) else {
14515 continue;
14516 };
14517 let Ok(blob_ref) = serde_json::from_str::<Value>(raw) else {
14518 continue;
14519 };
14520 let Some(blob_id) = blob_ref.get("blobId").and_then(Value::as_str) else {
14521 continue;
14522 };
14523 self.conn
14524 .execute(
14525 "INSERT OR IGNORE INTO _syncular_blob_commit_refs(commit_id, blob_id)
14526 SELECT ?1, ?2 WHERE EXISTS (SELECT 1 FROM _syncular_blobs WHERE blob_id = ?2)",
14527 rusqlite::params![commit.client_commit_id, blob_id],
14528 )
14529 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14530 }
14531 }
14532 Ok(())
14533 }
14534
14535 fn delete_outbox_persisted(&self, client_commit_id: &str) -> Result<(), String> {
14536 if let Some(commit) = self
14537 .outbox
14538 .iter()
14539 .find(|commit| commit.client_commit_id == client_commit_id)
14540 {
14541 for operation in &commit.ops {
14542 self.overlay_dirty.table(&operation.table);
14543 }
14544 }
14545
14546 self.conn
14547 .execute(
14548 "DELETE FROM _syncular_blob_commit_refs WHERE commit_id = ?1",
14549 rusqlite::params![client_commit_id],
14550 )
14551 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14552 self.conn
14553 .execute(
14554 "DELETE FROM _syncular_outbox WHERE commit_id = ?1",
14555 rusqlite::params![client_commit_id],
14556 )
14557 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14558 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)", [])
14559 .map(|_| ())
14560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14561 }
14562
14563 fn outcome_status_name(status: CommitOutcomeStatus) -> &'static str {
14564 match status {
14565 CommitOutcomeStatus::Applied => "applied",
14566 CommitOutcomeStatus::Cached => "cached",
14567 CommitOutcomeStatus::Conflict => "conflict",
14568 CommitOutcomeStatus::Rejected => "rejected",
14569 }
14570 }
14571
14572 fn outcome_resolution_name(resolution: CommitOutcomeResolution) -> &'static str {
14573 match resolution {
14574 CommitOutcomeResolution::Active => "active",
14575 CommitOutcomeResolution::ResolvedKeepServer => "resolved_keep_server",
14576 CommitOutcomeResolution::Superseded => "superseded",
14577 CommitOutcomeResolution::Dismissed => "dismissed",
14578 }
14579 }
14580
14581 fn parse_outcome_status(value: &str) -> Result<CommitOutcomeStatus, String> {
14582 match value {
14583 "applied" => Ok(CommitOutcomeStatus::Applied),
14584 "cached" => Ok(CommitOutcomeStatus::Cached),
14585 "conflict" => Ok(CommitOutcomeStatus::Conflict),
14586 "rejected" => Ok(CommitOutcomeStatus::Rejected),
14587 _ => Err(format!("invalid persisted commit outcome status {value:?}")),
14588 }
14589 }
14590
14591 fn parse_outcome_resolution(value: &str) -> Result<CommitOutcomeResolution, String> {
14592 match value {
14593 "active" => Ok(CommitOutcomeResolution::Active),
14594 "resolved_keep_server" => Ok(CommitOutcomeResolution::ResolvedKeepServer),
14595 "superseded" => Ok(CommitOutcomeResolution::Superseded),
14596 "dismissed" => Ok(CommitOutcomeResolution::Dismissed),
14597 _ => Err(format!(
14598 "invalid persisted commit outcome resolution {value:?}"
14599 )),
14600 }
14601 }
14602
14603 fn persist_commit_outcome(
14604 &self,
14605 client_commit_id: &str,
14606 status: CommitOutcomeStatus,
14607 results: &[CommitOperationOutcome],
14608 operations: Option<&[OutboxOp]>,
14609 ) -> Result<(), String> {
14610 let results_json = serde_json::to_string(results).map_err(|error| error.to_string())?;
14611 let operations_json = operations
14612 .map(|items| {
14613 serde_json::to_string(&items.iter().map(CommitOperation::from).collect::<Vec<_>>())
14614 })
14615 .transpose()
14616 .map_err(|error| error.to_string())?;
14617 self.conn
14618 .execute(
14619 "INSERT INTO _syncular_commit_outcomes (
14620 client_commit_id, status, recorded_at_ms, results_json,
14621 operations_json, resolution
14622 ) VALUES (?1, ?2, ?3, ?4, ?5, 'active')",
14623 rusqlite::params![
14624 client_commit_id,
14625 Self::outcome_status_name(status),
14626 self.clock_now_ms(),
14627 results_json,
14628 operations_json
14629 ],
14630 )
14631 .map(|_| ())
14632 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
14633 }
14634
14635 fn stored_cell_is_representable(cell: &Value) -> bool {
14638 cell.is_null()
14639 || cell.is_string()
14640 || cell.is_boolean()
14641 || cell.is_number()
14642 || is_canonical_bytes_envelope(cell)
14643 }
14644
14645 fn stored_row_is_representable(value: &Value) -> bool {
14646 value
14647 .as_object()
14648 .is_some_and(|row| row.values().all(Self::stored_cell_is_representable))
14649 }
14650
14651 fn stored_operation_is_representable(value: &Value) -> bool {
14652 value.get("table").is_some_and(Value::is_string)
14653 && value.get("rowId").is_some_and(Value::is_string)
14654 && value.get("baseVersion").is_none_or(|v| {
14655 v.as_i64()
14656 .is_some_and(|n| n.unsigned_abs() <= MAX_JS_SAFE_INTEGER)
14657 })
14658 && match value.get("op").and_then(Value::as_str) {
14659 Some("upsert") => {
14660 value
14661 .get("values")
14662 .is_some_and(Self::stored_row_is_representable)
14663 && value
14664 .get("insert")
14665 .is_none_or(|insert| *insert == Value::Bool(true))
14666 }
14667 Some("delete") => value.get("values").is_none() && value.get("insert").is_none(),
14668 _ => false,
14669 }
14670 }
14671
14672 fn outbox_op_is_journalable(operation: &OutboxOp) -> bool {
14676 serde_json::to_value(CommitOperation::from(operation))
14677 .is_ok_and(|value| Self::stored_operation_is_representable(&value))
14678 }
14679
14680 fn outcome_from_row(row: StoredCommitOutcomeRow) -> Result<CommitOutcome, String> {
14681 let StoredCommitOutcomeRow {
14682 sequence,
14683 client_commit_id,
14684 status,
14685 recorded_at_ms,
14686 results_json,
14687 operations_json,
14688 resolution,
14689 resolved_at_ms,
14690 replacement_client_commit_id,
14691 } = row;
14692 if sequence.unsigned_abs() > MAX_JS_SAFE_INTEGER
14693 || recorded_at_ms.unsigned_abs() > MAX_JS_SAFE_INTEGER
14694 || resolved_at_ms.is_some_and(|value| value.unsigned_abs() > MAX_JS_SAFE_INTEGER)
14695 {
14696 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned());
14697 }
14698 let invalid = || "sync.local_corrupt: persisted commit outcome is invalid".to_owned();
14699 let stored: Vec<Value> = serde_json::from_str(&results_json).map_err(|_| invalid())?;
14700 for result in &stored {
14701 let nested = match result.get("status").and_then(Value::as_str) {
14702 Some("conflict") => result.get("conflict"),
14703 Some("error") => result.get("rejection"),
14704 _ => None,
14705 };
14706 if nested
14707 .and_then(|record| record.get("operation"))
14708 .is_some_and(|op| !Self::stored_operation_is_representable(op))
14709 || (result.get("status").and_then(Value::as_str) == Some("conflict")
14710 && nested
14711 .and_then(|record| record.get("serverRow"))
14712 .is_some_and(|row| !Self::stored_row_is_representable(row)))
14713 {
14714 return Err(invalid());
14715 }
14716 }
14717 let results: Vec<CommitOperationOutcome> =
14718 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())?;
14719 let has_conflict = results
14720 .iter()
14721 .any(|result| matches!(result, CommitOperationOutcome::Conflict { .. }));
14722 let has_error = results
14723 .iter()
14724 .any(|result| matches!(result, CommitOperationOutcome::Error { .. }));
14725 let status = Self::parse_outcome_status(&status).map_err(|_| invalid())?;
14726 let redacted = results.is_empty() && operations_json.is_none();
14727 if match status {
14728 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => has_conflict || has_error,
14729 CommitOutcomeStatus::Conflict => !(has_conflict || redacted),
14730 CommitOutcomeStatus::Rejected => has_conflict || !(has_error || redacted),
14731 } {
14732 return Err(invalid());
14733 }
14734 let operations: Option<Vec<CommitOperation>> = operations_json
14735 .map(|raw| {
14736 let stored: Vec<Value> = serde_json::from_str(&raw).map_err(|_| invalid())?;
14737 if !stored.iter().all(Self::stored_operation_is_representable) {
14738 return Err(invalid());
14739 }
14740 serde_json::from_value(Value::Array(stored)).map_err(|_| invalid())
14741 })
14742 .transpose()?;
14743 for result in &results {
14744 match result {
14745 CommitOperationOutcome::Conflict { conflict }
14746 if conflict.server_version.unsigned_abs() > MAX_JS_SAFE_INTEGER =>
14747 {
14748 return Err("sync.local_corrupt: persisted commit outcome is invalid".to_owned())
14749 }
14750 CommitOperationOutcome::Error { rejection } => {
14751 if let Some(details) = &rejection.details {
14752 let encoded = serde_json::to_string(details).map_err(|_| {
14753 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14754 })?;
14755 RejectionDetails::parse(&encoded).map_err(|_| {
14756 "sync.local_corrupt: persisted commit outcome is invalid".to_owned()
14757 })?;
14758 }
14759 }
14760 _ => {}
14761 }
14762 }
14763 Ok(CommitOutcome {
14764 sequence,
14765 client_commit_id,
14766 status,
14767 recorded_at_ms,
14768 results,
14769 operations,
14770 retained_rows: None,
14771 resolution: Self::parse_outcome_resolution(&resolution)?,
14772 resolved_at_ms,
14773 replacement_client_commit_id,
14774 })
14775 }
14776
14777 fn unique_conflicts(
14779 &self,
14780 table: &TableSchema,
14781 values: &Map<String, Value>,
14782 base: bool,
14783 ) -> Result<Vec<RetainedUniqueConflict>, String> {
14784 let source = if base {
14785 base_table(&table.name)
14786 } else {
14787 visible_table(&table.name)
14788 };
14789 let mut conflicts = Vec::new();
14790 for index in table.indexes.iter().filter(|index| index.unique) {
14791 let mut params = vec![json_param_to_sql(
14792 values.get(&table.primary_key).ok_or("sync.local_corrupt")?,
14793 )?];
14794 for name in &index.columns {
14795 let column = table
14796 .columns
14797 .iter()
14798 .find(|column| column.name == *name)
14799 .ok_or("sync.local_corrupt")?;
14800 let cell = json_to_column_value(column, values.get(name))?;
14801 params.push(owned_sql_value(RowParam::Cell(&cell))?);
14802 }
14803 let sql = format!(
14804 "SELECT * FROM {source} WHERE {} != ? AND {}",
14805 quote_ident(&table.primary_key),
14806 index
14807 .columns
14808 .iter()
14809 .map(|column| format!("{} = ?", quote_ident(column)))
14810 .collect::<Vec<_>>()
14811 .join(" AND ")
14812 );
14813 let mut statement = self
14814 .conn
14815 .prepare_cached(&sql)
14816 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14817 let records = statement
14818 .query_map(rusqlite::params_from_iter(params), |record| {
14819 let values = table
14820 .columns
14821 .iter()
14822 .enumerate()
14823 .map(|(index, column)| {
14824 let value = sql_ref_to_json(column, record.get_ref(index)?);
14825 json_to_column_value(column, Some(&value)).map_err(|message| {
14826 rusqlite::Error::ToSqlConversionFailure(message.into())
14827 })
14828 })
14829 .collect::<Result<Row, _>>()?;
14830 Ok((values, record.get::<_, i64>(table.columns.len())?))
14831 })
14832 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14833 for record in records {
14834 let (row, version) =
14835 record.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14836 let row_id = render_row_id_json(Some(&column_value_to_json(&row[table.pk_index])))?;
14837 conflicts.push(RetainedUniqueConflict {
14838 index: index.name.clone(),
14839 columns: index.columns.clone(),
14840 row_id,
14841 server_row: table
14842 .columns
14843 .iter()
14844 .zip(&row)
14845 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
14846 .collect(),
14847 server_version: version,
14848 });
14849 }
14850 }
14851 Ok(conflicts)
14852 }
14853
14854 fn attach_retained_rows(&self, mut outcome: CommitOutcome) -> Result<CommitOutcome, String> {
14855 if let Some((commit, initial)) = self
14856 .failed_commits
14857 .iter()
14858 .find(|(commit, _)| commit.client_commit_id == outcome.client_commit_id)
14859 {
14860 let mut rows = Vec::new();
14861 for operation in &commit.ops {
14862 let table = self
14863 .schema
14864 .table(&operation.table)
14865 .ok_or_else(|| "sync.unknown_table".to_owned())?;
14866 let key =
14867 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
14868 .map_err(|_| "sync.local_corrupt".to_owned())?;
14869 let base = self.stored_row(table, &operation.row_id, true)?;
14870 rows.push(RetainedCommitRow {
14871 table: operation.table.clone(),
14872 row_id: operation.row_id.clone(),
14873 unique_conflicts: if operation.upsert {
14874 self.unique_conflicts(
14875 table,
14876 initial
14877 .get(&key)
14878 .and_then(Value::as_object)
14879 .or(operation.values.as_ref())
14880 .ok_or("sync.local_corrupt")?,
14881 true,
14882 )?
14883 } else {
14884 Vec::new()
14885 },
14886 local_row: if operation.upsert {
14887 initial
14888 .get(&key)
14889 .and_then(Value::as_object)
14890 .cloned()
14891 .or_else(|| operation.values.clone())
14892 } else {
14893 None
14894 },
14895 server_row: base.as_ref().map(|(values, _)| {
14896 table
14897 .columns
14898 .iter()
14899 .zip(values)
14900 .map(|(column, value)| {
14901 (column.name.clone(), column_value_to_json(value))
14902 })
14903 .collect()
14904 }),
14905 server_version: base.map(|(_, version)| version),
14906 });
14907 }
14908 outcome.retained_rows = Some(rows);
14909 }
14910 Ok(outcome)
14911 }
14912
14913 pub fn commit_outcome(&self, client_commit_id: &str) -> Result<Option<CommitOutcome>, String> {
14914 let row = self
14915 .conn
14916 .query_row(
14917 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14918 resolution, resolved_at_ms, replacement_client_commit_id
14919 FROM _syncular_commit_outcomes WHERE client_commit_id = ?1",
14920 rusqlite::params![client_commit_id],
14921 |row| {
14922 Ok(StoredCommitOutcomeRow {
14923 sequence: row.get(0)?,
14924 client_commit_id: row.get(1)?,
14925 status: row.get(2)?,
14926 recorded_at_ms: row.get(3)?,
14927 results_json: row.get(4)?,
14928 operations_json: row.get(5)?,
14929 resolution: row.get(6)?,
14930 resolved_at_ms: row.get(7)?,
14931 replacement_client_commit_id: row.get(8)?,
14932 })
14933 },
14934 )
14935 .optional()
14936 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14937 row.map(|row| self.attach_retained_rows(Self::outcome_from_row(row)?))
14938 .transpose()
14939 }
14940
14941 pub fn commit_outcomes(&self, query: CommitOutcomeQuery) -> Result<Vec<CommitOutcome>, String> {
14942 if query.limit == Some(0) {
14943 return Err("sync.invalid_request: commit outcome limit must be positive".to_owned());
14944 }
14945 let mut sql = String::from(
14946 "SELECT seq, client_commit_id, status, recorded_at_ms, results_json, operations_json,
14947 resolution, resolved_at_ms, replacement_client_commit_id
14948 FROM _syncular_commit_outcomes",
14949 );
14950 if query.active_only {
14951 sql.push_str(" WHERE resolution = 'active' AND status IN ('conflict', 'rejected')");
14952 }
14953 sql.push_str(" ORDER BY seq DESC");
14954 if let Some(limit) = query.limit {
14955 sql.push_str(&format!(" LIMIT {limit}"));
14956 }
14957 let mut stmt = self
14958 .conn
14959 .prepare(&sql)
14960 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14961 let rows = stmt
14962 .query_map([], |row| {
14963 Ok(StoredCommitOutcomeRow {
14964 sequence: row.get(0)?,
14965 client_commit_id: row.get(1)?,
14966 status: row.get(2)?,
14967 recorded_at_ms: row.get(3)?,
14968 results_json: row.get(4)?,
14969 operations_json: row.get(5)?,
14970 resolution: row.get(6)?,
14971 resolved_at_ms: row.get(7)?,
14972 replacement_client_commit_id: row.get(8)?,
14973 })
14974 })
14975 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
14976 let mut outcomes = Vec::new();
14977 for row in rows {
14978 outcomes.push(self.attach_retained_rows(Self::outcome_from_row(
14979 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
14980 )?)?);
14981 }
14982 Ok(outcomes)
14983 }
14984
14985 fn prune_commit_outcomes(&self) -> Result<(), String> {
14986 #[cfg(test)]
14987 self.outcome_prune_count
14988 .set(self.outcome_prune_count.get() + 1);
14989 let max_entries = self.limits.outcome_retention_max_entries.unwrap_or(1_000);
14990 let count = self
14991 .conn
14992 .query_row(
14993 "SELECT COUNT(*) FROM _syncular_commit_outcomes",
14994 [],
14995 |row| row.get::<_, i64>(0),
14996 )
14997 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
14998 as usize;
14999 let excess = count.saturating_sub(max_entries);
15000 if excess == 0 {
15001 return Ok(());
15002 }
15003 let mut stmt = self
15004 .conn
15005 .prepare(
15006 "SELECT seq FROM _syncular_commit_outcomes
15007 WHERE status IN ('applied', 'cached') OR resolution != 'active'
15008 ORDER BY seq ASC LIMIT ?1",
15009 )
15010 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15011 let rows = stmt
15012 .query_map(rusqlite::params![excess as i64], |row| row.get::<_, i64>(0))
15013 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15014 let sequences = rows
15015 .collect::<Result<Vec<_>, _>>()
15016 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15017 drop(stmt);
15018 for sequence in sequences {
15019 self.conn
15020 .execute(
15021 "DELETE FROM _syncular_commit_outcomes WHERE seq = ?1",
15022 rusqlite::params![sequence],
15023 )
15024 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15025 }
15026 Ok(())
15027 }
15028
15029 pub fn subscribe(
15032 &mut self,
15033 id: String,
15034 table: String,
15035 scopes: Vec<(String, Vec<String>)>,
15036 params: Option<String>,
15037 ) -> Result<(), String> {
15038 if self.schema.table(&table).is_none() {
15039 return Err(format!("unknown table {table:?}"));
15040 }
15041 let registered_at = self
15042 .subs
15043 .iter()
15044 .position(|subscription| subscription.id == id);
15045 if let Some(index) = registered_at {
15046 let existing = &self.subs[index];
15047 let same_intent = existing.table == table
15048 && canonical_scope_json(&existing.requested) == canonical_scope_json(&scopes)
15049 && existing.params == params;
15050 if !same_intent {
15051 return Err(
15052 "client.subscription_intent_mismatch: the subscription id is already registered for a different table, scopes, or params"
15053 .to_owned(),
15054 );
15055 }
15056 if existing.state != SubState::Revoked {
15061 return Ok(());
15062 }
15063 }
15064 let sub = Subscription {
15065 id: id.clone(),
15066 table,
15067 requested: scopes,
15068 params,
15069 cursor: -1,
15070 bootstrap_state: None,
15071 state: SubState::Active,
15072 reason_code: None,
15073 effective: None,
15074 synced_once: false,
15075 };
15076 self.persist_sub(&sub)?;
15077 match registered_at {
15078 Some(index) => self.subs[index] = sub,
15079 None => self.subs.push(sub),
15080 }
15081 if registered_at.is_some() {
15085 self.set_sync_needed(true, true);
15086 }
15087 Ok(())
15088 }
15089
15090 pub fn subscribe_many(&mut self, requests: Vec<SubscriptionRequest>) -> Result<(), String> {
15096 let prior_subs = self.subs.clone();
15097 self.begin_observation("syncular_subscribe")?;
15098 let registered = (|| {
15099 for request in requests {
15100 self.subscribe(request.id, request.table, request.scopes, request.params)?;
15101 }
15102 self.conn
15103 .execute_batch("RELEASE syncular_subscribe")
15104 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15105 })();
15106 if let Err(error) = registered {
15107 self.rollback_observation("syncular_subscribe");
15108 self.subs = prior_subs;
15109 return Err(error);
15110 }
15111 Ok(())
15112 }
15113
15114 pub fn unsubscribe(&mut self, id: &str) {
15115 if self.subs.iter().any(|s| s.id == id) {
15116 self.cancel_sync_round();
15117 }
15118 self.subs.retain(|s| s.id != id);
15119 let _ = self.conn.execute(
15120 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15121 rusqlite::params![id],
15122 );
15123 }
15124
15125 pub fn set_window(
15133 &mut self,
15134 base: &WindowBase,
15135 units: &[String],
15136 ) -> Result<CommandEffects, String> {
15137 let table = self
15138 .schema
15139 .table(&base.table)
15140 .ok_or_else(|| format!("unknown table {:?}", base.table))?;
15141 if table.scope_column(&base.variable).is_none() {
15142 return Err(format!(
15143 "setWindow: table {:?} has no scope variable {:?} (§4.8)",
15144 base.table, base.variable
15145 ));
15146 }
15147 let base_key = window_base_key(base);
15148 let wanted: std::collections::HashSet<&String> = units.iter().collect();
15149 let live = self.load_window_units(&base_key);
15150 self.begin_observation("syncular_window")?;
15151 let mut batch = ChangeAccumulator::default();
15152 let mut changed = false;
15153
15154 let prior_subs = self.subs.clone();
15155 let widened = (|| {
15156 for unit in units {
15158 if live.iter().any(|(u, _)| u == unit) {
15159 continue;
15160 }
15161 let sub_id = derive_sub_id(base, unit);
15162 self.delete_pending_evict(&sub_id)?;
15163 self.insert_window_unit(&base_key, unit, &sub_id)?;
15164 self.subscribe(
15165 sub_id,
15166 base.table.clone(),
15167 unit_scopes(base, unit),
15168 base.params.clone(),
15169 )?;
15170 batch.window(&base_key, &base.table, unit);
15171 changed = true;
15172 }
15173
15174 self.finish_observation("syncular_window", batch)
15175 })();
15176 if let Err(error) = widened {
15177 self.rollback_observation("syncular_window");
15178 self.subs = prior_subs;
15179 return Err(error);
15180 }
15181 for (unit, sub_id) in live {
15182 if wanted.contains(&unit) {
15183 continue;
15184 }
15185 self.rebuild_overlay_if_dirty()?;
15186 let prior_subs = self.subs.clone();
15187 self.begin_observation("syncular_window")?;
15188 let effective = self
15189 .subs
15190 .iter()
15191 .find(|sub| sub.id == sub_id)
15192 .and_then(|sub| sub.effective.clone())
15193 .unwrap_or_else(|| unit_scopes(base, &unit));
15194 let mut batch = ChangeAccumulator::default();
15195 self.record_scope_map(&mut batch, &base.table, &effective);
15196 batch.window(&base_key, &base.table, &unit);
15197 let result = self.evict_unit(&base_key, base, &unit, &sub_id);
15198 let remaining = match result {
15199 Ok(remaining) => remaining,
15200 Err(error) => {
15201 self.rollback_observation("syncular_window");
15202 self.subs = prior_subs;
15203 return Err(error);
15204 }
15205 };
15206 if let Err(error) = self.finish_observation("syncular_window", batch) {
15207 self.rollback_observation("syncular_window");
15208 self.subs = prior_subs;
15209 return Err(error);
15210 }
15211 if remaining {
15212 self.drain_pending_evictions()?;
15213 }
15214 changed = true;
15215 }
15216 Ok(if changed {
15217 CommandEffects::interactive()
15218 } else {
15219 CommandEffects::none()
15220 })
15221 }
15222
15223 pub fn window_state(&self, base: &WindowBase) -> WindowState {
15228 let mut units = Vec::new();
15229 let mut pending = Vec::new();
15230 for (unit, sub_id) in self.load_window_units(&window_base_key(base)) {
15231 let is_pending = match self.subs.iter().find(|s| s.id == sub_id) {
15232 Some(sub) => {
15233 sub.state != SubState::Active || sub.cursor < 0 || sub.bootstrap_state.is_some()
15234 }
15235 None => true,
15236 };
15237 if is_pending {
15238 pending.push(unit.clone());
15239 }
15240 units.push(unit);
15241 }
15242 WindowState { units, pending }
15243 }
15244
15245 fn load_window_units(&self, base_key: &str) -> Vec<(String, String)> {
15246 let mut stmt = match self
15247 .conn
15248 .prepare("SELECT unit, sub_id FROM _syncular_windows WHERE base = ?1 ORDER BY unit ASC")
15249 {
15250 Ok(stmt) => stmt,
15251 Err(_) => return Vec::new(),
15252 };
15253 let rows = stmt.query_map(rusqlite::params![base_key], |row| {
15254 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
15255 });
15256 match rows {
15257 Ok(rows) => rows.filter_map(Result::ok).collect(),
15258 Err(_) => Vec::new(),
15259 }
15260 }
15261
15262 fn load_registered_window_units(&self) -> Vec<(String, String, String)> {
15263 let mut stmt = match self.conn.prepare(
15264 "SELECT windows.base, windows.unit, subscriptions.tbl
15265 FROM _syncular_windows AS windows
15266 JOIN _syncular_subscriptions AS subscriptions
15267 ON subscriptions.id = windows.sub_id
15268 ORDER BY windows.base, windows.unit",
15269 ) {
15270 Ok(stmt) => stmt,
15271 Err(_) => return Vec::new(),
15272 };
15273 let rows = stmt.query_map([], |row| {
15274 Ok((
15275 row.get::<_, String>(0)?,
15276 row.get::<_, String>(1)?,
15277 row.get::<_, String>(2)?,
15278 ))
15279 });
15280 match rows {
15281 Ok(rows) => rows.filter_map(Result::ok).collect(),
15282 Err(_) => Vec::new(),
15283 }
15284 }
15285
15286 fn window_unit_by_sub_id(&self, sub_id: &str) -> Option<(String, String)> {
15287 self.conn
15288 .query_row(
15289 "SELECT base, unit FROM _syncular_windows WHERE sub_id = ?1 LIMIT 1",
15290 rusqlite::params![sub_id],
15291 |row| Ok((row.get(0)?, row.get(1)?)),
15292 )
15293 .ok()
15294 }
15295
15296 fn insert_window_unit(&self, base_key: &str, unit: &str, sub_id: &str) -> Result<(), String> {
15297 self.conn
15298 .execute(
15299 "INSERT OR REPLACE INTO _syncular_windows(base, unit, sub_id) VALUES (?1, ?2, ?3)",
15300 rusqlite::params![base_key, unit, sub_id],
15301 )
15302 .map(|_| ())
15303 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15304 }
15305
15306 fn delete_window_unit(&self, base_key: &str, unit: &str) -> Result<(), String> {
15307 self.conn
15308 .execute(
15309 "DELETE FROM _syncular_windows WHERE base = ?1 AND unit = ?2",
15310 rusqlite::params![base_key, unit],
15311 )
15312 .map(|_| ())
15313 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15314 }
15315
15316 fn evict_unit(
15323 &mut self,
15324 base_key: &str,
15325 base: &WindowBase,
15326 unit: &str,
15327 sub_id: &str,
15328 ) -> Result<bool, String> {
15329 let effective = self
15330 .subs
15331 .iter()
15332 .find(|s| s.id == sub_id)
15333 .and_then(|s| s.effective.clone())
15334 .unwrap_or_else(|| unit_scopes(base, unit));
15335 let pinned = self.pinned_row_ids(&base.table);
15336 let (remaining, deferred) = self.evict_scope_rows(&base.table, &effective, &pinned)?;
15337 self.delete_window_unit(base_key, unit)?;
15338 self.conn
15339 .execute(
15340 "DELETE FROM _syncular_subscriptions WHERE id = ?1",
15341 [sub_id],
15342 )
15343 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15344 self.subs.retain(|sub| sub.id != sub_id);
15345 if deferred {
15346 self.save_pending_evict(sub_id, &base.table, &effective)?;
15347 } else {
15348 self.delete_pending_evict(sub_id)?;
15349 }
15350 Ok(remaining)
15351 }
15352
15353 fn evict_scope_rows(
15357 &self,
15358 table_name: &str,
15359 effective: &[(String, Vec<String>)],
15360 pinned: &std::collections::HashSet<String>,
15361 ) -> Result<(bool, bool), String> {
15362 if effective.is_empty() {
15363 return Ok((false, false));
15364 }
15365 let table = self.schema.table(table_name).ok_or("sync.unknown_table")?;
15366 let mut clauses = Vec::new();
15367 let mut params: Vec<SqlValue> = Vec::new();
15368 for (variable, values) in effective {
15369 let column = table.scope_column(variable).ok_or("sync.scope_revoked")?;
15370 if values.is_empty() {
15371 return Ok((false, false));
15372 }
15373 let holes = values
15374 .iter()
15375 .map(|v| {
15376 params.push(SqlValue::Text(v.clone()));
15377 "?"
15378 })
15379 .collect::<Vec<_>>()
15380 .join(", ");
15381 clauses.push(format!("{} IN ({holes})", quote_ident(column)));
15382 }
15383 let scope = clauses.join(" AND ");
15384 let scope_params = params.len();
15385 let pk = quote_ident(&table.primary_key);
15386 if !pinned.is_empty() {
15387 let holes = pinned
15388 .iter()
15389 .map(|id| {
15390 params.push(SqlValue::Text(id.clone()));
15391 "?"
15392 })
15393 .collect::<Vec<_>>()
15394 .join(", ");
15395 clauses.push(format!("{pk} NOT IN ({holes})"));
15396 }
15397 let predicate = clauses.join(" AND ");
15398 let base = base_table(table_name);
15399 let visible = visible_table(table_name);
15400 let mut ids: Vec<SqlValue> = Vec::new();
15401 for source in [&base, &visible] {
15402 let mut stmt = self
15403 .conn
15404 .prepare_cached(&format!(
15405 "SELECT {pk} FROM {source} WHERE {predicate} LIMIT 1024"
15406 ))
15407 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15408 ids = stmt
15409 .query_map(rusqlite::params_from_iter(¶ms), |row| row.get(0))
15410 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
15411 .collect::<Result<_, _>>()
15412 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15413 if !ids.is_empty() {
15414 break;
15415 }
15416 }
15417 if !ids.is_empty() {
15418 for id in &ids {
15419 self.conn
15420 .execute(
15421 "DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2",
15422 rusqlite::params![table_name, id],
15423 )
15424 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15425 }
15426 let holes = vec!["?"; ids.len()].join(", ");
15427 for source in [&base, &visible] {
15428 self.conn
15429 .execute(
15430 &format!("DELETE FROM {source} WHERE {pk} IN ({holes})"),
15431 rusqlite::params_from_iter(&ids),
15432 )
15433 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15434 }
15435 }
15436 let mut remaining = false;
15437 let mut deferred = false;
15438 for source in [&base, &visible] {
15439 remaining |= self
15440 .conn
15441 .query_row(
15442 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {predicate})"),
15443 rusqlite::params_from_iter(¶ms),
15444 |row| row.get::<_, bool>(0),
15445 )
15446 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15447 deferred |= self
15448 .conn
15449 .query_row(
15450 &format!("SELECT EXISTS(SELECT 1 FROM {source} WHERE {scope})"),
15451 rusqlite::params_from_iter(¶ms[..scope_params]),
15452 |row| row.get::<_, bool>(0),
15453 )
15454 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15455 }
15456 Ok((remaining, deferred))
15457 }
15458
15459 fn drain_pending_evictions(&mut self) -> Result<(), String> {
15461 let pending = self.load_pending_evictions()?;
15462 if pending.is_empty() {
15463 return Ok(());
15464 }
15465 self.rebuild_overlay_if_dirty()?;
15466 for (sub_id, table_name, effective) in pending {
15467 if self.schema.table(&table_name).is_none() {
15468 self.delete_pending_evict(&sub_id)?;
15469 continue;
15470 }
15471 loop {
15472 std::thread::yield_now();
15473 self.begin_observation("syncular_evict")?;
15474 let pinned = self.pinned_row_ids(&table_name);
15475 let result = self.evict_scope_rows(&table_name, &effective, &pinned);
15476 let (remaining, deferred) = match result {
15477 Ok(result) => result,
15478 Err(error) => {
15479 self.rollback_observation("syncular_evict");
15480 return Err(error);
15481 }
15482 };
15483 if !deferred {
15484 if let Err(error) = self.delete_pending_evict(&sub_id) {
15485 self.rollback_observation("syncular_evict");
15486 return Err(error);
15487 }
15488 }
15489 let mut batch = ChangeAccumulator::default();
15490 self.record_scope_map(&mut batch, &table_name, &effective);
15491 if let Err(error) = self.finish_observation("syncular_evict", batch) {
15492 self.rollback_observation("syncular_evict");
15493 return Err(error);
15494 }
15495 if !remaining {
15496 break;
15497 }
15498 }
15499 }
15500 Ok(())
15501 }
15502
15503 fn pinned_row_ids(&self, table: &str) -> std::collections::HashSet<String> {
15506 let mut pinned = std::collections::HashSet::new();
15507 for commit in self
15508 .outbox
15509 .iter()
15510 .chain(self.failed_commits.iter().map(|(commit, _)| commit))
15511 {
15512 for op in &commit.ops {
15513 if op.table == table {
15514 pinned.insert(op.row_id.clone());
15515 }
15516 }
15517 }
15518 pinned
15519 }
15520
15521 fn save_pending_evict(
15522 &self,
15523 sub_id: &str,
15524 table: &str,
15525 effective: &[(String, Vec<String>)],
15526 ) -> Result<(), String> {
15527 self.conn.execute(
15528 "INSERT OR REPLACE INTO _syncular_window_pending_evict(sub_id, tbl, effective_scopes) VALUES (?1, ?2, ?3)",
15529 rusqlite::params![sub_id, table, scope_map_to_json(effective).to_string()],
15530 ).map(|_| ()).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15531 }
15532
15533 fn delete_pending_evict(&self, sub_id: &str) -> Result<(), String> {
15534 self.conn
15535 .execute(
15536 "DELETE FROM _syncular_window_pending_evict WHERE sub_id = ?1",
15537 rusqlite::params![sub_id],
15538 )
15539 .map(|_| ())
15540 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
15541 }
15542
15543 fn load_pending_evictions(&self) -> Result<Vec<PendingEvict>, String> {
15544 let mut stmt = self
15545 .conn
15546 .prepare("SELECT sub_id, tbl, effective_scopes FROM _syncular_window_pending_evict")
15547 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15548 let rows = stmt
15549 .query_map([], |row| {
15550 Ok((
15551 row.get::<_, String>(0)?,
15552 row.get::<_, String>(1)?,
15553 row.get::<_, String>(2)?,
15554 ))
15555 })
15556 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15557 rows.map(|row| {
15558 let (sub_id, table, json) =
15559 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15560 let value: Value = serde_json::from_str(&json)
15561 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15562 let effective = json_to_scope_map(&value)
15563 .map_err(|_| "sync.local_corrupt: invalid pending eviction scopes".to_owned())?;
15564 Ok((sub_id, table, effective))
15565 })
15566 .collect()
15567 }
15568
15569 pub fn set_retain_failed_commits(&mut self, enabled: bool) -> Result<(), String> {
15571 self.retain_failed_commits = enabled;
15572 for (commit, _) in &self.failed_commits {
15573 for operation in &commit.ops {
15574 self.overlay_dirty.table(&operation.table);
15575 }
15576 }
15577 self.failed_commits = self.load_failed_commits()?;
15578 for (commit, _) in &self.failed_commits {
15579 for operation in &commit.ops {
15580 self.overlay_dirty.table(&operation.table);
15581 }
15582 }
15583 Ok(())
15584 }
15585
15586 fn load_failed_commits(&self) -> Result<Vec<FailedCommit>, String> {
15587 let mut statement = self.conn.prepare("SELECT client_commit_id, operations_json, initial_json FROM _syncular_failed_commits ORDER BY seq")
15588 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15589 let rows = statement
15590 .query_map([], |row| {
15591 Ok((
15592 row.get::<_, String>(0)?,
15593 row.get::<_, String>(1)?,
15594 row.get::<_, String>(2)?,
15595 ))
15596 })
15597 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15598 rows.map(|row| {
15599 let (id, operations, initial) =
15600 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15601 let operations: Vec<CommitOperation> =
15602 serde_json::from_str(&operations).map_err(|_| "sync.local_corrupt".to_owned())?;
15603 let initial =
15604 serde_json::from_str(&initial).map_err(|_| "sync.local_corrupt".to_owned())?;
15605 Ok((
15606 OutboxCommit {
15607 client_commit_id: id,
15608 ops: operations
15609 .into_iter()
15610 .map(|op| OutboxOp {
15611 upsert: op.op == "upsert",
15612 table: op.table,
15613 row_id: op.row_id,
15614 base_version: op.base_version,
15615 values: op.values,
15616 insert: op.insert,
15617 })
15618 .collect(),
15619 },
15620 initial,
15621 ))
15622 })
15623 .collect()
15624 }
15625
15626 fn retain_failed_commit(&mut self, id: &str, operations: &[OutboxOp]) -> Result<(), String> {
15627 let mut initial = Map::new();
15628 for operation in operations {
15629 let table = self
15630 .schema
15631 .table(&operation.table)
15632 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15633 let initial_row = match self.stored_row(table, &operation.row_id, true)? {
15634 Some(row) => Some(row),
15635 None => self.visible_row(table, &operation.row_id)?,
15636 };
15637 if let Some((values, _)) = initial_row {
15638 let mut intended: Map<String, Value> = table
15639 .columns
15640 .iter()
15641 .zip(values.iter())
15642 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
15643 .collect();
15644 if let Some(patch) = &operation.values {
15645 intended.extend(patch.clone());
15646 }
15647 initial.insert(
15648 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15649 .map_err(|_| "sync.local_corrupt".to_owned())?,
15650 Value::Object(intended),
15651 );
15652 }
15653 }
15654 self.conn.execute("INSERT INTO _syncular_failed_commits(client_commit_id, operations_json, initial_json) VALUES (?1, ?2, ?3)", rusqlite::params![id,
15655 serde_json::to_string(&operations.iter().map(CommitOperation::from).collect::<Vec<_>>()).map_err(|_| "sync.local_corrupt".to_owned())?,
15656 serde_json::to_string(&initial).map_err(|_| "sync.local_corrupt".to_owned())?])
15657 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15658 self.failed_commits.push((
15659 OutboxCommit {
15660 client_commit_id: id.to_owned(),
15661 ops: operations.to_vec(),
15662 },
15663 initial,
15664 ));
15665 Ok(())
15666 }
15667
15668 fn drop_failed_commits(
15669 &mut self,
15670 matches: impl Fn(&str, &Map<String, Value>) -> bool,
15671 ) -> Result<Vec<OutboxCommit>, String> {
15672 let mut doomed = self.drop_acknowledged_rows(&matches)?;
15673 for (commit, initial) in &self.failed_commits {
15674 for operation in &commit.ops {
15675 let key =
15676 serde_json::to_string(&(operation.table.clone(), operation.row_id.clone()))
15677 .map_err(|_| "sync.local_corrupt".to_owned())?;
15678 if initial
15679 .get(&key)
15680 .and_then(Value::as_object)
15681 .is_some_and(|values| matches(&operation.table, values))
15682 {
15683 doomed.push(commit.clone());
15684 break;
15685 }
15686 }
15687 }
15688 for commit in &doomed {
15689 self.conn
15690 .execute(
15691 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
15692 rusqlite::params![commit.client_commit_id],
15693 )
15694 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15695 }
15696 for commit in &doomed {
15697 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))?;
15698 }
15699 self.conflicts.retain(|record| {
15700 !doomed
15701 .iter()
15702 .any(|commit| commit.client_commit_id == record.client_commit_id)
15703 });
15704 self.rejections.retain(|record| {
15705 !doomed
15706 .iter()
15707 .any(|commit| commit.client_commit_id == record.client_commit_id)
15708 });
15709 self.failed_commits.retain(|(commit, _)| {
15710 !doomed
15711 .iter()
15712 .any(|dropped| dropped.client_commit_id == commit.client_commit_id)
15713 });
15714 for commit in &doomed {
15715 for operation in &commit.ops {
15716 self.overlay_dirty.table(&operation.table);
15717 }
15718 }
15719 Ok(doomed)
15720 }
15721
15722 pub fn mutate(&mut self, mutations: Vec<Mutation>) -> Result<String, ClientError> {
15726 let prior = self.storage_failure.borrow_mut().take();
15727 let outcome = (|| -> Result<String, String> {
15728 if mutations.is_empty() {
15729 return Err(
15730 "sync.invalid_request: a commit must contain at least one operation (§6.1)"
15731 .to_owned(),
15732 );
15733 }
15734 let mut ops = Vec::with_capacity(mutations.len());
15735 for mutation in mutations {
15736 match mutation {
15737 Mutation::Patch {
15738 table,
15739 values,
15740 base_version,
15741 } => {
15742 let schema_table = self
15743 .schema
15744 .table(&table)
15745 .ok_or_else(|| "sync.unknown_table".to_owned())?;
15746 let values = normalize_values_casing(schema_table, values)?;
15747 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15748 ops.push(self.prepare_patch(&table, &row_id, values, base_version)?);
15749 }
15750 Mutation::Upsert {
15751 table,
15752 values,
15753 base_version,
15754 } => {
15755 let schema_table = self.schema.table(&table).ok_or_else(|| {
15756 format!("sync.unknown_table: unknown table {table:?}")
15757 })?;
15758 let values = normalize_values_casing(schema_table, values)?;
15762 let row_id = render_row_id_json(values.get(&schema_table.primary_key))?;
15763 let values = full_row_values(schema_table, values)?;
15766 self.validate_author_encode(schema_table, &row_id, &values)?;
15772 let insert = !self.visible_row_exists(schema_table, &row_id)?;
15776 ops.push(OutboxOp {
15777 upsert: true,
15778 table,
15779 row_id,
15780 base_version,
15781 values: Some(values),
15782 insert,
15783 });
15784 }
15785 Mutation::Delete {
15786 table,
15787 row_id,
15788 base_version,
15789 } => {
15790 if self.schema.table(&table).is_none() {
15791 return Err(format!("sync.unknown_table: unknown table {table:?}"));
15792 }
15793 ops.push(OutboxOp {
15794 upsert: false,
15795 table,
15796 row_id,
15797 base_version,
15798 values: None,
15799 insert: false,
15800 });
15801 }
15802 }
15803 }
15804 self.record_outbox_commit(ops)
15805 })();
15806 self.finish_authoring(prior, outcome)
15807 }
15808
15809 fn finish_authoring(
15815 &self,
15816 prior: Option<QueryReadFailure>,
15817 outcome: Result<String, String>,
15818 ) -> Result<String, ClientError> {
15819 let fresh = self.storage_failure.borrow_mut().take();
15820 *self.storage_failure.borrow_mut() = fresh.clone().or(prior);
15821 match outcome {
15822 Ok(id) => Ok(id),
15823 Err(message) => Err(fresh
15824 .map(ClientError::from)
15825 .unwrap_or_else(|| ClientError::from(message))),
15826 }
15827 }
15828
15829 fn record_outbox_commit(&mut self, ops: Vec<OutboxOp>) -> Result<String, String> {
15830 let commit = OutboxCommit {
15831 client_commit_id: uuid::Uuid::new_v4().to_string(),
15832 ops,
15833 };
15834 self.begin_observation("syncular_mutation")?;
15835 let mut batch = ChangeAccumulator::default();
15836 for op in &commit.ops {
15837 let mut precise = self.record_row_scopes(&mut batch, &op.table, &op.row_id, false);
15838 if let Some(values) = &op.values {
15839 if let Some(table) = self.schema.table(&op.table) {
15840 for scope in &table.scope_variables {
15841 if let Some(Value::String(value)) = values.get(&scope.column) {
15842 batch.scope(&op.table, format!("{}:{value}", scope.prefix));
15843 precise = true;
15844 }
15845 }
15846 }
15847 }
15848 if !precise {
15849 batch.table(&op.table);
15850 }
15851 }
15852 if let Err(error) = self.persist_outbox_insert(&commit) {
15853 self.rollback_observation("syncular_mutation");
15854 return Err(error);
15855 }
15856 let was_dirty = self.overlay_dirty.snapshot();
15857 if let Err(error) = self.rebuild_overlay_if_dirty() {
15862 self.rollback_observation("syncular_mutation");
15863 self.overlay_dirty.restore(was_dirty.clone());
15864 return Err(error);
15865 }
15866 if let Some(error) = commit
15867 .ops
15868 .iter()
15869 .find_map(|op| self.apply_outbox_op(op).err())
15870 {
15871 self.rollback_observation("syncular_mutation");
15872 self.overlay_dirty.restore(was_dirty.clone());
15873 return Err(error);
15874 }
15875 let id = commit.client_commit_id.clone();
15876 self.outbox.push(commit);
15877 batch.status = true;
15878 if let Err(error) = self.finish_observation("syncular_mutation", batch) {
15879 self.rollback_observation("syncular_mutation");
15880 self.outbox.pop();
15881 self.overlay_dirty.restore(was_dirty.clone());
15882 return Err(error);
15883 }
15884 Ok(id)
15885 }
15886
15887 pub fn patch(
15893 &mut self,
15894 table: &str,
15895 row_id: &str,
15896 partial: Map<String, Value>,
15897 base_version: Option<i64>,
15898 ) -> Result<String, ClientError> {
15899 let prior = self.storage_failure.borrow_mut().take();
15900 let outcome = self.perform_patch(table, row_id, partial, base_version);
15901 self.finish_authoring(prior, outcome)
15902 }
15903
15904 fn perform_patch(
15907 &mut self,
15908 table: &str,
15909 row_id: &str,
15910 partial: Map<String, Value>,
15911 base_version: Option<i64>,
15912 ) -> Result<String, String> {
15913 let operation = self.prepare_patch(table, row_id, partial, base_version)?;
15914 self.record_outbox_commit(vec![operation])
15915 }
15916
15917 fn prepare_patch(
15918 &self,
15919 table: &str,
15920 row_id: &str,
15921 partial: Map<String, Value>,
15922 base_version: Option<i64>,
15923 ) -> Result<OutboxOp, String> {
15924 let schema_table = self
15925 .schema
15926 .table(table)
15927 .ok_or_else(|| format!("sync.unknown_table: unknown table {table:?}"))?;
15928 let partial = normalize_values_casing(schema_table, partial)?;
15929 if let Some(pk) = partial.get(&schema_table.primary_key) {
15930 if render_row_id_json(Some(pk)).ok().as_deref() != Some(row_id) {
15931 return Err(format!(
15932 "sync.invalid_request: table {table:?}: patch cannot change the primary key"
15933 ));
15934 }
15935 }
15936 if !self.visible_row_exists(schema_table, row_id)? {
15937 return Err("sync.row_missing: a sparse patch requires a local row".to_owned());
15938 }
15939 for scope in &schema_table.scope_variables {
15949 if !partial.contains_key(&scope.column) || scope.column == schema_table.primary_key {
15950 continue;
15951 }
15952 let Some(column) = schema_table
15953 .columns
15954 .iter()
15955 .find(|column| column.name == scope.column)
15956 else {
15957 continue;
15958 };
15959 let stored = self
15960 .conn
15961 .query_row(
15962 &format!(
15963 "SELECT {} FROM {} WHERE {}",
15964 quote_ident(&scope.column),
15965 visible_table(&schema_table.name),
15966 row_id_predicate(schema_table)
15967 ),
15968 rusqlite::params![row_id],
15969 |row| Ok(sql_ref_to_json(column, row.get_ref(0)?)),
15970 )
15971 .optional()
15972 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
15973 let incoming = json_to_column_value(column, partial.get(&scope.column))
15978 .ok()
15979 .map(|value| column_value_to_json(&value));
15980 let equal = stored.as_ref().is_some_and(|stored| {
15984 stored.get("$bytes").is_none() && incoming.as_ref() == Some(stored)
15985 });
15986 if !equal {
15987 return Err(format!(
15988 "sync.invalid_request: table {table:?}: patch cannot write scope column {:?} (§3.4)",
15989 scope.column
15990 ));
15991 }
15992 }
15993 let mut values = partial;
15994 for scope in &schema_table.scope_variables {
15997 values.remove(&scope.column);
15998 }
15999 let pk_value = match schema_table.columns[schema_table.pk_index].ty {
16000 ColumnType::Integer => match row_id.parse::<i64>() {
16002 Ok(v)
16003 if v.to_string() == row_id
16004 && (-ssp2::primitives::I64_SAFE_MAX..=ssp2::primitives::I64_SAFE_MAX)
16005 .contains(&v) =>
16006 {
16007 Value::from(v)
16008 }
16009 _ => {
16010 return Err(format!(
16011 "sync.invalid_request: table {table:?}: invalid integer primary key {row_id:?}"
16012 ))
16013 }
16014 },
16015 ColumnType::Boolean => match row_id {
16016 "true" => Value::Bool(true),
16017 "false" => Value::Bool(false),
16018 _ => {
16019 return Err(format!(
16020 "sync.invalid_request: table {table:?}: invalid boolean primary key {row_id:?}"
16021 ))
16022 }
16023 },
16024 _ => Value::from(row_id.to_owned()),
16025 };
16026 values.insert(schema_table.primary_key.clone(), pk_value);
16027 self.validate_author_encode(schema_table, row_id, &values)?;
16033 Ok(OutboxOp {
16034 upsert: true,
16035 table: table.to_owned(),
16036 row_id: row_id.to_owned(),
16037 base_version,
16038 values: Some(values),
16039 insert: false,
16040 })
16041 }
16042
16043 fn stored_key_fallback(&self, table: &TableSchema, row_id: &str) -> Option<Row> {
16048 let selector = self.encryption.key_id_columns.get(&table.name)?;
16049 let index = table
16050 .columns
16051 .iter()
16052 .position(|column| &column.name == selector)?;
16053 let sql = format!(
16054 "SELECT {} FROM {} WHERE {}",
16055 quote_ident(selector),
16056 visible_table(&table.name),
16057 row_id_predicate(table)
16058 );
16059 let key: Option<String> = self
16060 .conn
16061 .query_row(&sql, rusqlite::params![row_id], |row| row.get(0))
16062 .ok()?;
16063 let key = key.filter(|key| !key.is_empty())?;
16064 let mut fallback: Row = vec![None; table.columns.len()];
16065 fallback[index] = Some(ColumnValue::String(key));
16066 Some(fallback)
16067 }
16068
16069 fn validate_author_encode(
16074 &self,
16075 table: &TableSchema,
16076 row_id: &str,
16077 values: &Map<String, Value>,
16078 ) -> Result<(), String> {
16079 match encode_sparse_row_json(table, row_id, values, &self.encryption, None) {
16080 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => Ok(()),
16081 result => result.map(|_| ()),
16082 }
16083 }
16084
16085 fn drop_unencodable_outbox(
16099 &mut self,
16100 with_encryption: bool,
16101 batch: Option<&mut ChangeAccumulator>,
16102 ) -> Result<bool, String> {
16103 let failures = self
16104 .outbox
16105 .iter()
16106 .filter_map(|commit| {
16107 commit
16108 .ops
16109 .iter()
16110 .find_map(|op| {
16111 let table = self.schema.table(&op.table).filter(|_| op.upsert)?;
16112 let values = op.presented_values(table)?;
16115 let fallback = self.stored_key_fallback(table, &op.row_id);
16116 match encode_sparse_row_json(
16117 table,
16118 &op.row_id,
16119 &values,
16120 &self.encryption,
16121 fallback.as_ref(),
16122 ) {
16123 Err(error) if error.starts_with(ENCRYPT_FAILED_CODE) => {
16124 with_encryption.then_some((error, ENCRYPT_FAILED_CODE))
16125 }
16126 Err(error) if error.starts_with(INVALID_REQUEST_CODE) => Some((
16131 LEGACY_VALUE_REFUSAL_MESSAGE.to_owned(),
16132 OUTBOX_INCOMPATIBLE_CODE,
16133 )),
16134 _ => None,
16135 }
16136 })
16137 .map(|(error, code)| (commit.clone(), error, code))
16138 })
16139 .collect::<Vec<_>>();
16140 if failures.is_empty() {
16141 return Ok(false);
16142 }
16143 let prior_outbox = self.outbox.clone();
16149 let prior_rejections = self.rejections.clone();
16150 let prior_failed_commits = self.failed_commits.clone();
16154 let prior_changes = self.change_queue.len();
16155 let prior_intents = self.sync_intent_queue.len();
16156 let prior_last_change = self.last_change.clone();
16157 let prior_overlay_dirty = self.overlay_dirty.snapshot();
16158 let owns_observation = batch.is_none();
16159 let owns_transaction = self.conn.is_autocommit();
16160 let mut domains = ChangeAccumulator::default();
16161 if owns_observation {
16162 self.begin_observation("syncular_value_recovery")?;
16163 }
16164 let dropped = (|| -> Result<(), String> {
16165 for (commit, message, code) in failures {
16166 if self.retain_failed_commits && code == ENCRYPT_FAILED_CODE {
16167 self.retain_failed_commit(&commit.client_commit_id, &commit.ops)?;
16168 }
16169 let representable = commit.ops.iter().all(Self::outbox_op_is_journalable);
16170 let rejection = RejectionRecord {
16171 client_commit_id: commit.client_commit_id.clone(),
16172 op_index: 0,
16173 code: code.to_owned(),
16174 message,
16175 retryable: false,
16176 details: (code == OUTBOX_INCOMPATIBLE_CODE).then(|| RejectionDetails {
16177 field_paths: None,
16178 reason: Some(INVALID_STORED_VALUES_REASON.to_owned()),
16179 required_action: None,
16180 references: None,
16181 }),
16182 operation: representable
16183 .then(|| commit.ops.first())
16184 .flatten()
16185 .map(CommitOperation::from),
16186 };
16187 self.persist_commit_outcome(
16188 &commit.client_commit_id,
16189 CommitOutcomeStatus::Rejected,
16190 &[CommitOperationOutcome::Error {
16191 rejection: rejection.clone(),
16192 }],
16193 representable.then_some(commit.ops.as_slice()),
16194 )?;
16195 self.delete_outbox_persisted(&commit.client_commit_id)?;
16196 self.outbox
16197 .retain(|candidate| candidate.client_commit_id != commit.client_commit_id);
16198 self.rejections.push(rejection);
16199 for operation in &commit.ops {
16200 domains.table(operation.table.as_str());
16201 }
16202 }
16203 domains.status = true;
16204 domains.rejections = true;
16205 domains.outcomes = true;
16206 self.prune_commit_outcomes()?;
16207 self.overlay_dirty.set(true);
16209 self.rebuild_overlay()?;
16210 Ok(())
16211 })();
16212 let result = match batch {
16213 Some(batch) => {
16214 if dropped.is_ok() {
16215 for table in domains.tables.keys() {
16216 batch.table(table.as_str());
16217 }
16218 batch.status |= domains.status;
16219 batch.rejections |= domains.rejections;
16220 batch.outcomes |= domains.outcomes;
16221 }
16222 dropped
16223 }
16224 None => {
16225 dropped.and_then(|()| self.finish_observation("syncular_value_recovery", domains))
16226 }
16227 };
16228 if let Err(error) = result {
16229 if owns_observation {
16230 if owns_transaction {
16231 if let Err(rollback) = self.conn.execute_batch("ROLLBACK") {
16232 let mut failure = self.storage_failure.borrow_mut();
16233 let failure =
16234 failure.get_or_insert_with(|| QueryReadFailure::from(error.clone()));
16235 failure.rollback_failure = Some(Box::new(QueryReadFailure::from(rollback)));
16236 }
16237 } else {
16238 self.rollback_observation("syncular_value_recovery");
16239 }
16240 }
16241 self.outbox = prior_outbox;
16242 self.rejections = prior_rejections;
16243 self.failed_commits = prior_failed_commits;
16244 self.change_queue.truncate(prior_changes);
16245 self.sync_intent_queue.truncate(prior_intents);
16246 self.last_change = prior_last_change;
16247 self.overlay_dirty.restore(prior_overlay_dirty);
16248 return Err(error);
16249 }
16250 Ok(true)
16251 }
16252
16253 pub fn pending_commit_ids(&self) -> Vec<String> {
16254 self.outbox
16255 .iter()
16256 .map(|c| c.client_commit_id.clone())
16257 .collect()
16258 }
16259
16260 pub fn pending_payloads(&self) -> Vec<Vec<u8>> {
16264 let mut payloads = Vec::new();
16265 for commit in &self.outbox {
16266 for op in &commit.ops {
16267 let Some(table) = self.schema.table(&op.table).filter(|_| op.upsert) else {
16268 continue;
16269 };
16270 let Some(values) = op.presented_values(table) else {
16271 continue;
16272 };
16273 let fallback = self.stored_key_fallback(table, &op.row_id);
16274 if let Ok(payload) = encode_sparse_row_json(
16275 table,
16276 &op.row_id,
16277 &values,
16278 &self.encryption,
16279 fallback.as_ref(),
16280 ) {
16281 payloads.push(payload);
16282 }
16283 }
16284 }
16285 payloads
16286 }
16287
16288 pub fn conflicts(&self) -> &[ConflictRecord] {
16289 &self.conflicts
16290 }
16291
16292 pub fn rejections(&self) -> &[RejectionRecord] {
16293 &self.rejections
16294 }
16295
16296 pub fn resolve_commit_outcome(
16297 &mut self,
16298 input: ResolveCommitOutcomeInput,
16299 ) -> Result<CommitOutcome, String> {
16300 let current = self
16301 .commit_outcome(&input.client_commit_id)?
16302 .ok_or_else(|| {
16303 format!(
16304 "sync.outcome_not_found: no durable outcome exists for {:?}",
16305 input.client_commit_id
16306 )
16307 })?;
16308 if current.resolution != CommitOutcomeResolution::Active {
16309 return Ok(current);
16310 }
16311 if input.resolution == CommitOutcomeResolution::Active {
16312 return Err("sync.invalid_request: resolution must leave active state".to_owned());
16313 }
16314 match input.resolution {
16315 CommitOutcomeResolution::Superseded => {
16316 let replacement = input
16317 .replacement_client_commit_id
16318 .as_deref()
16319 .filter(|value| !value.is_empty() && *value != input.client_commit_id)
16320 .ok_or_else(|| {
16321 "sync.invalid_request: superseded outcomes require a distinct replacementClientCommitId"
16322 .to_owned()
16323 })?;
16324 let _ = replacement;
16325 }
16326 _ if input.replacement_client_commit_id.is_some() => {
16327 return Err(
16328 "sync.invalid_request: replacementClientCommitId is valid only for superseded outcomes"
16329 .to_owned(),
16330 );
16331 }
16332 _ => {}
16333 }
16334 let allowed = match current.status {
16335 CommitOutcomeStatus::Conflict => matches!(
16336 input.resolution,
16337 CommitOutcomeResolution::ResolvedKeepServer | CommitOutcomeResolution::Superseded
16338 ),
16339 CommitOutcomeStatus::Rejected => {
16340 matches!(
16341 input.resolution,
16342 CommitOutcomeResolution::Superseded
16343 | CommitOutcomeResolution::ResolvedKeepServer
16344 )
16345 }
16346 CommitOutcomeStatus::Applied | CommitOutcomeStatus::Cached => {
16347 input.resolution == CommitOutcomeResolution::Dismissed
16348 }
16349 };
16350 if !allowed {
16351 return Err(format!(
16352 "sync.invalid_request: resolution {:?} is invalid for {:?} outcome",
16353 input.resolution, current.status
16354 ));
16355 }
16356
16357 self.begin_observation("syncular_outcome_resolution")?;
16358 let previous_failed = self.failed_commits.clone();
16359 let previous_conflicts = self.conflicts.clone();
16360 let previous_rejections = self.rejections.clone();
16361 let result = (|| {
16362 self.conn
16363 .execute(
16364 "UPDATE _syncular_commit_outcomes
16365 SET resolution = ?1, resolved_at_ms = ?2,
16366 replacement_client_commit_id = ?3
16367 WHERE client_commit_id = ?4 AND resolution = 'active'",
16368 rusqlite::params![
16369 Self::outcome_resolution_name(input.resolution),
16370 self.clock_now_ms(),
16371 input.replacement_client_commit_id,
16372 input.client_commit_id
16373 ],
16374 )
16375 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16376 let resolved = self
16377 .commit_outcome(¤t.client_commit_id)?
16378 .ok_or_else(|| "sync.outcome_not_found: outcome disappeared".to_owned())?;
16379 self.conn
16380 .execute(
16381 "DELETE FROM _syncular_failed_commits WHERE client_commit_id = ?1",
16382 rusqlite::params![input.client_commit_id],
16383 )
16384 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16385 for (commit, _) in &self.failed_commits {
16386 if commit.client_commit_id == input.client_commit_id {
16387 for operation in &commit.ops {
16388 self.overlay_dirty.table(&operation.table);
16389 }
16390 }
16391 }
16392 self.failed_commits
16393 .retain(|(commit, _)| commit.client_commit_id != input.client_commit_id);
16394 self.prune_commit_outcomes()?;
16395 Ok(resolved)
16396 })();
16397 let resolved = match result {
16398 Ok(outcome) => outcome,
16399 Err(error) => {
16400 self.rollback_observation("syncular_outcome_resolution");
16401 self.failed_commits = previous_failed;
16402 self.conflicts = previous_conflicts;
16403 self.rejections = previous_rejections;
16404 return Err(error);
16405 }
16406 };
16407 self.conflicts
16408 .retain(|record| record.client_commit_id != current.client_commit_id);
16409 self.rejections
16410 .retain(|record| record.client_commit_id != current.client_commit_id);
16411 let mut batch = ChangeAccumulator {
16412 conflicts: current.status == CommitOutcomeStatus::Conflict,
16413 rejections: current.status == CommitOutcomeStatus::Rejected,
16414 outcomes: true,
16415 ..ChangeAccumulator::default()
16416 };
16417 if let Some(operations) = ¤t.operations {
16418 for operation in operations {
16419 batch.table(&operation.table);
16420 }
16421 }
16422 if let Err(error) = self
16423 .rebuild_overlay_if_dirty()
16424 .and_then(|()| self.finish_observation("syncular_outcome_resolution", batch))
16425 {
16426 self.rollback_observation("syncular_outcome_resolution");
16427 self.failed_commits = previous_failed;
16428 self.conflicts = previous_conflicts;
16429 self.rejections = previous_rejections;
16430 return Err(error);
16431 }
16432 Ok(resolved)
16433 }
16434
16435 pub fn schema_floor(&self) -> Option<&SchemaFloor> {
16436 self.schema_floor.as_ref()
16437 }
16438
16439 pub fn lease_state(&self) -> Option<&LeaseState> {
16441 self.lease_state.as_ref()
16442 }
16443
16444 fn record_lease_error(&mut self, code: &str) {
16447 if code != "sync.auth_lease_required" && code != "sync.auth_lease_revoked" {
16448 return;
16449 }
16450 let mut next = self.lease_state.clone().unwrap_or_default();
16451 next.error_code = Some(code.to_owned());
16452 self.set_lease_state(Some(next));
16453 }
16454
16455 fn set_lease_state(&mut self, next: Option<LeaseState>) {
16456 if self.lease_state == next {
16457 return;
16458 }
16459 if self.begin_observation("syncular_lease").is_err() {
16460 return;
16461 }
16462 self.lease_state = next;
16463 if let Some(lease) = &self.lease_state {
16464 if let Ok(json) = serde_json::to_string(lease) {
16465 self.set_meta(LEASE_STATE_KEY, &json);
16466 }
16467 } else {
16468 self.delete_meta(LEASE_STATE_KEY);
16469 }
16470 let batch = ChangeAccumulator {
16471 status: true,
16472 ..ChangeAccumulator::default()
16473 };
16474 if self.finish_observation("syncular_lease", batch).is_err() {
16475 self.rollback_observation("syncular_lease");
16476 }
16477 }
16478
16479 fn set_schema_floor(&mut self, next: Option<SchemaFloor>) {
16480 if self.schema_floor == next {
16481 return;
16482 }
16483 if self.begin_observation("syncular_schema_floor").is_err() {
16484 return;
16485 }
16486 self.schema_floor = next;
16487 self.stopped = self.schema_floor.is_some();
16488 if let Some(floor) = &self.schema_floor {
16489 if let Ok(json) = serde_json::to_string(floor) {
16490 self.set_meta(SCHEMA_FLOOR_KEY, &json);
16491 }
16492 } else {
16493 self.delete_meta(SCHEMA_FLOOR_KEY);
16494 }
16495 let batch = ChangeAccumulator {
16496 status: true,
16497 ..ChangeAccumulator::default()
16498 };
16499 if self
16500 .finish_observation("syncular_schema_floor", batch)
16501 .is_err()
16502 {
16503 self.rollback_observation("syncular_schema_floor");
16504 }
16505 }
16506
16507 fn set_upgrading(&mut self, value: bool) {
16508 if self.upgrading == value {
16509 return;
16510 }
16511 if self.begin_observation("syncular_upgrading").is_err() {
16512 return;
16513 }
16514 self.upgrading = value;
16515 let batch = ChangeAccumulator {
16516 status: true,
16517 ..ChangeAccumulator::default()
16518 };
16519 if self
16520 .finish_observation("syncular_upgrading", batch)
16521 .is_err()
16522 {
16523 self.rollback_observation("syncular_upgrading");
16524 }
16525 }
16526
16527 pub fn sync_needed(&self) -> bool {
16528 self.sync_needed
16529 }
16530
16531 pub fn subscription_state(&self, id: &str) -> Option<SubscriptionStateView> {
16532 let sub = self.subs.iter().find(|s| s.id == id)?;
16533 Some(SubscriptionStateView {
16534 id: sub.id.clone(),
16535 table: sub.table.clone(),
16536 status: sub.state.name().to_owned(),
16537 cursor: sub.cursor,
16538 has_resume_token: sub.bootstrap_state.is_some(),
16539 effective_scopes: sub.effective.as_ref().map(|e| scope_map_to_json(e)),
16540 reason_code: sub.reason_code.clone(),
16541 })
16542 }
16543
16544 pub fn read_rows(&self, table: &str) -> Result<Vec<RowState>, String> {
16545 let schema_table = self
16546 .schema
16547 .table(table)
16548 .ok_or_else(|| format!("unknown table {table:?}"))?;
16549 let sql = format!(
16550 "SELECT * FROM {} ORDER BY {} ASC",
16551 visible_table(table),
16552 quote_ident(&schema_table.primary_key)
16553 );
16554 let mut stmt = self
16555 .conn
16556 .prepare(&sql)
16557 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16558 let mut rows = stmt
16559 .query([])
16560 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16561 let mut out = Vec::new();
16562 while let Some(row) = rows
16563 .next()
16564 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16565 {
16566 let mut values = Map::new();
16567 for (i, column) in schema_table.columns.iter().enumerate() {
16568 let value = row
16569 .get_ref(i)
16570 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16571 values.insert(column.name.clone(), sql_ref_to_json(column, value));
16572 }
16573 let version: i64 = row
16574 .get(schema_table.columns.len())
16575 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
16576 let row_id = match values.get(&schema_table.primary_key) {
16577 Some(Value::String(s)) => s.clone(),
16578 Some(Value::Number(n)) => n.to_string(),
16579 Some(Value::Bool(b)) => b.to_string(),
16580 other => format!("{}", other.cloned().unwrap_or(Value::Null)),
16581 };
16582 out.push(RowState {
16583 row_id,
16584 version,
16585 values,
16586 });
16587 }
16588 Ok(out)
16589 }
16590
16591 #[cfg(feature = "crdt-yjs")]
16607 fn crdt_column_bytes(
16608 &self,
16609 table: &str,
16610 row_id: &str,
16611 column: &str,
16612 ) -> Result<Option<Vec<u8>>, String> {
16613 let schema_table = self
16614 .schema
16615 .table(table)
16616 .ok_or_else(|| format!("unknown table {table:?}"))?;
16617 let col = schema_table
16618 .columns
16619 .iter()
16620 .find(|c| c.name == column)
16621 .ok_or_else(|| format!("table {table:?} has no column {column:?}"))?;
16622 if col.ty != ColumnType::Crdt {
16623 return Err(format!("column {column:?} is not a crdt column (§5.10.1)"));
16624 }
16625 let sql = format!(
16626 "SELECT {} FROM {} WHERE {}",
16627 quote_ident(column),
16628 visible_table(table),
16629 row_id_predicate(schema_table)
16630 );
16631 let bytes: Option<Vec<u8>> = self
16632 .conn
16633 .prepare_cached(&sql)
16634 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
16635 .query_row(rusqlite::params![row_id], |row| {
16636 row.get::<_, Option<Vec<u8>>>(0)
16637 })
16638 .map_err(|e| match e {
16639 rusqlite::Error::QueryReturnedNoRows => "no such row".to_owned(),
16640 other => Self::sqlite_failure(&self.storage_failure, other),
16641 })?;
16642 Ok(bytes)
16643 }
16644
16645 #[cfg(feature = "crdt-yjs")]
16649 pub fn crdt_text(
16650 &self,
16651 table: &str,
16652 row_id: &str,
16653 column: &str,
16654 name: &str,
16655 ) -> Result<String, String> {
16656 let bytes = self
16657 .crdt_column_bytes(table, row_id, column)?
16658 .unwrap_or_default();
16659 crate::crdt::text(&bytes, name)
16660 }
16661
16662 #[cfg(feature = "crdt-yjs")]
16666 pub fn crdt_insert_text(
16667 &mut self,
16668 table: &str,
16669 row_id: &str,
16670 column: &str,
16671 name: &str,
16672 index: u32,
16673 value: &str,
16674 ) -> Result<String, ClientError> {
16675 let prior = self.storage_failure.borrow_mut().take();
16676 let outcome = (|| -> Result<String, String> {
16677 let current = self
16678 .crdt_column_bytes(table, row_id, column)?
16679 .unwrap_or_default();
16680 let update = crate::crdt::insert_text(¤t, name, index, value)?;
16681 self.perform_crdt_push_update(table, row_id, column, &update)
16682 })();
16683 self.finish_authoring(prior, outcome)
16684 }
16685
16686 #[cfg(feature = "crdt-yjs")]
16690 pub fn crdt_delete_text(
16691 &mut self,
16692 table: &str,
16693 row_id: &str,
16694 column: &str,
16695 name: &str,
16696 index: u32,
16697 len: u32,
16698 ) -> Result<String, ClientError> {
16699 let prior = self.storage_failure.borrow_mut().take();
16700 let outcome = (|| -> Result<String, String> {
16701 let current = self
16702 .crdt_column_bytes(table, row_id, column)?
16703 .unwrap_or_default();
16704 let update = crate::crdt::delete_text(¤t, name, index, len)?;
16705 self.perform_crdt_push_update(table, row_id, column, &update)
16706 })();
16707 self.finish_authoring(prior, outcome)
16708 }
16709
16710 #[cfg(feature = "crdt-yjs")]
16715 pub fn crdt_apply_update(
16716 &mut self,
16717 table: &str,
16718 row_id: &str,
16719 column: &str,
16720 update: &[u8],
16721 ) -> Result<String, ClientError> {
16722 let prior = self.storage_failure.borrow_mut().take();
16723 let outcome = (|| -> Result<String, String> {
16724 let current = self
16725 .crdt_column_bytes(table, row_id, column)?
16726 .unwrap_or_default();
16727 let next = crate::crdt::apply_update(¤t, update)?;
16728 self.perform_crdt_push_update(table, row_id, column, &next)
16729 })();
16730 self.finish_authoring(prior, outcome)
16731 }
16732
16733 #[cfg(feature = "crdt-yjs")]
16740 fn perform_crdt_push_update(
16741 &mut self,
16742 table: &str,
16743 row_id: &str,
16744 column: &str,
16745 crdt_bytes: &[u8],
16746 ) -> Result<String, String> {
16747 let mut bytes_obj = Map::new();
16748 bytes_obj.insert("$bytes".to_owned(), Value::from(bytes_to_hex(crdt_bytes)));
16749 let mut partial = Map::new();
16750 partial.insert(column.to_owned(), Value::Object(bytes_obj));
16751 self.perform_patch(table, row_id, partial, None)
16752 }
16753
16754 pub fn query(&self, sql: &str, params: &[QueryValue]) -> Result<Vec<QueryRow>, String> {
16769 query_connection(&self.conn, sql, params).map_err(|failure| failure.to_string())
16770 }
16771
16772 #[cfg(feature = "bench-internals")]
16774 #[doc(hidden)]
16775 pub fn benchmark_connection(&mut self) -> &mut Connection {
16776 &mut self.conn
16777 }
16778
16779 #[cfg(feature = "bench-internals")]
16781 #[doc(hidden)]
16782 pub fn benchmark_phases(
16783 &self,
16784 enabled: Option<bool>,
16785 reset: bool,
16786 ) -> Result<Option<Value>, String> {
16787 self.benchmark_phases.configure(enabled, reset)?;
16788 Ok(self.benchmark_phases.snapshot())
16789 }
16790
16791 pub fn authority_snapshot(&self) -> Result<Value, String> {
16793 validate_authority_reads(&self.schema, &self.limits.authority_reads)?;
16794 if self.limits.authority_reads.is_empty() {
16795 return Err("client.authority_read_forbidden: no authority reads were declared at client creation".into());
16796 }
16797 self.conn
16798 .execute_batch("SAVEPOINT authority_snapshot")
16799 .map_err(|e| e.to_string())?;
16800 let result = (|| {
16801 let revision = self
16802 .get_meta(LOCAL_REVISION_KEY)
16803 .ok_or("sync.local_corrupt: authority revision is missing")?;
16804 let parsed_revision = revision
16805 .parse::<u64>()
16806 .map_err(|_| "sync.local_corrupt: authority revision is invalid")?;
16807 if parsed_revision.to_string() != revision {
16808 return Err("sync.local_corrupt: authority revision is invalid".into());
16809 }
16810 let mut tables = Vec::new();
16811 for read in &self.limits.authority_reads {
16812 let table = self
16813 .schema
16814 .table(&read.table)
16815 .ok_or("client.authority_read_forbidden: authority table is not declared")?;
16816 let scalar = |column: &str, value: &Value| -> Result<String, String> {
16817 let ty = table
16818 .columns
16819 .iter()
16820 .find(|c| c.name == column)
16821 .expect("validated authority column")
16822 .ty;
16823 if ty == ColumnType::Boolean {
16824 if let Some(value) = value.as_bool() {
16825 return Ok(value.to_string());
16826 }
16827 return match value.as_i64() {
16828 Some(0) => Ok("false".into()),
16829 Some(1) => Ok("true".into()),
16830 _ => Err("sync.local_corrupt: authority boolean is invalid".into()),
16831 };
16832 }
16833 match value {
16834 Value::String(s) => Ok(s.clone()),
16835 Value::Number(n) => Ok(n.to_string()),
16836 Value::Object(o) if ty == ColumnType::Integer && o.len() == 1 => o
16837 .get("$bigint")
16838 .and_then(Value::as_str)
16839 .map(str::to_owned)
16840 .ok_or("sync.local_corrupt: authority integer is invalid".into()),
16841 _ => {
16842 Err("sync.local_corrupt: authority scalar is missing or invalid".into())
16843 }
16844 }
16845 };
16846 let matches = |row: &QueryRow| -> Result<bool, String> {
16847 for (variable, values) in &read.scopes {
16848 let column = table
16849 .scope_column(variable)
16850 .expect("validated authority scope");
16851 let value = row
16852 .get(column)
16853 .ok_or("sync.local_corrupt: authority scope column is missing")?;
16854 if value.is_null() || !values.contains(&scalar(column, value)?) {
16855 return Ok(false);
16856 }
16857 }
16858 Ok(true)
16859 };
16860 let selected = read
16861 .columns
16862 .iter()
16863 .map(|c| quote_ident(c))
16864 .collect::<Vec<_>>()
16865 .join(",");
16866 let mut intent = BTreeSet::new();
16867 let mut relevant = BTreeSet::new();
16868 for row in query_connection(&self.conn,
16869 "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=?",
16870 &[Value::String(read.table.clone())]).map_err(|e| e.to_string())? {
16871 let id = row.get("id").and_then(Value::as_str).ok_or("sync.local_corrupt: authority intent id is invalid")?.to_owned();
16872 intent.insert(id.clone());
16873 if let Some(raw) = row.get("values_json").and_then(Value::as_str) {
16874 let values: QueryRow = serde_json::from_str(raw).map_err(|_| "sync.local_corrupt: authority intent values are invalid")?;
16875 if read.scopes.keys().all(|v| values.contains_key(table.scope_column(v).expect("validated scope"))) && matches(&values)? {relevant.insert(id);}
16876 }
16877 }
16878 let mut rows = BTreeMap::new();
16879 for full in [base_table(&read.table), visible_table(&read.table)] {
16880 for mut values in query_connection(
16881 &self.conn,
16882 &format!(
16883 "SELECT {selected},_syncular_version AS authority_version FROM {full}"
16884 ),
16885 &[],
16886 )
16887 .map_err(|e| e.to_string())?
16888 {
16889 if !matches(&values)? {
16890 continue;
16891 }
16892 let id = scalar(
16893 &table.primary_key,
16894 values
16895 .get(&table.primary_key)
16896 .expect("selected primary key"),
16897 )?;
16898 relevant.insert(id.clone());
16899 if full == base_table(&read.table) {
16900 let version = values
16901 .remove("authority_version")
16902 .expect("selected version");
16903 let version = version
16904 .as_i64()
16905 .ok_or("sync.local_corrupt: authority server version is invalid")?;
16906 if version >= 0 {
16907 rows.insert(id.clone(), json!({"values": values, "version": version, "hasLocalIntent": intent.contains(&id)}));
16908 }
16909 }
16910 }
16911 }
16912 intent.retain(|id| relevant.contains(id));
16913 let rows = rows.into_values().collect::<Vec<_>>();
16914 let mut persisted = Vec::new();
16915 for row in query_connection(
16916 &self.conn,
16917 "SELECT state_json FROM _syncular_subscriptions WHERE tbl=? ORDER BY rowid",
16918 &[Value::String(read.table.clone())],
16919 )
16920 .map_err(|e| e.message)?
16921 {
16922 let state: Value = serde_json::from_str(
16923 row.get("state_json")
16924 .and_then(Value::as_str)
16925 .ok_or("sync.local_corrupt: authority subscription state is missing")?,
16926 )
16927 .map_err(|_| "sync.local_corrupt: authority subscription state is invalid")?;
16928 let effective = state.get("effectiveScopes").and_then(Value::as_object);
16929 let status = state
16930 .get("status")
16931 .and_then(Value::as_str)
16932 .ok_or("sync.local_corrupt: authority subscription status is missing")?;
16933 let cursor = state
16934 .get("cursor")
16935 .and_then(Value::as_i64)
16936 .ok_or("sync.local_corrupt: authority subscription cursor is missing")?;
16937 let complete = effective.is_some_and(|s| s.len() == read.scopes.len())
16938 && status == "active"
16939 && cursor >= 0
16940 && state.get("bootstrapState").is_none_or(Value::is_null)
16941 && state.get("params").is_none_or(Value::is_null);
16942 let exposed = effective.map(|s| {
16943 read.scopes
16944 .keys()
16945 .map(|v| (v.clone(), s.get(v).cloned().unwrap_or(json!([]))))
16946 .collect::<Map<_, _>>()
16947 });
16948 let requested = read
16949 .scopes
16950 .keys()
16951 .map(|v| {
16952 (
16953 v.clone(),
16954 state
16955 .get("requested")
16956 .and_then(|s| s.get(v))
16957 .cloned()
16958 .unwrap_or(json!([])),
16959 )
16960 })
16961 .collect::<Map<_, _>>();
16962 persisted.push(json!({"requestedScopes": requested, "status": status, "cursor": cursor, "effectiveScopes": exposed, "complete": complete}));
16963 }
16964 fn covered(selectors: &[(&String, &Vec<String>)], candidates: &[&Value]) -> bool {
16965 match selectors.split_first() {
16966 None => !candidates.is_empty(),
16967 Some(((variable, values), rest)) => values.iter().all(|value| {
16968 covered(
16969 rest,
16970 &candidates
16971 .iter()
16972 .copied()
16973 .filter(|p| {
16974 p["effectiveScopes"][variable.as_str()]
16975 .as_array()
16976 .is_some_and(|held| {
16977 held.contains(&Value::String(value.clone()))
16978 || held.contains(&Value::String("*".into()))
16979 })
16980 })
16981 .collect::<Vec<_>>(),
16982 )
16983 }),
16984 }
16985 }
16986 let coverage = if covered(
16987 &read.scopes.iter().collect::<Vec<_>>(),
16988 &persisted
16989 .iter()
16990 .filter(|p| p["complete"] == true)
16991 .collect::<Vec<_>>(),
16992 ) {
16993 "complete"
16994 } else if persisted.iter().any(|p| {
16995 p["status"] == "active"
16996 && read.scopes.iter().all(|(v, values)| {
16997 values.iter().any(|value| {
16998 p["requestedScopes"][v].as_array().is_some_and(|held| {
16999 held.contains(&Value::String(value.clone()))
17000 || held.contains(&Value::String("*".into()))
17001 })
17002 })
17003 })
17004 }) {
17005 "pending"
17006 } else {
17007 "missing"
17008 };
17009 tables.push(json!({"table": read.table, "rows": rows, "localIntentRowIds": intent, "scopes": read.scopes, "coverage": coverage, "persisted": persisted}));
17010 }
17011 Ok(
17012 json!({"revision": revision, "complete": tables.iter().all(|t| t["coverage"] == "complete"), "tables": tables}),
17013 )
17014 })();
17015 match result {
17016 Ok(snapshot) => {
17017 self.conn
17018 .execute_batch("RELEASE authority_snapshot")
17019 .map_err(|e| e.to_string())?;
17020 Ok(snapshot)
17021 }
17022 Err(error) => {
17023 self.conn
17024 .execute_batch("ROLLBACK TO authority_snapshot; RELEASE authority_snapshot")
17025 .map_err(|e| format!("{error}; rollback failed: {e}"))?;
17026 Err(error)
17027 }
17028 }
17029 }
17030
17031 pub fn query_snapshot(
17035 &mut self,
17036 sql: &str,
17037 params: &[QueryValue],
17038 coverage: &[WindowCoverage],
17039 owner: Option<QueryOwner<'_>>,
17040 ) -> Result<QuerySnapshot, String> {
17041 let result = snapshot_connection(&self.conn, sql, params, coverage);
17042 if let Some(owner) = owner {
17043 self.record_query_read(owner, result.as_ref().err());
17044 }
17045 result.map_err(|failure| failure.to_string())
17046 }
17047
17048 pub fn record_query_read(&mut self, owner: QueryOwner<'_>, failure: Option<&QueryReadFailure>) {
17052 let position = self
17053 .query_failures
17054 .iter()
17055 .position(|entry| entry.id == owner.id);
17056 let Some(failure) = failure else {
17057 if let Some(index) = position {
17058 self.query_failures.remove(index);
17059 }
17060 return;
17061 };
17062 let tables: Vec<String> = BTreeSet::from_iter(owner.tables.iter().copied())
17063 .into_iter()
17064 .map(str::to_owned)
17065 .collect();
17066 let code = failure.code.unwrap_or("client.query_failed").to_owned();
17067 if position.is_some_and(|index| {
17068 let entry = &self.query_failures[index];
17069 entry.tables == tables && entry.code == code && entry.sqlite_code == failure.sqlite_code
17070 }) {
17071 return;
17072 }
17073 if let Some(index) = position {
17074 self.query_failures.remove(index);
17075 }
17076 self.query_failures.push(DiagnosticQueryFailure {
17077 id: owner.id.to_owned(),
17078 tables,
17079 code,
17080 sqlite_code: failure.sqlite_code,
17081 at_ms: self.clock_now_ms(),
17082 });
17083 if self.query_failures.len() > MAX_DIAGNOSTIC_QUERY_FAILURES {
17084 self.query_failures.remove(0);
17085 }
17086 }
17087
17088 fn build_request(&self, url_capable: bool) -> Result<(Message, RequestMeta), Box<SyncOutcome>> {
17091 #[cfg(feature = "bench-internals")]
17092 let _phase = self.benchmark_phases.start(Phase::RequestPrepare);
17093 let log_epoch = self.get_meta(LOG_EPOCH_KEY);
17094 let max_commits = self.limits.max_push_commits_per_request;
17095 let max_ops = self
17096 .limits
17097 .max_push_operations_per_request
17098 .unwrap_or(PUSH_OPS_PER_REQUEST);
17099 let byte_cap = self.limits.max_push_request_bytes;
17100 let accept = self.limits.accept.unwrap_or(if url_capable {
17103 DEFAULT_ACCEPT | ACCEPT_SIGNED_URLS
17104 } else {
17105 DEFAULT_ACCEPT
17106 });
17107 let header = Frame::ReqHeader {
17108 client_id: self.client_id.clone(),
17109 schema_version: self.schema.version,
17110 log_epoch: log_epoch.clone(),
17111 };
17112 let mut lead = vec![header];
17116 lead.extend(
17117 self.round_failure
17118 .iter()
17119 .map(|code| Frame::RoundFailure { code: code.clone() }),
17120 );
17121 let pull = Frame::PullHeader {
17122 limit_commits: self.limits.limit_commits.unwrap_or(0),
17123 limit_snapshot_rows: self.limits.limit_snapshot_rows.unwrap_or(0),
17124 max_snapshot_pages: self.limits.max_snapshot_pages.unwrap_or(0),
17125 accept,
17126 };
17127 let mut fresh = Vec::new();
17128 let mut sub_frames = Vec::new();
17129 for sub in &self.subs {
17130 if sub.state != SubState::Active {
17131 continue;
17132 }
17133 let mut scopes = sub.requested.clone();
17134 sort_scope_map(&mut scopes);
17135 sub_frames.push(Frame::Subscription {
17136 id: sub.id.clone(),
17137 table: sub.table.clone(),
17138 scopes,
17139 params: sub.params.clone().map(RawJson),
17140 cursor: sub.cursor,
17141 bootstrap_state: sub.bootstrap_state.clone().map(RawJson),
17142 });
17143 fresh.push((
17144 sub.id.clone(),
17145 sub.cursor < 0 && sub.bootstrap_state.is_none(),
17146 ));
17147 }
17148 let fixed_bytes = match byte_cap {
17151 Some(_) => {
17152 let mut frames = lead.clone();
17153 frames.push(pull.clone());
17154 frames.extend(sub_frames.iter().cloned());
17155 encode_message(&Message {
17156 wire_version: WIRE_VERSION,
17157 msg_kind: MsgKind::Request,
17158 frames,
17159 })
17160 .len()
17161 }
17162 None => 0,
17163 };
17164 if let Some(cap) = byte_cap {
17165 if fixed_bytes > cap {
17166 return Err(Self::push_capacity_outcome("bytes", cap, fixed_bytes, None));
17167 }
17168 }
17169 let base_bytes = match byte_cap {
17170 Some(_) => {
17171 let mut frames = lead.clone();
17172 frames.push(pull.clone());
17173 encode_message(&Message {
17174 wire_version: WIRE_VERSION,
17175 msg_kind: MsgKind::Request,
17176 frames,
17177 })
17178 .len()
17179 }
17180 None => 0,
17181 };
17182 let total_outbox = if log_epoch.is_some() {
17183 self.outbox.len()
17184 } else {
17185 0
17186 };
17187 let mut push_frames: Vec<Frame> = Vec::new();
17188 let mut pushed_ids = Vec::new();
17189 let mut ops_in_request = 0usize;
17190 let mut bytes_in_request = 0usize;
17191 let mut deferred_commits = 0usize;
17192 #[cfg(feature = "bench-internals")]
17193 let outbox_phase = self.benchmark_phases.start(Phase::OutboxEncode);
17194 for (index, commit) in self.outbox.iter().take(total_outbox).enumerate() {
17195 if max_commits.is_some_and(|cap| push_frames.len() >= cap) {
17199 deferred_commits = total_outbox - index;
17200 break;
17201 }
17202 if ops_in_request + commit.ops.len() > max_ops {
17203 if push_frames.is_empty() {
17204 return Err(Self::push_capacity_outcome(
17205 "operations",
17206 max_ops,
17207 commit.ops.len(),
17208 Some(commit.client_commit_id.as_str()),
17209 ));
17210 }
17211 deferred_commits = total_outbox - index;
17212 break;
17213 }
17214 let operations = commit
17215 .ops
17216 .iter()
17217 .map(|op| {
17218 let payload = self.schema.table(&op.table).and_then(|table| {
17219 let values = op.presented_values(table)?;
17220 let fallback = self.stored_key_fallback(table, &op.row_id);
17230 encode_sparse_row_json(
17231 table,
17232 &op.row_id,
17233 &values,
17234 &self.encryption,
17235 fallback.as_ref(),
17236 )
17237 .ok()
17238 });
17239 ssp2::model::Operation {
17240 table: op.table.clone(),
17241 row_id: op.row_id.clone(),
17242 op: if op.upsert { Op::Upsert } else { Op::Delete },
17243 base_version: op.base_version,
17244 payload,
17245 }
17246 })
17247 .collect();
17248 let frame = Frame::PushCommit {
17249 client_commit_id: commit.client_commit_id.clone(),
17250 operations,
17251 };
17252 let frame_bytes = match byte_cap {
17253 Some(_) => {
17254 let mut frames = lead.clone();
17255 frames.push(frame.clone());
17256 frames.push(pull.clone());
17257 encode_message(&Message {
17258 wire_version: WIRE_VERSION,
17259 msg_kind: MsgKind::Request,
17260 frames,
17261 })
17262 .len()
17263 - base_bytes
17264 }
17265 None => 0,
17266 };
17267 if let Some(cap) = byte_cap {
17271 if !push_frames.is_empty() && fixed_bytes + bytes_in_request + frame_bytes > cap {
17272 deferred_commits = total_outbox - index;
17273 break;
17274 }
17275 }
17276 push_frames.push(frame);
17277 pushed_ids.push(commit.client_commit_id.clone());
17278 ops_in_request += commit.ops.len();
17279 bytes_in_request += frame_bytes;
17280 }
17281 #[cfg(feature = "bench-internals")]
17282 drop(outbox_phase);
17283 if let Some(cap) = byte_cap {
17284 if fixed_bytes + bytes_in_request > cap {
17285 let blocked = if push_frames.is_empty() {
17288 None
17289 } else {
17290 self.outbox.first().map(|c| c.client_commit_id.as_str())
17291 };
17292 return Err(Self::push_capacity_outcome(
17293 "bytes",
17294 cap,
17295 fixed_bytes + bytes_in_request,
17296 blocked,
17297 ));
17298 }
17299 }
17300 let mut frames = lead;
17301 frames.extend(push_frames);
17302 frames.push(pull);
17303 frames.extend(sub_frames);
17304 let message = Message {
17305 wire_version: WIRE_VERSION,
17306 msg_kind: MsgKind::Request,
17307 frames,
17308 };
17309 Ok((
17310 message,
17311 RequestMeta {
17312 pushed_ids,
17313 fresh,
17314 accept,
17315 deferred_commits,
17316 },
17317 ))
17318 }
17319
17320 fn push_capacity_outcome(
17322 kind: &str,
17323 limit: usize,
17324 size: usize,
17325 client_commit_id: Option<&str>,
17326 ) -> Box<SyncOutcome> {
17327 let mut details = Map::new();
17328 details.insert("kind".to_owned(), Value::from(kind));
17329 details.insert("limit".to_owned(), Value::from(limit as u64));
17330 details.insert("size".to_owned(), Value::from(size as u64));
17331 if let Some(id) = client_commit_id {
17332 details.insert("clientCommitId".to_owned(), Value::from(id));
17333 }
17334 Box::new(SyncOutcome::Failed {
17335 error_code: "client.push_request_too_large".into(),
17336 message: "the push request exceeds a configured capacity limit".into(),
17337 details: Some(Value::Object(details)),
17338 recommended_action: None,
17339 })
17340 }
17341
17342 pub fn progress(&self) -> ProgressObserver {
17346 self.progress.clone()
17347 }
17348
17349 pub fn sync(&mut self, transport: &mut dyn Transport) -> SyncOutcome {
17350 let prepared = match self.prepare_sync_round(transport.supports_url_fetch()) {
17351 Ok(prepared) => prepared,
17352 Err(outcome) => return *outcome,
17353 };
17354 let mut next = prepared;
17359 loop {
17360 match self.apply_sync_round(next.exchange(transport)) {
17361 crate::AppliedSyncRound::Continue(prepared) => next = prepared,
17362 crate::AppliedSyncRound::Complete {
17363 outcome, controls, ..
17364 } => {
17365 if self.transport_enabled {
17366 for text in controls {
17367 let _ = transport.realtime_send(&text);
17368 }
17369 } else {
17370 self.disconnect_realtime(transport);
17371 }
17372 return outcome;
17373 }
17374 }
17375 }
17376 }
17377
17378 fn finish_sync_round(&mut self, started_at_ms: i64, mut outcome: SyncOutcome) -> SyncOutcome {
17379 if let SyncOutcome::Failed {
17380 error_code,
17381 message,
17382 details,
17383 ..
17384 } = &mut outcome
17385 {
17386 if let Some(failure) = self.storage_failure.borrow().as_ref() {
17387 let code = failure
17388 .code
17389 .expect("retained storage failures are classified");
17390 *error_code = code.to_owned();
17391 *message = match code {
17392 "client.storage_busy" => "local SQLite storage is busy",
17393 "client.storage_full" => "local SQLite storage is full",
17394 "client.storage_io" => "local SQLite storage I/O failed",
17395 "client.storage_corrupt" => "local SQLite storage is corrupt",
17396 _ => unreachable!("unrecognized classified storage failure"),
17397 }
17398 .to_owned();
17399 *details = failure.details();
17400 }
17401 }
17402 let retry_delay_ms = self.round_retry_delay_ms;
17403 self.progress.update(|p| match &outcome {
17404 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report)
17405 if report.failed.is_empty() =>
17406 {
17407 p.state = ProgressState::Complete
17408 }
17409 SyncOutcome::Ok(_) | SyncOutcome::BudgetExhausted(_) => {
17410 p.state = ProgressState::Failed;
17411 p.error_code = Some("sync.scope_revoked".into());
17412 }
17413 SyncOutcome::Failed { error_code, .. } => {
17414 p.state = ProgressState::Failed;
17415 p.error_code = Some(Self::diagnostic_code(error_code));
17416 p.retry_delay_ms = retry_delay_ms;
17417 }
17418 SyncOutcome::RealtimeUnavailable { .. } => {
17419 p.state = ProgressState::Failed;
17420 p.error_code = Some(REALTIME_UNAVAILABLE_CODE.to_owned());
17421 p.retry_delay_ms = retry_delay_ms;
17422 }
17423 });
17424 let completed_at_ms = self.clock_now_ms();
17425 self.last_round = Some(match &outcome {
17426 SyncOutcome::Ok(report) | SyncOutcome::BudgetExhausted(report) => DiagnosticLastRound {
17427 status: "succeeded".to_owned(),
17428 started_at_ms,
17429 completed_at_ms,
17430 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17431 counters: Some(DiagnosticRoundCounters {
17432 pushed: report.pushed,
17433 applied: report.applied.len(),
17434 rejected: report.rejected.len(),
17435 retryable: report.retryable.len(),
17436 conflicts: report.conflicts,
17437 commits_applied: report.commits_applied,
17438 segment_rows_applied: report.segment_rows_applied,
17439 bootstrapping: report.bootstrapping.len(),
17440 resets: report.resets.len(),
17441 revoked: report.revoked.len(),
17442 failed: report.failed.len(),
17443 deferred_commits: report.deferred_commits,
17444 }),
17445 error_code: None,
17446 },
17447 SyncOutcome::Failed { error_code, .. } => DiagnosticLastRound {
17448 status: "failed".to_owned(),
17449 started_at_ms,
17450 completed_at_ms,
17451 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17452 counters: None,
17453 error_code: Some(Self::diagnostic_code(error_code)),
17454 },
17455 SyncOutcome::RealtimeUnavailable { .. } => DiagnosticLastRound {
17456 status: "failed".to_owned(),
17457 started_at_ms,
17458 completed_at_ms,
17459 duration_ms: completed_at_ms.saturating_sub(started_at_ms).max(0),
17460 counters: None,
17461 error_code: Some(REALTIME_UNAVAILABLE_CODE.to_owned()),
17462 },
17463 });
17464 outcome
17465 }
17466
17467 pub fn prepare_sync_round(
17470 &mut self,
17471 url_capable: bool,
17472 ) -> Result<crate::PreparedSyncRound, Box<SyncOutcome>> {
17473 if !self.transport_enabled {
17474 return Err(Box::new(SyncOutcome::Failed {
17475 error_code: "sync.offline".into(),
17476 message: "transport gate is closed".into(),
17477 details: None,
17478 recommended_action: None,
17479 }));
17480 }
17481 if self.active_round.is_some() {
17482 return Err(Box::new(SyncOutcome::Failed {
17483 error_code: "client.round_in_progress".into(),
17484 message: "a sync round is already in flight".into(),
17485 details: None,
17486 recommended_action: None,
17487 }));
17488 }
17489 self.storage_failure.borrow_mut().take();
17490 self.progress.start();
17491 self.round_retry_delay_ms = None;
17492 let started_at_ms = self.clock_now_ms();
17493 let prepared = (|| {
17494 if self.stopped {
17495 return Err(Box::new(SyncOutcome::Ok(SyncReport {
17496 schema_floor: self.schema_floor.clone(),
17497 ..SyncReport::default()
17498 })));
17499 }
17500 if self.security_preflight {
17501 return Err(Box::new(SyncOutcome::Failed {
17502 error_code: SECURITY_PREFLIGHT_REQUIRED_CODE.into(),
17503 message: "security preflight must complete before syncing".into(),
17504 details: None,
17505 recommended_action: None,
17506 }));
17507 }
17508 self.set_sync_needed(false, false);
17509 self.drain_pending_evictions()
17510 .and_then(|_| self.drop_incompatible_outbox())
17511 .and_then(|_| self.drop_unencodable_outbox(true, None))
17512 .map_err(|message| {
17513 Box::new(SyncOutcome::Failed {
17514 error_code: "storage.failed".into(),
17515 message,
17516 details: None,
17517 recommended_action: None,
17518 })
17519 })?;
17520 let uploads = if self.get_meta(LOG_EPOCH_KEY).is_some() && self.schema_has_blobs() {
17521 self.pending_blob_uploads().map_err(|e| {
17522 Box::new(SyncOutcome::Failed {
17523 error_code: e.code,
17524 message: e.message,
17525 details: e.details,
17526 recommended_action: None,
17527 })
17528 })?
17529 } else {
17530 Vec::new()
17531 };
17532 if self.realtime_state != RealtimeState::Connected
17533 && self.realtime_policy == RealtimePolicy::Required
17534 {
17535 let retry_delay_ms = self.schedule_background_retry();
17536 self.realtime_retry_delay_ms = Some(retry_delay_ms);
17537 return Err(Box::new(SyncOutcome::RealtimeUnavailable {
17538 state: self.realtime_state,
17539 reason_code: self.realtime_reason_code.clone(),
17540 retry_delay_ms,
17541 }));
17542 }
17543 let (message, meta) = self.build_request(url_capable)?;
17544 let id = uuid::Uuid::new_v4();
17545 self.active_round = Some(id);
17546 let phase = if uploads.is_empty() {
17547 crate::round::RoundPhase::Sync
17548 } else {
17549 crate::round::RoundPhase::UploadGrant
17550 };
17551 Ok(crate::PreparedSyncRound {
17552 id,
17553 started_at_ms,
17554 message,
17555 meta,
17556 uploads: uploads.into(),
17557 phase,
17558 encoded: None,
17559 continuation: None,
17560 completed: None,
17561 realtime: self.realtime_state == RealtimeState::Connected,
17562 scopes: self
17563 .subs
17564 .iter()
17565 .map(|s| (s.id.clone(), canonical_scope_json(&s.requested)))
17566 .collect(),
17567 #[cfg(feature = "bench-internals")]
17568 benchmark_phases: self.benchmark_phases.clone(),
17569 progress: self.progress.clone(),
17570 fixed_now: self.now_ms,
17571 round_deadline_at: None,
17572 })
17573 })();
17574 prepared.map_err(|outcome| Box::new(self.finish_sync_round(started_at_ms, *outcome)))
17575 }
17576
17577 pub fn cancel_sync_round(&mut self) {
17580 self.active_round = None;
17581 }
17582
17583 pub fn apply_sync_round(
17586 &mut self,
17587 completed: crate::CompletedSyncRound,
17588 ) -> crate::AppliedSyncRound {
17589 let prepared = completed.prepared;
17590 if self.active_round != Some(prepared.id) || self.security_preflight {
17591 return crate::AppliedSyncRound::Complete {
17592 outcome: SyncOutcome::Failed {
17593 error_code: "client.round_cancelled".into(),
17594 message: "sync round belongs to a released client context".into(),
17595 details: None,
17596 recommended_action: None,
17597 },
17598 controls: Vec::new(),
17599 more: false,
17600 bootstrap_advanced: false,
17601 };
17602 }
17603 self.storage_failure.borrow_mut().take();
17604 let (response, mut downloads, transport_failed) = match completed.exchange {
17605 crate::round::ExchangeResult::Upload { id, result } => {
17606 let result = result.and_then(|()| {
17607 self.conn
17608 .execute(
17609 "DELETE FROM _syncular_blob_uploads WHERE blob_id = ?",
17610 rusqlite::params![id],
17611 )
17612 .map(|_| ())
17613 .map_err(|error| {
17614 TransportError::new(
17615 "client.failed",
17616 Self::sqlite_failure(&self.storage_failure, error),
17617 )
17618 })
17619 });
17620 match result {
17621 Ok(()) => return crate::AppliedSyncRound::Continue(prepared),
17622 Err(TransportError {
17623 code,
17624 message,
17625 details,
17626 }) => {
17627 self.active_round = None;
17628 if Self::retryable_failure_code(&code)
17629 && !Self::unreachable_transport(&details)
17630 {
17631 self.schedule_background_retry();
17632 }
17633 let recommended_action = recommended_action_of(&details);
17634 let outcome = self.finish_sync_round(
17635 prepared.started_at_ms,
17636 SyncOutcome::Failed {
17637 error_code: code,
17638 message,
17639 details,
17640 recommended_action,
17641 },
17642 );
17643 return crate::AppliedSyncRound::Complete {
17644 outcome,
17645 controls: Vec::new(),
17646 more: false,
17647 bootstrap_advanced: false,
17648 };
17649 }
17650 }
17651 }
17652 crate::round::ExchangeResult::Reply {
17653 response,
17654 downloads,
17655 transport_failed,
17656 } => (response, downloads, transport_failed),
17657 };
17658 self.active_round = None;
17659 let before = self
17660 .subs
17661 .iter()
17662 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17663 .collect::<Vec<_>>();
17664 let outcome = (|| {
17665 let response = match response {
17666 Ok(response) => {
17667 self.round_failure = None;
17670 response
17671 }
17672 Err(TransportError {
17673 code,
17674 message,
17675 details,
17676 }) => {
17677 if ssp2::model::valid_round_failure_code(&code) {
17682 self.round_failure = Some(code.clone());
17683 }
17684 if transport_failed {
17685 if prepared.realtime {
17686 self.set_realtime_state(RealtimeState::Lost, Some(&code));
17687 }
17688 self.record_lease_error(&code);
17689 }
17690 if Self::retryable_failure_code(&code) && !Self::unreachable_transport(&details)
17691 {
17692 let delay = self.schedule_background_retry();
17693 if prepared.realtime && transport_failed {
17694 self.realtime_retry_delay_ms = Some(delay);
17695 }
17696 }
17697 let recommended_action = recommended_action_of(&details);
17698 return SyncOutcome::Failed {
17699 error_code: code,
17700 message,
17701 details,
17702 recommended_action,
17703 };
17704 }
17705 };
17706 let mut outcome = self.process_response(&mut downloads, response, &prepared.meta);
17707 if let SyncOutcome::Ok(report) = &mut outcome {
17708 report.deferred_commits = prepared.meta.deferred_commits;
17709 }
17710 match &outcome {
17711 SyncOutcome::Ok(_) => self.reset_background_retry(),
17712 SyncOutcome::Failed {
17713 error_code,
17714 details,
17715 ..
17716 } if Self::retryable_failure_code(error_code)
17717 && !Self::unreachable_transport(details) =>
17718 {
17719 self.schedule_background_retry();
17720 }
17721 _ => {}
17722 }
17723 if prepared.meta.deferred_commits > 0 {
17724 self.set_sync_needed(true, true);
17725 }
17726 self.previous_version_lifetime_check();
17727 outcome
17728 })();
17729 let more = self.transport_enabled
17730 && matches!(&outcome, SyncOutcome::Ok(report)
17731 if report.schema_floor.is_none()
17732 && (!report.bootstrapping.is_empty()
17733 || report.commits_applied > 0
17734 || report.segment_rows_applied > 0
17735 || !report.resets.is_empty()
17736 || report.deferred_commits > 0
17737 || self.sync_needed));
17738 let bootstrap_advanced = matches!(&outcome, SyncOutcome::Ok(report) if report.segment_rows_applied > 0 && !report.bootstrapping.is_empty())
17739 && before
17740 != self
17741 .subs
17742 .iter()
17743 .map(|s| (s.id.clone(), s.bootstrap_state.clone()))
17744 .collect::<Vec<_>>();
17745 crate::AppliedSyncRound::Complete {
17746 outcome: self.finish_sync_round(prepared.started_at_ms, outcome),
17747 controls: if self.transport_enabled {
17748 downloads.controls
17749 } else {
17750 Vec::new()
17751 },
17752 more,
17753 bootstrap_advanced,
17754 }
17755 }
17756
17757 pub const SYNC_IDLE_TIMEOUT_MS: u64 = 60_000;
17759
17760 pub fn sync_until_idle(
17761 &mut self,
17762 transport: &mut dyn Transport,
17763 max_rounds: Option<u32>,
17764 ) -> SyncOutcome {
17765 self.sync_until_idle_with_deadline(transport, max_rounds, Some(Self::SYNC_IDLE_TIMEOUT_MS))
17766 }
17767
17768 pub fn sync_until_idle_with_deadline(
17773 &mut self,
17774 transport: &mut dyn Transport,
17775 max_rounds: Option<u32>,
17776 timeout_ms: Option<u64>,
17777 ) -> SyncOutcome {
17778 if max_rounds == Some(0) {
17781 return SyncOutcome::Failed {
17782 details: None,
17783 error_code: "sync.invalid_request".into(),
17784 message: "maxRounds must be a positive integer".into(),
17785 recommended_action: None,
17786 };
17787 }
17788 if timeout_ms.is_some_and(|ms| ms == 0 || i64::try_from(ms).is_err()) {
17789 return SyncOutcome::Failed {
17790 details: None,
17791 error_code: "sync.invalid_request".into(),
17792 message: "timeoutMs must be a positive integer".into(),
17793 recommended_action: None,
17794 };
17795 }
17796 let started_at_ms = self.clock_now_ms();
17797 let deadline_ms = timeout_ms.map(|ms| started_at_ms.saturating_add(ms as i64));
17798 self.sync_until_idle_bounded(transport, max_rounds, started_at_ms, deadline_ms)
17799 }
17800
17801 fn sync_until_idle_bounded(
17802 &mut self,
17803 transport: &mut dyn Transport,
17804 max_rounds: Option<u32>,
17805 started_at_ms: i64,
17806 deadline_ms: Option<i64>,
17807 ) -> SyncOutcome {
17808 let rounds = max_rounds.unwrap_or(20);
17809 let mut aggregate = SyncReport::default();
17810 let mut spent = 0;
17811 while spent < rounds {
17812 if let Some(deadline_ms) = deadline_ms {
17813 if self.clock_now_ms() >= deadline_ms {
17814 return SyncOutcome::Failed {
17815 error_code: "sync.idle_timeout".into(),
17816 message: "sync_until_idle exceeded its deadline".into(),
17817 details: Some(serde_json::json!({
17818 "timeoutMs": deadline_ms
17819 .saturating_sub(started_at_ms) as u64,
17820 "rounds": spent,
17821 })),
17822 recommended_action: Some("retryLater".into()),
17823 };
17824 }
17825 }
17826 spent += 1;
17827 let before = self
17828 .subs
17829 .iter()
17830 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17831 .collect::<Vec<_>>();
17832 match self.sync(transport) {
17833 SyncOutcome::Failed {
17834 error_code,
17835 message,
17836 details,
17837 recommended_action,
17838 } => {
17839 return SyncOutcome::Failed {
17840 details,
17841 error_code,
17842 message,
17843 recommended_action,
17844 };
17845 }
17846 outcome @ SyncOutcome::RealtimeUnavailable { .. } => return outcome,
17847 SyncOutcome::BudgetExhausted(_) => {
17848 unreachable!("sync() never exhausts a round budget")
17849 }
17850 SyncOutcome::Ok(report) => {
17851 if max_rounds.is_none()
17852 && report.segment_rows_applied > 0
17853 && !report.bootstrapping.is_empty()
17854 && before
17855 != self
17856 .subs
17857 .iter()
17858 .map(|sub| (sub.id.clone(), sub.bootstrap_state.clone()))
17859 .collect::<Vec<_>>()
17860 {
17861 spent = 0;
17862 }
17863 let stop = !self.transport_enabled() || report.schema_floor.is_some();
17866 aggregate.merge(&report);
17867 if stop {
17868 return SyncOutcome::Ok(aggregate);
17869 }
17870 let more = !report.bootstrapping.is_empty()
17876 || report.commits_applied > 0
17877 || report.segment_rows_applied > 0
17878 || !report.resets.is_empty()
17879 || report.deferred_commits > 0
17880 || self.sync_needed;
17881 if !more {
17882 return SyncOutcome::Ok(aggregate);
17883 }
17884 }
17885 }
17886 }
17887 SyncOutcome::BudgetExhausted(aggregate)
17890 }
17891
17892 fn process_response(
17893 &mut self,
17894 transport: &mut dyn Transport,
17895 response: Message,
17896 meta: &RequestMeta,
17897 ) -> SyncOutcome {
17898 #[cfg(feature = "bench-internals")]
17899 let _phase = self.benchmark_phases.start(Phase::ResponseApply);
17900 let mut report = SyncReport {
17901 pushed: meta.pushed_ids.len() as u32,
17902 ..SyncReport::default()
17903 };
17904 let mut rejection_details_by_commit: HashMap<String, BTreeMap<i32, RejectionDetails>> =
17905 HashMap::new();
17906 let pushed_ids = meta
17907 .pushed_ids
17908 .iter()
17909 .map(String::as_str)
17910 .collect::<HashSet<_>>();
17911 let mut last_final_push_result_id: Option<String> = None;
17912 for frame in &response.frames {
17913 match frame {
17914 Frame::PushResultDetails {
17915 client_commit_id,
17916 entries,
17917 } => {
17918 let details = rejection_details_by_commit
17919 .entry(client_commit_id.clone())
17920 .or_default();
17921 for entry in entries {
17922 let parsed = match RejectionDetails::parse(&entry.details.0) {
17923 Ok(value) => value,
17924 Err(message) => {
17925 return SyncOutcome::Failed {
17926 details: None,
17927 error_code: "sync.invalid_request".to_owned(),
17928 message,
17929 recommended_action: None,
17930 };
17931 }
17932 };
17933 details.insert(entry.op_index, parsed);
17934 }
17935 }
17936 Frame::PushResult {
17937 client_commit_id,
17938 status,
17939 results,
17940 ..
17941 } if pushed_ids.contains(client_commit_id.as_str())
17942 && Self::push_result_is_final(*status, results) =>
17943 {
17944 last_final_push_result_id = Some(client_commit_id.clone());
17945 }
17946 _ => {}
17947 }
17948 }
17949 if response.wire_version < 2 {
17950 return SyncOutcome::Failed {
17951 details: None,
17952 error_code: "client.invalid_host_response".to_owned(),
17953 message: "server response does not carry wire version 2 log-epoch state".to_owned(),
17954 recommended_action: None,
17955 };
17956 }
17957 let coalesced_deletes = coalesced_delete_keys(&response.frames, |id, status, _| {
17958 status == SubStatus::Active && self.subs.iter().any(|sub| sub.id == id)
17959 });
17960 let mut frames = response.frames.into_iter().peekable();
17961 match frames.next() {
17962 Some(Frame::RespHeader {
17963 required_schema_version,
17964 latest_schema_version,
17965 log_epoch,
17966 reset_required,
17967 }) => {
17968 let Some(log_epoch) = log_epoch else {
17969 return SyncOutcome::Failed {
17970 details: None,
17971 error_code: "client.invalid_host_response".to_owned(),
17972 message: "response header omits logEpoch".to_owned(),
17973 recommended_action: None,
17974 };
17975 };
17976 let Some(reset_required) = reset_required else {
17977 return SyncOutcome::Failed {
17978 details: None,
17979 error_code: "client.invalid_host_response".to_owned(),
17980 message: "response header omits resetRequired".to_owned(),
17981 recommended_action: None,
17982 };
17983 };
17984 if let Some(required) = required_schema_version {
17985 let floor = SchemaFloor {
17988 required_schema_version: Some(required),
17989 latest_schema_version,
17990 };
17991 self.set_schema_floor(Some(floor.clone()));
17992 report.schema_floor = Some(floor);
17993 return SyncOutcome::Ok(report);
17994 }
17995 if reset_required {
17996 if frames.next().is_some() {
17997 return SyncOutcome::Failed {
17998 details: None,
17999 error_code: "client.invalid_host_response".to_owned(),
18000 message: "log-epoch reset response contains body frames".to_owned(),
18001 recommended_action: None,
18002 };
18003 }
18004 match self.run_log_epoch_reset(&log_epoch) {
18005 Ok(resets) => {
18006 report.resets = resets;
18007 return SyncOutcome::Ok(report);
18008 }
18009 Err(message) => {
18010 return SyncOutcome::Failed {
18011 details: None,
18012 error_code: "sync.local_corrupt".to_owned(),
18013 message,
18014 recommended_action: None,
18015 };
18016 }
18017 }
18018 }
18019 if self.get_meta(LOG_EPOCH_KEY).as_deref() != Some(log_epoch.as_str()) {
18020 return SyncOutcome::Failed {
18021 details: None,
18022 error_code: "client.invalid_host_response".to_owned(),
18023 message: "server changed logEpoch without requiring a reset".to_owned(),
18024 recommended_action: None,
18025 };
18026 }
18027 }
18028 _ => {
18029 return SyncOutcome::Failed {
18030 details: None,
18031 error_code: "sync.invalid_request".to_owned(),
18032 message: "response does not start with RESP_HEADER".to_owned(),
18033 recommended_action: None,
18034 };
18035 }
18036 }
18037
18038 if let Err(message) = self.begin_coalesced_apply() {
18039 return SyncOutcome::Failed {
18040 details: None,
18041 error_code: "storage.failed".into(),
18042 message,
18043 recommended_action: None,
18044 };
18045 }
18046 let mut failure: Option<(String, String, Option<String>, Option<Value>)> = None;
18047 while let Some(frame) = frames.next() {
18048 match frame {
18049 frame @ Frame::PushResult { .. } => {
18050 let successful = matches!(
18051 &frame,
18052 Frame::PushResult {
18053 status: PushStatus::Applied | PushStatus::Cached,
18054 ..
18055 }
18056 );
18057 let mut results = vec![frame];
18058 if successful {
18059 while matches!(
18060 frames.peek(),
18061 Some(Frame::PushResult {
18062 status: PushStatus::Applied | PushStatus::Cached,
18063 ..
18064 })
18065 ) {
18066 if let Some(next) = frames.next() {
18067 results.push(next);
18068 }
18069 }
18070 }
18071 if self
18072 .handle_push_results(
18073 &results,
18074 &pushed_ids,
18075 &rejection_details_by_commit,
18076 &mut report,
18077 last_final_push_result_id.as_deref(),
18078 )
18079 .is_err()
18080 {
18081 failure = Some((
18082 "client.outcome_persistence_failed".to_owned(),
18083 "local commit outcome could not be persisted".to_owned(),
18084 None,
18085 None,
18086 ));
18087 break;
18088 }
18089 }
18090 Frame::PushResultDetails { .. } => {}
18091 Frame::SubStart {
18092 id,
18093 status,
18094 reason_code,
18095 effective_scopes,
18096 bootstrap: _,
18097 } => {
18098 let mut body = Vec::new();
18099 let mut sub_end: Option<(i64, Option<String>)> = None;
18100 for inner in frames.by_ref() {
18101 match inner {
18102 Frame::SubEnd {
18103 next_cursor,
18104 bootstrap_state,
18105 } => {
18106 sub_end = Some((next_cursor, bootstrap_state.map(|r| r.0)));
18107 break;
18108 }
18109 Frame::Unknown { .. } => {}
18110 other => body.push(other),
18111 }
18112 }
18113 if let Err(SectionError::Abort(code, message)) = self.process_section(
18114 transport,
18115 &id,
18116 status,
18117 &reason_code,
18118 effective_scopes,
18119 body,
18120 sub_end,
18121 meta,
18122 &mut report,
18123 &coalesced_deletes,
18124 ) {
18125 failure = Some((code, message, None, None));
18126 break;
18127 }
18128 }
18129 Frame::Lease {
18130 lease_id,
18131 expires_at_ms,
18132 } => {
18133 self.set_lease_state(Some(LeaseState {
18136 lease_id: Some(lease_id),
18137 expires_at_ms: Some(expires_at_ms),
18138 error_code: None,
18139 }));
18140 }
18141 Frame::Error {
18142 code,
18143 message,
18144 recommended_action,
18145 details,
18146 ..
18147 } => {
18148 let recommended_action =
18151 (!recommended_action.is_empty()).then_some(recommended_action);
18152 let details = details
18153 .and_then(|details| serde_json::from_str::<Value>(&details.0).ok())
18154 .filter(Value::is_object);
18155 failure = Some((code, message, recommended_action, details));
18156 break;
18157 }
18158 Frame::Unknown { .. } => {}
18159 _ => {
18160 failure = Some((
18161 "sync.invalid_request".to_owned(),
18162 "unexpected frame in response".to_owned(),
18163 None,
18164 None,
18165 ));
18166 break;
18167 }
18168 }
18169 }
18170
18171 let applied = match failure {
18172 Some(failure) => Err(failure),
18173 None => self
18178 .rebuild_overlay_if_dirty()
18179 .and_then(|()| self.drain_pending_evictions())
18180 .map_err(|message| ("storage.failed".to_owned(), message, None, None)),
18181 };
18182 if let Err(message) = self.finish_coalesced_apply() {
18185 return SyncOutcome::Failed {
18186 details: None,
18187 error_code: "storage.failed".into(),
18188 message,
18189 recommended_action: None,
18190 };
18191 }
18192 if let Err((error_code, message, recommended_action, details)) = applied {
18193 return SyncOutcome::Failed {
18194 details,
18195 error_code,
18196 message,
18197 recommended_action,
18198 };
18199 }
18200 self.ack_after_pull(transport);
18201 if self.upgrading && report.bootstrapping.is_empty() {
18204 self.set_upgrading(false);
18205 }
18206 SyncOutcome::Ok(report)
18207 }
18208
18209 fn handle_push_results(
18212 &mut self,
18213 frames: &[Frame],
18214 pushed_ids: &HashSet<&str>,
18215 rejection_details_by_commit: &HashMap<String, BTreeMap<i32, RejectionDetails>>,
18216 report: &mut SyncReport,
18217 last_final_push_result_id: Option<&str>,
18218 ) -> Result<(), String> {
18219 self.begin_observation("syncular_push_results")?;
18220 let previous_failed = self.failed_commits.clone();
18221 let previous_sync_needed = self.sync_needed;
18222 let conflict_count = self.conflicts.len();
18223 let rejection_count = self.rejections.len();
18224 let applied_count = report.applied.len();
18225 let rejected_count = report.rejected.len();
18226 let retryable_count = report.retryable.len();
18227 let was_dirty = self.overlay_dirty.snapshot();
18228 let mut removed = Vec::new();
18229 let mut batch = ChangeAccumulator::default();
18230 let persisted = (|| {
18231 for frame in frames {
18232 let Frame::PushResult {
18233 client_commit_id,
18234 status,
18235 results,
18236 commit_seq,
18237 } = frame
18238 else {
18239 return Err("unexpected acknowledgement frame".to_owned());
18240 };
18241 if !pushed_ids.contains(client_commit_id.as_str()) {
18242 continue;
18243 }
18244 let Some(index) = self
18245 .outbox
18246 .iter()
18247 .position(|commit| commit.client_commit_id == *client_commit_id)
18248 else {
18249 continue;
18250 };
18251 let status = *status;
18252 let rejection_details = rejection_details_by_commit.get(client_commit_id);
18253 let operations = self.outbox[index].ops.clone();
18254 match status {
18255 PushStatus::Applied | PushStatus::Cached => {
18256 let sequence =
18259 commit_seq.ok_or_else(|| "ACK lacks a commit sequence".to_owned())?;
18260 let affected: HashSet<_> = operations
18261 .iter()
18262 .map(|op| (op.table.as_str(), op.row_id.as_str()))
18263 .collect();
18264 let prior = self.acknowledged_operations(Some(&affected), None)?;
18265 for (op_index, operation) in operations.iter().enumerate() {
18266 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))
18267 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18268 if delivered {
18269 continue;
18270 }
18271 let table = self
18272 .schema
18273 .table(&operation.table)
18274 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18275 let mut intended: Map<String, Value> = self
18276 .stored_row(table, &operation.row_id, true)?
18277 .map(|(row, _)| {
18278 table
18279 .columns
18280 .iter()
18281 .zip(&row)
18282 .map(|(column, value)| {
18283 (column.name.clone(), column_value_to_json(value))
18284 })
18285 .collect()
18286 })
18287 .unwrap_or_default();
18288 let mut scope_image = intended.clone();
18289 for op in prior
18290 .iter()
18291 .chain(
18292 self.failed_commits
18293 .iter()
18294 .flat_map(|(commit, _)| &commit.ops),
18295 )
18296 .chain(
18297 self.outbox
18298 .iter()
18299 .take(index)
18300 .flat_map(|commit| &commit.ops),
18301 )
18302 .chain(operations.iter().take(op_index + 1))
18303 {
18304 if op.table != operation.table || op.row_id != operation.row_id {
18305 continue;
18306 }
18307 if let Some(values) = &op.values {
18308 intended.extend(values.clone());
18309 } else {
18310 scope_image = intended.clone();
18311 intended.clear();
18312 }
18313 }
18314 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)",
18315 rusqlite::params![client_commit_id, op_index as i64, operation.table, operation.row_id, sequence,
18316 serde_json::to_string(&CommitOperation::from(operation)).map_err(|_| "sync.local_corrupt".to_owned())?,
18317 serde_json::to_string(if operation.upsert { &intended } else { &scope_image }).map_err(|_| "sync.local_corrupt".to_owned())?])
18318 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18319 }
18320 let journal_results = results
18321 .iter()
18322 .map(|result| {
18323 let op_index = match result {
18324 OpResult::Applied { op_index }
18325 | OpResult::Conflict { op_index, .. }
18326 | OpResult::Error { op_index, .. } => *op_index,
18327 };
18328 CommitOperationOutcome::Applied { op_index }
18329 })
18330 .collect::<Vec<_>>();
18331 let outcome_status = if status == PushStatus::Applied {
18332 CommitOutcomeStatus::Applied
18333 } else {
18334 CommitOutcomeStatus::Cached
18335 };
18336 let persisted = self
18337 .persist_commit_outcome(
18338 client_commit_id,
18339 outcome_status,
18340 &journal_results,
18341 None,
18342 )
18343 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18344 persisted?;
18345 for operation in &operations {
18346 self.overlay_dirty.table(&operation.table);
18347 }
18348 self.sync_needed = true;
18349 batch.status = true;
18350 batch.outcomes = true;
18351 }
18352 PushStatus::Rejected => {
18353 if results.iter().any(|result| {
18354 matches!(
18355 result,
18356 OpResult::Error {
18357 code,
18358 retryable: true,
18359 ..
18360 } if code == "sync.idempotency_cache_miss"
18361 )
18362 }) {
18363 report.retryable.push(client_commit_id.to_owned());
18366 continue;
18367 }
18368
18369 let mut journal_results = Vec::with_capacity(results.len());
18370 let mut conflicts = Vec::new();
18371 let mut rejections = Vec::new();
18372 for result in results {
18373 match result {
18374 OpResult::Applied { op_index } => {
18375 journal_results.push(CommitOperationOutcome::Applied {
18376 op_index: *op_index,
18377 });
18378 }
18379 OpResult::Conflict {
18380 op_index,
18381 code,
18382 message,
18383 server_version,
18384 server_row,
18385 conflict_columns: conflict_columns_bitmap,
18386 } => {
18387 let operation = operations
18388 .get(*op_index as usize)
18389 .map(CommitOperation::from);
18390 let (table, row_id) = operation
18391 .as_ref()
18392 .map(|op| (op.table.clone(), op.row_id.clone()))
18393 .unwrap_or_default();
18394 let server_row_json = self
18395 .schema
18396 .table(&table)
18397 .and_then(|t| {
18398 decode_row_bytes(t, server_row, &self.encryption)
18399 .ok()
18400 .map(|row| (t, row))
18401 })
18402 .map(|(t, row)| {
18403 let mut map = Map::new();
18404 for (i, column) in t.columns.iter().enumerate() {
18405 map.insert(
18406 column.name.clone(),
18407 column_value_to_json(
18408 row.get(i).unwrap_or(&None),
18409 ),
18410 );
18411 }
18412 map
18413 })
18414 .unwrap_or_default();
18415 let conflict_columns = self
18418 .schema
18419 .table(&table)
18420 .map(|t| {
18421 t.columns
18422 .iter()
18423 .enumerate()
18424 .filter(|(index, _)| {
18425 conflict_columns_bitmap
18426 .get(index / 8)
18427 .is_some_and(|byte| {
18428 byte & (1 << (index % 8)) != 0
18429 })
18430 })
18431 .map(|(_, column)| column.name.clone())
18432 .collect::<Vec<_>>()
18433 })
18434 .unwrap_or_default();
18435 let conflict = ConflictRecord {
18436 client_commit_id: client_commit_id.to_owned(),
18437 op_index: *op_index,
18438 table,
18439 row_id,
18440 code: code.clone(),
18441 message: message.clone(),
18442 server_version: *server_version,
18443 server_row: server_row_json,
18444 conflict_columns,
18445 operation,
18446 };
18447 journal_results.push(CommitOperationOutcome::Conflict {
18448 conflict: conflict.clone(),
18449 });
18450 conflicts.push(conflict);
18451 }
18452 OpResult::Error {
18453 op_index,
18454 code,
18455 message,
18456 retryable,
18457 } => {
18458 let rejection = RejectionRecord {
18459 client_commit_id: client_commit_id.to_owned(),
18460 op_index: *op_index,
18461 code: code.clone(),
18462 message: message.clone(),
18463 retryable: *retryable,
18464 details: rejection_details
18465 .and_then(|details| details.get(op_index))
18466 .cloned(),
18467 operation: operations
18468 .get(*op_index as usize)
18469 .map(CommitOperation::from),
18470 };
18471 journal_results.push(CommitOperationOutcome::Error {
18472 rejection: rejection.clone(),
18473 });
18474 rejections.push(rejection);
18475 }
18476 }
18477 }
18478 if self.retain_failed_commits {
18479 self.retain_failed_commit(client_commit_id, &operations)?;
18480 }
18481 let outcome_status = if conflicts.is_empty() {
18482 CommitOutcomeStatus::Rejected
18483 } else {
18484 CommitOutcomeStatus::Conflict
18485 };
18486 let persisted = self
18487 .persist_commit_outcome(
18488 client_commit_id,
18489 outcome_status,
18490 &journal_results,
18491 Some(&operations),
18492 )
18493 .and_then(|()| self.delete_outbox_persisted(client_commit_id));
18494 persisted?;
18495 batch.conflicts = !conflicts.is_empty();
18496 batch.rejections = !rejections.is_empty();
18497 batch.status = true;
18498 batch.outcomes = true;
18499 self.conflicts.extend(conflicts);
18500 self.rejections.extend(rejections);
18501 for operation in &operations {
18502 self.overlay_dirty.table(&operation.table);
18503 }
18504 }
18505 }
18506 removed.push((index, self.outbox.remove(index)));
18507 if batch.status {
18508 for operation in &operations {
18509 if !self.record_row_scopes(
18510 &mut batch,
18511 &operation.table,
18512 &operation.row_id,
18513 false,
18514 ) {
18515 batch.table(&operation.table);
18516 }
18517 }
18518 }
18519 match status {
18520 PushStatus::Applied | PushStatus::Cached => {
18521 report.applied.push(client_commit_id.to_owned())
18522 }
18523 PushStatus::Rejected => report.rejected.push(client_commit_id.to_owned()),
18524 }
18525 }
18526 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)) {
18527 self.prune_commit_outcomes()?;
18528 }
18529 self.rebuild_overlay_if_dirty()?;
18530 self.finish_observation("syncular_push_results", batch)
18531 })();
18532 if let Err(error) = persisted {
18533 self.rollback_observation("syncular_push_results");
18534 self.failed_commits = previous_failed;
18535 self.sync_needed = previous_sync_needed;
18536 for (index, commit) in removed.into_iter().rev() {
18537 self.outbox.insert(index, commit);
18538 }
18539 self.conflicts.truncate(conflict_count);
18540 self.rejections.truncate(rejection_count);
18541 self.overlay_dirty.restore(was_dirty.clone());
18542 report.applied.truncate(applied_count);
18543 report.rejected.truncate(rejected_count);
18544 report.retryable.truncate(retryable_count);
18545 return Err(error);
18546 }
18547 if !previous_sync_needed && self.sync_needed {
18548 self.sync_intent_queue.push_back(SyncIntent::Interactive);
18549 }
18550 report.conflicts += (self.conflicts.len() - conflict_count) as u32;
18551 Ok(())
18552 }
18553
18554 fn push_result_is_final(status: PushStatus, results: &[OpResult]) -> bool {
18555 status != PushStatus::Rejected
18556 || !results.iter().any(|result| {
18557 matches!(
18558 result,
18559 OpResult::Error {
18560 code,
18561 retryable: true,
18562 ..
18563 } if code == "sync.idempotency_cache_miss"
18564 )
18565 })
18566 }
18567
18568 #[allow(clippy::too_many_arguments)]
18571 fn process_section(
18572 &mut self,
18573 transport: &mut dyn Transport,
18574 id: &str,
18575 status: SubStatus,
18576 reason_code: &str,
18577 effective_scopes: Vec<(String, Vec<String>)>,
18578 body: Vec<Frame>,
18579 sub_end: Option<(i64, Option<String>)>,
18580 meta: &RequestMeta,
18581 report: &mut SyncReport,
18582 coalesced_deletes: &HashSet<(i64, String, String)>,
18583 ) -> Result<(), SectionError> {
18584 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
18585 return Ok(()); };
18587 if status != SubStatus::Active && sub_end.is_none() {
18588 return Err(SectionError::Abort(
18589 "sync.invalid_request".into(),
18590 "subscription section without SUB_END".into(),
18591 ));
18592 }
18593 match status {
18594 SubStatus::Revoked => {
18595 self.begin_observation("syncular_revocation")
18596 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18597 let previous = self.subs[sub_index].clone();
18598 let was_dirty = self.overlay_dirty.snapshot();
18599 let previous_outbox = self.outbox.clone();
18600 let previous_failed = self.failed_commits.clone();
18601 let previous_rejections = self.rejections.clone();
18602 let previous_conflicts = self.conflicts.clone();
18603 let revoked_count = report.revoked.len();
18604 let failed_count = report.failed.len();
18605 let result = (|| {
18606 let mut batch = ChangeAccumulator::default();
18607 let registered = self.window_unit_by_sub_id(id);
18608 let (table, effective) = {
18610 let sub = &self.subs[sub_index];
18611 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
18612 };
18613 let scoped_table = self.schema.table(&table).cloned().ok_or_else(|| {
18614 SectionError::Abort(
18615 "sync.unknown_table".to_owned(),
18616 "revoked scope references an unknown table".to_owned(),
18617 )
18618 })?;
18619 let dropped = self
18620 .drop_failed_commits(|name, values| {
18621 name == table
18622 && !effective.is_empty()
18623 && effective.iter().all(|(variable, allowed)| {
18624 scoped_table
18625 .scope_column(variable)
18626 .and_then(|column| values.get(column))
18627 .and_then(Value::as_str)
18628 .is_some_and(|value| {
18629 allowed.iter().any(|allowed| allowed == value)
18630 })
18631 })
18632 })
18633 .map_err(|message| {
18634 SectionError::Abort("storage.failed".to_owned(), message)
18635 })?;
18636 for commit in dropped {
18637 batch.conflicts = true;
18638 batch.rejections = true;
18639 batch.outcomes = true;
18640 for operation in commit.ops {
18641 batch.table(&operation.table);
18642 }
18643 }
18644 let purged = self.purge_scope_rows(&table, &effective);
18645 match purged {
18646 Ok(()) => {
18647 self.record_scope_map(&mut batch, &table, &effective);
18648 let sub = &mut self.subs[sub_index];
18649 sub.state = SubState::Revoked;
18650 sub.reason_code = Some(if reason_code.is_empty() {
18651 "sync.scope_revoked".to_owned()
18652 } else {
18653 reason_code.to_owned()
18654 });
18655 report.revoked.push(id.to_owned());
18656 let doomed_effective = effective;
18657 let sub_table = table;
18658 self.persist_sub(&self.subs[sub_index].clone())
18659 .map_err(|message| {
18660 SectionError::Abort("storage.failed".into(), message)
18661 })?;
18662 let dropped = self
18663 .drop_doomed_outbox(&sub_table, &doomed_effective)
18664 .map_err(|message| {
18665 SectionError::Abort("storage.failed".to_owned(), message)
18666 })?;
18667 if dropped {
18668 batch.status = true;
18669 batch.rejections = true;
18670 batch.outcomes = true;
18671 }
18672 self.delete_unreferenced_cached_blobs().map_err(|message| {
18675 SectionError::Abort("storage.failed".into(), message)
18676 })?;
18677 }
18678 Err(()) => {
18679 let sub = &mut self.subs[sub_index];
18682 sub.state = SubState::Failed;
18683 sub.reason_code = Some("sync.scope_revoked".to_owned());
18684 report.failed.push(id.to_owned());
18685 self.persist_sub(&self.subs[sub_index].clone())
18686 .map_err(|message| {
18687 SectionError::Abort("storage.failed".into(), message)
18688 })?;
18689 }
18690 }
18691 if let Some((base_key, unit)) = registered {
18692 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18693 }
18694 self.rebuild_overlay_if_dirty()
18695 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18696 self.finish_observation("syncular_revocation", batch)
18697 .map_err(|message| {
18698 SectionError::Abort("storage.failed".to_owned(), message)
18699 })?;
18700 Ok(())
18701 })();
18702 if result.is_err() {
18703 self.rollback_observation("syncular_revocation");
18704 self.subs[sub_index] = previous;
18705 self.overlay_dirty.restore(was_dirty.clone());
18706 self.outbox = previous_outbox;
18707 self.failed_commits = previous_failed;
18708 self.rejections = previous_rejections;
18709 self.conflicts = previous_conflicts;
18710 report.revoked.truncate(revoked_count);
18711 report.failed.truncate(failed_count);
18712 }
18713 result
18714 }
18715 SubStatus::Reset => {
18716 self.begin_observation("syncular_reset")
18717 .map_err(|message| SectionError::Abort("storage.failed".to_owned(), message))?;
18718 let previous = self.subs[sub_index].clone();
18719 let was_dirty = self.overlay_dirty.snapshot();
18720 let reset_count = report.resets.len();
18721 let result = (|| {
18722 let mut batch = ChangeAccumulator::default();
18723 let registered = self.window_unit_by_sub_id(id);
18724 let sub = &mut self.subs[sub_index];
18727 sub.cursor = -1;
18728 sub.bootstrap_state = None;
18729 sub.reason_code = Some(if reason_code.is_empty() {
18730 "sync.cursor_expired".to_owned()
18731 } else {
18732 reason_code.to_owned()
18733 });
18734 report.resets.push(id.to_owned());
18735 self.persist_sub(&self.subs[sub_index].clone())
18736 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
18737 if let Some((base_key, unit)) = registered {
18738 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18739 }
18740 self.finish_observation("syncular_reset", batch)
18741 .map_err(|message| {
18742 SectionError::Abort("storage.failed".to_owned(), message)
18743 })?;
18744 Ok(())
18745 })();
18746 if result.is_err() {
18747 self.rollback_observation("syncular_reset");
18748 self.subs[sub_index] = previous;
18749 self.overlay_dirty.restore(was_dirty.clone());
18750 report.resets.truncate(reset_count);
18751 }
18752 result
18753 }
18754 SubStatus::Active => {
18755 let fresh = meta
18756 .fresh
18757 .iter()
18758 .find(|(fid, _)| fid == id)
18759 .map(|(_, fresh)| *fresh)
18760 .unwrap_or(false);
18761 let previous = self.subs[sub_index].clone();
18762 self.subs[sub_index].effective = Some(effective_scopes);
18763 let outcome = self
18764 .apply_section_body(
18765 transport,
18766 sub_index,
18767 body,
18768 fresh,
18769 meta,
18770 report,
18771 coalesced_deletes,
18772 )
18773 .and_then(|()| {
18774 let (cursor, bootstrap_state) = sub_end.ok_or_else(|| {
18775 SectionError::Abort(
18776 "sync.invalid_request".into(),
18777 "subscription section without SUB_END".into(),
18778 )
18779 })?;
18780 self.apply_sub_end(sub_index, cursor, bootstrap_state)
18781 .map_err(|message| {
18782 SectionError::Abort("storage.failed".into(), message)
18783 })
18784 });
18785 match outcome {
18786 Ok(()) => {
18787 if self.subs[sub_index].bootstrap_state.is_some() {
18788 report.bootstrapping.push(id.to_owned());
18789 }
18790 Ok(())
18791 }
18792 Err(SectionError::FailClosed) => {
18793 self.subs[sub_index] = previous.clone();
18794 self.begin_observation("syncular_section_failure")
18795 .map_err(|message| {
18796 SectionError::Abort("storage.failed".into(), message)
18797 })?;
18798 let persisted = (|| {
18799 let mut batch = ChangeAccumulator::default();
18800 if let Some((base_key, unit)) = self.window_unit_by_sub_id(id) {
18801 batch.window(&base_key, &self.subs[sub_index].table, &unit);
18802 }
18803 self.subs[sub_index].state = SubState::Failed;
18804 self.subs[sub_index].reason_code = Some("sync.scope_revoked".into());
18805 self.persist_sub(&self.subs[sub_index])?;
18806 self.finish_observation("syncular_section_failure", batch)
18807 })();
18808 if let Err(message) = persisted {
18809 self.rollback_observation("syncular_section_failure");
18810 self.subs[sub_index] = previous;
18811 return Err(SectionError::Abort("storage.failed".into(), message));
18812 }
18813 report.failed.push(id.to_owned());
18814 Ok(())
18815 }
18816 Err(error) => {
18817 self.subs[sub_index] = previous;
18820 Err(error)
18821 }
18822 }
18823 }
18824 }
18825 }
18826
18827 fn apply_sub_end(
18828 &mut self,
18829 sub_index: usize,
18830 cursor: i64,
18831 bootstrap_state: Option<String>,
18832 ) -> Result<(), String> {
18833 #[cfg(feature = "bench-internals")]
18834 let _phase = self.benchmark_phases.start(Phase::CursorPersist);
18835 let previous = self.subs[sub_index].clone();
18836 self.begin_observation("syncular_sub_end")?;
18837 let persisted = (|| {
18838 let mut batch = ChangeAccumulator::default();
18839 let completed = (previous.cursor < 0 || previous.bootstrap_state.is_some())
18840 && cursor >= 0
18841 && bootstrap_state.is_none();
18842 if completed {
18843 let table = self
18844 .schema
18845 .table(&previous.table)
18846 .ok_or_else(|| "sync.unknown_table".to_owned())?;
18847 let effective = previous.effective.as_deref().unwrap_or(&[]);
18848 let mut statement = self.conn.prepare("SELECT id,intent_json FROM _syncular_acked_rows WHERE tbl=?1 AND commit_seq<=?2")
18849 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18850 let rows = statement
18851 .query_map(rusqlite::params![previous.table, cursor], |row| {
18852 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
18853 })
18854 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18855 let mut covered = HashSet::new();
18856 for row in rows {
18857 let (id, intent) =
18858 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18859 let values: Map<String, Value> = serde_json::from_str(&intent)
18860 .map_err(|_| "sync.local_corrupt".to_owned())?;
18861 if !effective.is_empty()
18862 && effective.iter().all(|(variable, allowed)| {
18863 table
18864 .scope_column(variable)
18865 .and_then(|column| values.get(column))
18866 .is_some_and(|value| {
18867 allowed.contains(
18868 &value
18869 .as_str()
18870 .map_or_else(|| value.to_string(), str::to_owned),
18871 )
18872 })
18873 })
18874 {
18875 covered.insert(id);
18876 }
18877 }
18878 drop(statement);
18879 for id in covered {
18880 self.conn.execute("DELETE FROM _syncular_acked_rows WHERE tbl=?1 AND id=?2 AND commit_seq<=?3", rusqlite::params![previous.table, id, cursor])
18881 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
18882 self.overlay_dirty.table(&previous.table);
18883 batch.table(&previous.table);
18884 }
18885 self.rebuild_overlay_if_dirty()?;
18886 if let Some((base_key, unit)) = self.window_unit_by_sub_id(&previous.id) {
18887 batch.window(&base_key, &previous.table, &unit);
18888 }
18889 }
18890 let sub = &mut self.subs[sub_index];
18891 sub.cursor = cursor;
18892 sub.bootstrap_state = bootstrap_state;
18893 sub.reason_code = None;
18896 sub.synced_once = true;
18897 self.persist_sub(&self.subs[sub_index])?;
18898 self.finish_observation("syncular_sub_end", batch)
18899 })();
18900 if persisted.is_err() {
18901 self.rollback_observation("syncular_sub_end");
18902 self.subs[sub_index] = previous;
18903 }
18904 persisted
18905 }
18906
18907 #[allow(clippy::too_many_arguments)]
18908 fn apply_section_body(
18909 &mut self,
18910 transport: &mut dyn Transport,
18911 sub_index: usize,
18912 body: Vec<Frame>,
18913 fresh: bool,
18914 meta: &RequestMeta,
18915 report: &mut SyncReport,
18916 coalesced_deletes: &HashSet<(i64, String, String)>,
18917 ) -> Result<(), SectionError> {
18918 let mut saw_segment = false;
18919 for frame in body {
18920 match frame {
18921 Frame::Commit {
18922 tables,
18923 changes,
18924 commit_seq,
18925 ..
18926 } => {
18927 if self
18928 .apply_commit_frame_coalesced(
18929 &tables,
18930 changes,
18931 commit_seq,
18932 coalesced_deletes,
18933 )
18934 .map_err(|(c, m)| SectionError::Abort(c, m))?
18935 {
18936 report.commits_applied += 1;
18937 }
18938 }
18939 Frame::SegmentInline { payload } => {
18940 let segment = decode_rows_segment(&payload)
18941 .map_err(|e| SectionError::Abort(e.code.as_str().to_owned(), e.detail))?;
18942 self.progress.update(|p| {
18943 p.phase = ProgressPhase::Import;
18944 p.subscription_id = Some(self.subs[sub_index].id.clone());
18945 p.table = Some(self.subs[sub_index].table.clone());
18946 p.segment_id = None;
18947 p.bytes_received = payload.len() as u64;
18948 p.bytes_total = Some(payload.len() as u64);
18949 p.rows_processed = 0;
18950 p.rows_total =
18951 Some(segment.blocks.iter().map(|block| block.len() as u64).sum());
18952 });
18953 let first = !saw_segment;
18954 saw_segment = true;
18955 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
18956 report.segment_rows_applied += applied;
18957 }
18958 Frame::SegmentRef {
18959 segment_id,
18960 byte_length,
18961 media_type,
18962 table,
18963 row_count,
18964 as_of_commit_seq,
18965 scope_digest,
18966 row_cursor,
18967 next_row_cursor,
18968 url,
18969 url_expires_at_ms,
18970 ..
18971 } => {
18972 let advertised = match media_type {
18975 MediaType::Rows => {
18976 meta.accept & ACCEPT_EXTERNAL_ROWS != 0
18977 || meta.accept & ACCEPT_INLINE_ROWS != 0
18978 }
18979 MediaType::Sqlite => meta.accept & ACCEPT_SQLITE != 0,
18980 };
18981 if !advertised {
18982 return Err(SectionError::Abort(
18983 "sync.invalid_request".to_owned(),
18984 format!(
18985 "SEGMENT_REF mediaType {} was not advertised in accept (§4.2)",
18986 media_type.name()
18987 ),
18988 ));
18989 }
18990 self.progress.update(|p| {
18991 p.phase = ProgressPhase::Download;
18992 p.subscription_id = Some(self.subs[sub_index].id.clone());
18993 p.table = Some(table.clone());
18994 p.segment_id = Some(segment_id.clone());
18995 p.bytes_received = 0;
18996 p.bytes_total = u64::try_from(byte_length).ok();
18997 p.rows_processed = 0;
18998 p.rows_total = u64::try_from(row_count).ok();
18999 });
19000 let observer = self.progress.clone();
19001 let mut on_progress = |bytes| observer.update(|p| p.bytes_received = bytes);
19002 let bytes = if let Some(url) = url {
19003 if meta.accept & ACCEPT_SIGNED_URLS == 0 {
19008 return Err(SectionError::Abort(
19009 "sync.invalid_request".to_owned(),
19010 "SEGMENT_REF carries a url but accept bit 3 was not advertised (§5.4)"
19011 .to_owned(),
19012 ));
19013 }
19014 if url_expires_at_ms.is_some_and(|exp| {
19016 exp <= transport
19017 .segment_fetch_started_at(&url)
19018 .unwrap_or_else(|| self.clock_now_ms())
19019 }) {
19020 return Err(SectionError::Abort(
19021 "sync.segment_expired".to_owned(),
19022 format!(
19023 "signed URL for segment {segment_id} expired before fetch — re-pull mints fresh descriptors (§5.4)"
19024 ),
19025 ));
19026 }
19027 transport
19028 .fetch_url(&url, &mut on_progress)
19029 .map_err(|e| self.segment_fetch_failed(e))?
19030 } else {
19031 let requested_scopes_json =
19032 canonical_scope_json(&self.subs[sub_index].requested);
19033 transport
19034 .download_segment(
19035 &SegmentRequest {
19036 segment_id: segment_id.clone(),
19037 table,
19038 requested_scopes_json,
19039 },
19040 &mut on_progress,
19041 )
19042 .map_err(|e| self.segment_fetch_failed(e))?
19043 };
19044 on_progress(bytes.len() as u64);
19046 let digest = Sha256::digest(&bytes);
19047 let expected = segment_id
19048 .strip_prefix("sha256:")
19049 .unwrap_or(segment_id.as_str());
19050 if bytes_to_hex(&digest) != expected {
19051 self.schedule_background_retry();
19053 return Err(SectionError::Abort(
19054 "sync.invalid_request".to_owned(),
19055 "segment bytes do not match the content address (§5.1)".to_owned(),
19056 ));
19057 }
19058 self.progress.update(|p| p.phase = ProgressPhase::Import);
19059 if media_type == MediaType::Sqlite {
19060 if row_cursor.is_some() || next_row_cursor.is_some() {
19063 return Err(SectionError::Abort(
19064 "sync.invalid_request".to_owned(),
19065 "sqlite segments are whole-table: rowCursor/nextRowCursor must be absent (§5.3)"
19066 .to_owned(),
19067 ));
19068 }
19069 let first = !saw_segment;
19070 saw_segment = true;
19071 let applied = self.apply_sqlite_segment(
19072 sub_index,
19073 &bytes,
19074 fresh && first,
19075 row_count,
19076 as_of_commit_seq,
19077 &scope_digest,
19078 )?;
19079 report.segment_rows_applied += applied;
19080 } else {
19081 let segment = decode_rows_segment(&bytes).map_err(|e| {
19082 SectionError::Abort(e.code.as_str().to_owned(), e.detail)
19083 })?;
19084 let first = row_cursor.is_none();
19085 saw_segment = true;
19086 let applied = self.apply_segment(sub_index, &segment, fresh && first)?;
19087 report.segment_rows_applied += applied;
19088 }
19089 }
19090 Frame::Unknown { .. } => {}
19091 Frame::Error { code, message, .. } => {
19092 return Err(SectionError::Abort(code, message))
19093 }
19094 _ => {
19095 return Err(SectionError::Abort(
19096 "sync.invalid_request".to_owned(),
19097 "unexpected frame inside a subscription section".to_owned(),
19098 ));
19099 }
19100 }
19101 }
19102 Ok(())
19103 }
19104
19105 fn apply_commit_frame_coalesced(
19109 &mut self,
19110 tables: &[String],
19111 changes: Vec<ssp2::model::Change>,
19112 commit_seq: i64,
19113 coalesced_deletes: &HashSet<(i64, String, String)>,
19114 ) -> Result<bool, (String, String)> {
19115 let changes: Vec<ssp2::model::Change> = changes
19116 .into_iter()
19117 .filter(|change| {
19118 if change.op != Op::Delete {
19119 return true;
19120 }
19121 let Some(table) = tables.get(change.table_index as usize) else {
19122 return true;
19123 };
19124 !coalesced_deletes.contains(&(commit_seq, table.clone(), change.row_id.clone()))
19125 })
19126 .collect();
19127 if changes.is_empty() {
19128 return Ok(false);
19129 }
19130 self.apply_commit_frame(tables, &changes, Some(commit_seq))?;
19131 Ok(true)
19132 }
19133
19134 fn apply_commit_frame(
19136 &mut self,
19137 tables: &[String],
19138 changes: &[ssp2::model::Change],
19139 commit_seq: Option<i64>,
19140 ) -> Result<(), (String, String)> {
19141 #[cfg(feature = "bench-internals")]
19142 let _phase = self.benchmark_phases.start(Phase::CommitApply);
19143 let was_dirty = self.overlay_dirty.snapshot();
19144 self.coalesce_unit(changes.len())
19145 .and_then(|()| self.begin_observation("syncular_commit"))
19146 .map_err(|message| ("storage.failed".into(), message))?;
19147 let applied = (|| {
19148 let mut batch = ChangeAccumulator::default();
19149 self.record_commit_changes(&mut batch, tables, changes);
19150 if let Some(sequence) = commit_seq {
19151 for change in changes {
19152 let table = tables.get(change.table_index as usize).ok_or_else(|| {
19153 (
19154 "sync.invalid_request".to_owned(),
19155 "change tableIndex out of range".to_owned(),
19156 )
19157 })?;
19158 if self
19159 .outbox
19160 .iter()
19161 .flat_map(|commit| &commit.ops)
19162 .any(|op| op.table == *table && op.row_id == change.row_id)
19163 {
19164 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])
19165 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19166 }
19167 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])
19168 .map_err(|error| ("storage.failed".to_owned(), Self::sqlite_failure(&self.storage_failure, error)))?;
19169 }
19170 }
19171 self.apply_commit_changes(tables, changes)?;
19172 self.rebuild_overlay_if_dirty()
19173 .map_err(|message| ("storage.failed".into(), message))?;
19174 self.finish_observation("syncular_commit", batch)
19175 .map_err(|message| ("storage.failed".into(), message))
19176 })();
19177 if applied.is_err() {
19178 self.rollback_observation("syncular_commit");
19179 self.overlay_dirty.restore(was_dirty.clone());
19180 }
19181 applied
19182 }
19183
19184 fn apply_commit_changes(
19185 &mut self,
19186 tables: &[String],
19187 changes: &[ssp2::model::Change],
19188 ) -> Result<(), (String, String)> {
19189 let mirror_visible = !self.overlay_dirty.get()
19194 && self.failed_commits.is_empty()
19195 && ((self.outbox.is_empty() && !self.has_acknowledged_rows())
19196 || changes.iter().all(|change| {
19197 tables
19198 .get(change.table_index as usize)
19199 .and_then(|name| self.schema.table(name))
19200 .is_some_and(|table| !table.indexes.iter().any(|index| index.unique))
19201 }));
19202 for change in changes {
19203 let table_name = tables.get(change.table_index as usize).ok_or_else(|| {
19204 (
19205 "sync.invalid_request".to_owned(),
19206 "change tableIndex out of range".to_owned(),
19207 )
19208 })?;
19209 let table = self.schema.table(table_name).ok_or_else(|| {
19210 (
19211 "sync.schema_mismatch".to_owned(),
19212 format!("change targets unknown table {table_name:?}"),
19213 )
19214 })?;
19215 match change.op {
19216 Op::Upsert => {
19217 let payload = change.row.as_ref().ok_or_else(|| {
19218 (
19219 "sync.invalid_request".to_owned(),
19220 "upsert change without row payload".to_owned(),
19221 )
19222 })?;
19223 let row = {
19225 #[cfg(feature = "bench-internals")]
19226 let _phase = self.benchmark_phases.start(Phase::RowDecode);
19227 decode_row_bytes(table, payload, &self.encryption)
19228 .map_err(|m| ("sync.invalid_request".to_owned(), m))?
19229 };
19230 let version = change.row_version.unwrap_or(0);
19231 let table_name = table.name.clone();
19232 self.write_base_row(&table_name, &row, version)
19233 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19234 if mirror_visible {
19235 self.write_row(&visible_table(&table_name), &table_name, &row, version)
19236 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19237 }
19238 }
19239 Op::Delete => {
19240 self.delete_base_row(table_name, &change.row_id)
19241 .map_err(|m| ("sync.invalid_request".to_owned(), m))?;
19242 if mirror_visible {
19243 let sql = format!(
19244 "DELETE FROM {} WHERE {}",
19245 visible_table(table_name),
19246 row_id_predicate(table)
19247 );
19248 self.conn
19249 .prepare_cached(&sql)
19250 .and_then(|mut statement| {
19251 statement.execute(rusqlite::params![change.row_id])
19252 })
19253 .map_err(|error| {
19254 ("sync.invalid_request".to_owned(), error.to_string())
19255 })?;
19256 }
19257 }
19258 }
19259 }
19260 if mirror_visible {
19261 {
19262 let changed_rows: HashSet<(&str, &str)> = changes
19263 .iter()
19264 .map(|change| {
19265 (
19266 tables[change.table_index as usize].as_str(),
19267 change.row_id.as_str(),
19268 )
19269 })
19270 .collect();
19271 let acknowledged = self
19272 .acknowledged_operations(Some(&changed_rows), None)
19273 .map_err(|error| ("storage.failed".to_owned(), error))?;
19274 self.apply_outbox_ops(&acknowledged)
19275 .map_err(|message| ("storage.failed".into(), message))?;
19276 self.apply_outbox_ops(
19277 self.outbox
19278 .iter()
19279 .flat_map(|commit| &commit.ops)
19280 .filter(|op| {
19281 changed_rows.contains(&(op.table.as_str(), op.row_id.as_str()))
19282 }),
19283 )
19284 .map_err(|message| ("storage.failed".into(), message))?;
19285 }
19286 self.overlay_dirty.set(false);
19287 }
19288 Ok(())
19289 }
19290
19291 fn apply_segment(
19296 &mut self,
19297 sub_index: usize,
19298 segment: &RowsSegment,
19299 first_fresh_page: bool,
19300 ) -> Result<u32, SectionError> {
19301 let (sub_table, effective) = {
19302 let sub = &self.subs[sub_index];
19303 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19304 };
19305 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19306 SectionError::Abort(
19307 "sync.schema_mismatch".to_owned(),
19308 format!("subscription table {sub_table:?} is not in the client schema"),
19309 )
19310 })?;
19311 let matches = segment.table == table.name
19316 && segment.schema_version == self.schema.version
19317 && segment.columns.len() == table.wire_columns.len()
19318 && segment
19319 .columns
19320 .iter()
19321 .zip(table.wire_columns.iter())
19322 .all(|(a, b)| a.name == b.name && a.ty == b.ty && a.nullable == b.nullable);
19323 if !matches {
19324 return Err(SectionError::Abort(
19325 "sync.schema_mismatch".to_owned(),
19326 "segment column table does not match the generated schema (§5.2)".to_owned(),
19327 ));
19328 }
19329 let mut applied = 0u32;
19330 let empty = Vec::new();
19331 let blocks = if segment.blocks.is_empty() {
19332 std::slice::from_ref(&empty)
19333 } else {
19334 &segment.blocks
19335 };
19336 for (index, block) in blocks.iter().enumerate() {
19337 let clear = first_fresh_page && index == 0;
19338 if !clear && block.is_empty() {
19339 continue;
19340 }
19341 let was_dirty = self.overlay_dirty.snapshot();
19342 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19343 && self
19344 .outbox
19345 .iter()
19346 .flat_map(|commit| &commit.ops)
19347 .any(|op| op.table == table.name);
19348 let mirror_visible = !reconcile_pending
19349 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19350 self.coalesce_unit(block.len())
19351 .and_then(|()| self.begin_observation("syncular_segment_block"))
19352 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19353 let outcome = (|| {
19354 self.rebuild_overlay_if_dirty()
19355 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19356 let mut changed_keys = Vec::new();
19357 let mut batch = ChangeAccumulator::default();
19358 if !block.is_empty() || (clear && self.scoped_rows_exist(&table.name, &effective)) {
19359 batch.table(&table.name);
19360 }
19361 if clear {
19362 self.purge_scope_rows(&table.name, &effective)
19363 .map_err(|()| SectionError::FailClosed)?;
19364 }
19365 for (row_index, row) in block.iter().enumerate() {
19366 let decrypted;
19367 let values = if table.has_encrypted_columns() {
19368 let mut values = row.values.clone();
19369 crate::values::decrypt_segment_row(&table, &mut values, &self.encryption)
19370 .map_err(|message| {
19371 SectionError::Abort("client.decrypt_failed".into(), message)
19372 })?;
19373 decrypted = values;
19374 &decrypted
19375 } else {
19376 &row.values
19377 };
19378 self.write_base_row(&table.name, values, row.server_version)
19379 .map_err(|message| {
19380 SectionError::Abort("sync.invalid_request".into(), message)
19381 })?;
19382 if reconcile_pending {
19383 changed_keys.push(
19384 owned_sql_value(RowParam::Cell(&values[table.pk_index])).map_err(
19385 |message| SectionError::Abort("storage.failed".into(), message),
19386 )?,
19387 );
19388 }
19389 if mirror_visible {
19390 self.write_row(
19391 &visible_table(&table.name),
19392 &table.name,
19393 values,
19394 row.server_version,
19395 )
19396 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19397 }
19398 let processed = u64::from(applied) + row_index as u64 + 1;
19399 if processed.is_multiple_of(1024) {
19400 self.progress.update(|p| p.rows_processed = processed);
19401 }
19402 }
19403 if reconcile_pending {
19404 self.reconcile_imported_rows(&table, &mut changed_keys)
19405 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19406 }
19407 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19408 self.apply_outbox_ops(
19409 self.outbox
19410 .iter()
19411 .flat_map(|commit| &commit.ops)
19412 .filter(|op| op.table == table.name),
19413 )
19414 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19415 self.overlay_dirty.set(false);
19416 } else {
19417 self.rebuild_overlay_if_dirty()
19418 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19419 }
19420 self.finish_observation("syncular_segment_block", batch)
19421 .map_err(|message| SectionError::Abort("storage.failed".into(), message))
19422 })();
19423 if let Err(error) = outcome {
19424 self.rollback_observation("syncular_segment_block");
19425 self.overlay_dirty.restore(was_dirty.clone());
19426 return Err(error);
19427 }
19428 applied += block.len() as u32;
19429 self.progress
19430 .update(|p| p.rows_processed = u64::from(applied));
19431 std::thread::yield_now();
19432 }
19433 self.progress
19434 .update(|p| p.rows_processed = u64::from(applied));
19435 Ok(applied)
19436 }
19437
19438 fn apply_sqlite_segment(
19446 &mut self,
19447 sub_index: usize,
19448 bytes: &[u8],
19449 first_fresh_page: bool,
19450 row_count: i64,
19451 as_of_commit_seq: i64,
19452 scope_digest: &str,
19453 ) -> Result<u32, SectionError> {
19454 let invalid = |detail: &str| {
19455 SectionError::Abort(
19456 "sync.invalid_request".to_owned(),
19457 format!("sqlite segment rejected: {detail} (§5.3)"),
19458 )
19459 };
19460 let (sub_table, effective) = {
19461 let sub = &self.subs[sub_index];
19462 (sub.table.clone(), sub.effective.clone().unwrap_or_default())
19463 };
19464 let table = self.schema.table(&sub_table).cloned().ok_or_else(|| {
19465 SectionError::Abort(
19466 "sync.schema_mismatch".to_owned(),
19467 format!("subscription table {sub_table:?} is not in the client schema"),
19468 )
19469 })?;
19470
19471 let path = std::env::temp_dir().join(format!("syncular-image-{}.db", uuid::Uuid::new_v4()));
19472 std::fs::write(&path, bytes).map_err(|_| invalid("image temp file write failed"))?;
19473 let img = match rusqlite::Connection::open_with_flags(
19474 &path,
19475 rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
19476 ) {
19477 Ok(conn) => conn,
19478 Err(_) => {
19479 let _ = std::fs::remove_file(&path);
19480 return Err(invalid("bytes do not open as a SQLite database"));
19481 }
19482 };
19483 let outcome = self.apply_sqlite_image(
19484 &img,
19485 &table,
19486 first_fresh_page,
19487 &effective,
19488 row_count,
19489 as_of_commit_seq,
19490 scope_digest,
19491 );
19492 drop(img);
19493 let _ = std::fs::remove_file(&path);
19494 outcome
19495 }
19496
19497 #[allow(clippy::too_many_arguments)]
19498 fn apply_sqlite_image(
19499 &mut self,
19500 img: &rusqlite::Connection,
19501 table: &crate::schema::TableSchema,
19502 first_fresh_page: bool,
19503 effective: &[(String, Vec<String>)],
19504 row_count: i64,
19505 as_of_commit_seq: i64,
19506 scope_digest: &str,
19507 ) -> Result<u32, SectionError> {
19508 let invalid = |detail: String| {
19509 SectionError::Abort(
19510 "sync.invalid_request".to_owned(),
19511 format!("sqlite segment rejected: {detail} (§5.3)"),
19512 )
19513 };
19514
19515 type MetaRow = (i64, String, i64, i64, String, i64, i64);
19518 let meta: MetaRow = img
19519 .query_row(
19520 "SELECT format, \"table\", \"schemaVersion\", \"asOfCommitSeq\",
19521 \"scopeDigest\", \"rowCount\",
19522 (SELECT count(*) FROM _syncular_segment)
19523 FROM _syncular_segment",
19524 [],
19525 |row| {
19526 Ok((
19527 row.get(0)?,
19528 row.get(1)?,
19529 row.get(2)?,
19530 row.get(3)?,
19531 row.get(4)?,
19532 row.get(5)?,
19533 row.get(6)?,
19534 ))
19535 },
19536 )
19537 .map_err(|_| invalid("missing or unreadable _syncular_segment metadata".to_owned()))?;
19538 let (format, meta_table, schema_version, pin, digest, meta_rows, meta_count) = meta;
19539 if meta_count != 1 {
19540 return Err(invalid(format!(
19541 "_syncular_segment must contain exactly one row, found {meta_count}"
19542 )));
19543 }
19544 if format != 1 {
19545 return Err(invalid(format!("format {format}")));
19546 }
19547 if meta_table != table.name {
19548 return Err(invalid(format!("image table {meta_table:?}")));
19549 }
19550 if schema_version != i64::from(self.schema.version) {
19551 return Err(invalid(format!("schemaVersion {schema_version}")));
19552 }
19553 if pin != as_of_commit_seq {
19554 return Err(invalid(format!("asOfCommitSeq {pin}")));
19555 }
19556 if digest != scope_digest {
19557 return Err(invalid("scopeDigest mismatch".to_owned()));
19558 }
19559 if meta_rows != row_count {
19560 return Err(invalid(format!("rowCount {meta_rows}")));
19561 }
19562
19563 let mut names: Vec<String> = Vec::new();
19565 let mut primary_keys: Vec<String> = Vec::new();
19566 {
19567 let mut stmt = img
19568 .prepare(&format!("PRAGMA table_info({})", quote_ident(&table.name)))
19569 .map_err(|_| invalid("image data table missing".to_owned()))?;
19570 let mut rows = stmt
19571 .query([])
19572 .map_err(|_| invalid("image data table unreadable".to_owned()))?;
19573 while let Some(row) = rows
19574 .next()
19575 .map_err(|_| invalid("image data table unreadable".to_owned()))?
19576 {
19577 if row
19578 .get::<_, i64>(5)
19579 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?
19580 > 0
19581 {
19582 primary_keys.push(row.get(1).map_err(|error| {
19583 invalid(Self::sqlite_failure(&self.storage_failure, error))
19584 })?);
19585 }
19586 names.push(
19587 row.get::<_, String>(1)
19588 .map_err(|_| invalid("image data table unreadable".to_owned()))?,
19589 );
19590 }
19591 }
19592 let mut expected: Vec<&str> = table.columns.iter().map(|c| c.name.as_str()).collect();
19593 expected.push("_syncular_version");
19594 if names.len() != expected.len() || names.iter().zip(expected.iter()).any(|(a, b)| a != b) {
19595 return Err(SectionError::Abort(
19596 "sync.schema_mismatch".to_owned(),
19597 "sqlite segment columns do not match the generated schema (§5.3)".to_owned(),
19598 ));
19599 }
19600
19601 if primary_keys != vec![table.primary_key.clone()] {
19602 return Err(invalid(
19603 "image primary key does not match generated schema".into(),
19604 ));
19605 }
19606 let actual_count: i64 = img
19607 .query_row(
19608 &format!("SELECT count(*) FROM {}", quote_ident(&table.name)),
19609 [],
19610 |row| row.get(0),
19611 )
19612 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19613 if actual_count != row_count {
19614 return Err(invalid("image row count does not match descriptor".into()));
19615 }
19616 let insert = self.insert_row_sql(&base_table(&table.name), table);
19617 let visible_insert = self.insert_row_sql(&visible_table(&table.name), table);
19618 let column_list = names
19619 .iter()
19620 .map(|n| quote_ident(n))
19621 .collect::<Vec<_>>()
19622 .join(", ");
19623 let mut stmt = img
19624 .prepare(&format!(
19625 "SELECT {column_list} FROM {} ORDER BY {}",
19626 quote_ident(&table.name),
19627 quote_ident(&table.primary_key)
19628 ))
19629 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19630 let mut rows = stmt
19631 .query([])
19632 .map_err(|error| invalid(Self::sqlite_failure(&self.storage_failure, error)))?;
19633 let mut applied = 0u32;
19634 loop {
19635 let clear = applied == 0 && first_fresh_page;
19636 let was_dirty = self.overlay_dirty.snapshot();
19637 let reconcile_pending = table.indexes.iter().any(|index| index.unique)
19638 && self
19639 .outbox
19640 .iter()
19641 .flat_map(|commit| &commit.ops)
19642 .any(|op| op.table == table.name);
19643 let mirror_visible = !reconcile_pending
19644 && (self.failed_commits.is_empty() && !self.has_acknowledged_rows());
19645 self.coalesce_unit(1024)
19647 .and_then(|()| self.begin_observation("syncular_image_chunk"))
19648 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19649 let result = (|| {
19650 self.rebuild_overlay_if_dirty()
19651 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19652 let mut changed_keys = Vec::new();
19653 let mut batch = ChangeAccumulator::default();
19654 if clear {
19655 if self.scoped_rows_exist(&table.name, effective) {
19656 batch.table(&table.name);
19657 }
19658 self.purge_scope_rows(&table.name, effective)
19659 .map_err(|()| SectionError::FailClosed)?;
19660 }
19661 let mut processed = 0u32;
19662 {
19663 let mut ins = self.conn.prepare_cached(&insert).map_err(|error| {
19664 invalid(Self::sqlite_failure(&self.storage_failure, error))
19665 })?;
19666 let mut mirror = if mirror_visible {
19667 Some(self.conn.prepare_cached(&visible_insert).map_err(|error| {
19668 invalid(Self::sqlite_failure(&self.storage_failure, error))
19669 })?)
19670 } else {
19671 None
19672 };
19673 for _ in 0..1024 {
19674 let Some(row) = rows.next().map_err(|error| {
19675 invalid(Self::sqlite_failure(&self.storage_failure, error))
19676 })?
19677 else {
19678 break;
19679 };
19680 for (i, column) in table.columns.iter().enumerate() {
19681 let cell = row.get_ref(i).map_err(|error| {
19682 invalid(Self::sqlite_failure(&self.storage_failure, error))
19683 })?;
19684 let param = image_cell_param(column, cell).map_err(invalid)?;
19685 ins.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19686 invalid(Self::sqlite_failure(&self.storage_failure, error))
19687 })?;
19688 if let Some(mirror) = &mut mirror {
19689 mirror.raw_bind_parameter(i + 1, ¶m).map_err(|error| {
19690 invalid(Self::sqlite_failure(&self.storage_failure, error))
19691 })?;
19692 }
19693 }
19694 let version_index = table.columns.len();
19695 let version: i64 = row.get(version_index).map_err(|error| {
19696 invalid(Self::sqlite_failure(&self.storage_failure, error))
19697 })?;
19698 if version < 1 {
19699 return Err(invalid("image row version must be positive".into()));
19700 }
19701 ins.raw_bind_parameter(version_index + 1, version)
19702 .map_err(|error| {
19703 invalid(Self::sqlite_failure(&self.storage_failure, error))
19704 })?;
19705 ins.raw_execute().map_err(|error| {
19706 invalid(Self::sqlite_failure(&self.storage_failure, error))
19707 })?;
19708 if let Some(mirror) = &mut mirror {
19709 mirror
19710 .raw_bind_parameter(version_index + 1, version)
19711 .map_err(|error| {
19712 invalid(Self::sqlite_failure(&self.storage_failure, error))
19713 })?;
19714 mirror.raw_execute().map_err(|error| {
19715 invalid(Self::sqlite_failure(&self.storage_failure, error))
19716 })?;
19717 }
19718 if reconcile_pending {
19719 changed_keys.push(row.get::<_, SqlValue>(table.pk_index).map_err(
19720 |error| invalid(Self::sqlite_failure(&self.storage_failure, error)),
19721 )?);
19722 }
19723 processed += 1;
19724 }
19725 }
19726 if processed > 0 {
19727 batch.table(&table.name);
19728 self.overlay_dirty.table(&table.name);
19729 }
19730 if reconcile_pending {
19731 self.reconcile_imported_rows(table, &mut changed_keys)
19732 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19733 }
19734 if self.failed_commits.is_empty() && !self.has_acknowledged_rows() {
19735 self.apply_outbox_ops(
19736 self.outbox
19737 .iter()
19738 .flat_map(|commit| &commit.ops)
19739 .filter(|op| op.table == table.name),
19740 )
19741 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19742 self.overlay_dirty.set(false);
19743 } else {
19744 self.rebuild_overlay_if_dirty()
19745 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19746 }
19747 self.finish_observation("syncular_image_chunk", batch)
19748 .map_err(|message| SectionError::Abort("storage.failed".into(), message))?;
19749 Ok(processed)
19750 })();
19751 let processed = match result {
19752 Ok(processed) => processed,
19753 Err(error) => {
19754 self.rollback_observation("syncular_image_chunk");
19755 self.overlay_dirty.restore(was_dirty.clone());
19756 return Err(error);
19757 }
19758 };
19759 applied += processed;
19760 self.progress
19761 .update(|p| p.rows_processed = u64::from(applied));
19762 std::thread::yield_now();
19763 if i64::from(applied) == actual_count {
19764 break;
19765 }
19766 }
19767 Ok(applied)
19768 }
19769
19770 fn reconcile_imported_rows(
19774 &self,
19775 table: &TableSchema,
19776 keys: &mut Vec<SqlValue>,
19777 ) -> Result<(), String> {
19778 let visible = visible_table(&table.name);
19779 let base = base_table(&table.name);
19780 let pk = quote_ident(&table.primary_key);
19781 let predicate = row_id_predicate(table);
19782 let mut lookup = self.conn.prepare_cached(&format!(
19783 "SELECT {pk} FROM {base} WHERE {predicate} UNION SELECT {pk} FROM {visible} WHERE {predicate}"
19784 )).map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19785 let mut deleted = HashSet::new();
19786 for op in self
19787 .outbox
19788 .iter()
19789 .flat_map(|commit| &commit.ops)
19790 .filter(|op| op.table == table.name)
19791 {
19792 if op.upsert {
19793 if let Some(values) = &op.values {
19794 let key = json_to_column_value(
19795 &table.columns[table.pk_index],
19796 values.get(&table.primary_key),
19797 )?;
19798 keys.push(owned_sql_value(RowParam::Cell(&key))?);
19799 }
19800 } else if deleted.insert(&op.row_id) {
19801 keys.extend(
19802 lookup
19803 .query_map([&op.row_id], |row| row.get::<_, SqlValue>(0))
19804 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
19805 .collect::<Result<Vec<_>, _>>()
19806 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19807 );
19808 }
19809 }
19810 for chunk in keys.chunks(400) {
19813 let holes = vec!["?"; chunk.len()].join(",");
19814 self.conn
19815 .prepare_cached(&format!("DELETE FROM {visible} WHERE {pk} IN ({holes})"))
19816 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19817 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19818 }
19819 let updates = table
19820 .columns
19821 .iter()
19822 .map(|column| column.name.as_str())
19823 .chain(std::iter::once("_syncular_version"))
19824 .filter(|name| *name != table.primary_key)
19825 .map(|name| {
19826 let name = quote_ident(name);
19827 format!("{name}=excluded.{name}")
19828 })
19829 .collect::<Vec<_>>()
19830 .join(",");
19831 for chunk in keys.chunks(400) {
19832 let holes = vec!["?"; chunk.len()].join(",");
19833 self.conn.prepare_cached(&format!("INSERT INTO {visible} SELECT * FROM {base} WHERE {pk} IN ({holes}) ON CONFLICT ({pk}) DO UPDATE SET {updates}"))
19834 .and_then(|mut statement| statement.execute(rusqlite::params_from_iter(chunk)))
19835 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19836 }
19837 Ok(())
19838 }
19839
19840 fn compile_local_data_purge(
19843 &self,
19844 input: &LocalDataPurgeInput,
19845 ) -> Result<(Vec<CompiledLocalDataPurgeTarget>, String), String> {
19846 let invalid = |message: String| format!("sync.invalid_request: {message}");
19847 if input.purge_id.is_empty()
19848 || input.purge_id.len() > 128
19849 || !is_local_operation_code_like(&input.purge_id)
19850 {
19851 return Err(invalid(
19852 "local purge purgeId must be a 1–128 character code-like identifier".to_owned(),
19853 ));
19854 }
19855 if input.targets.is_empty() || input.targets.len() > MAX_LOCAL_PURGE_TARGETS {
19856 return Err(invalid(format!(
19857 "local purge needs between 1 and {MAX_LOCAL_PURGE_TARGETS} targets"
19858 )));
19859 }
19860
19861 let mut deduplicated: BTreeMap<
19862 String,
19863 (CompiledLocalDataPurgeTarget, LocalDataPurgeTarget),
19864 > = BTreeMap::new();
19865 for target in &input.targets {
19866 let table = self.schema.table(&target.table).ok_or_else(|| {
19867 invalid(format!(
19868 "local purge names unknown table {:?}",
19869 target.table
19870 ))
19871 })?;
19872 if target.selectors.is_empty() || target.selectors.len() > MAX_LOCAL_PURGE_SELECTORS {
19873 return Err(invalid(format!(
19874 "local purge target {:?} needs between 1 and {MAX_LOCAL_PURGE_SELECTORS} selectors",
19875 target.table
19876 )));
19877 }
19878 let mut selectors = Vec::with_capacity(target.selectors.len());
19879 let mut canonical_selectors = BTreeMap::new();
19880 for (column_name, raw_values) in &target.selectors {
19881 let Some((column_index, column)) = table
19882 .columns
19883 .iter()
19884 .enumerate()
19885 .find(|(_, column)| column.name == *column_name)
19886 else {
19887 return Err(invalid(format!(
19888 "local purge target {:?} names unknown column {:?}",
19889 target.table, column_name
19890 )));
19891 };
19892 let encrypted = table
19893 .encrypted_columns
19894 .iter()
19895 .any(|candidate| candidate.index == column_index);
19896 if column.ty != ColumnType::String || encrypted {
19897 return Err(invalid(format!(
19898 "local purge selector {:?}.{:?} must be a plaintext string column",
19899 target.table, column_name
19900 )));
19901 }
19902 if raw_values.is_empty() || raw_values.len() > MAX_LOCAL_PURGE_VALUES {
19903 return Err(invalid(format!(
19904 "local purge selector {:?}.{:?} needs between 1 and {MAX_LOCAL_PURGE_VALUES} values",
19905 target.table, column_name
19906 )));
19907 }
19908 let mut values = raw_values.clone();
19909 values.sort();
19910 values.dedup();
19911 if values.iter().any(|value| {
19912 value.is_empty()
19913 || value.len() > MAX_LOCAL_PURGE_VALUE_LENGTH
19914 || !is_local_operation_code_like(value)
19915 }) {
19916 return Err(invalid(format!(
19917 "local purge selector values must be 1–{MAX_LOCAL_PURGE_VALUE_LENGTH} character code-like identifiers"
19918 )));
19919 }
19920 selectors.push((column_name.clone(), values.clone()));
19921 canonical_selectors.insert(column_name.clone(), values);
19922 }
19923 let canonical = LocalDataPurgeTarget {
19924 table: target.table.clone(),
19925 selectors: canonical_selectors,
19926 };
19927 let key = serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19928 deduplicated.insert(
19929 key,
19930 (
19931 CompiledLocalDataPurgeTarget {
19932 table: target.table.clone(),
19933 selectors,
19934 },
19935 canonical,
19936 ),
19937 );
19938 }
19939 let targets = deduplicated
19940 .values()
19941 .map(|(compiled, _)| compiled.clone())
19942 .collect::<Vec<_>>();
19943 let canonical = deduplicated
19944 .values()
19945 .map(|(_, target)| target.clone())
19946 .collect::<Vec<_>>();
19947 let canonical_plan =
19948 serde_json::to_string(&canonical).map_err(|error| error.to_string())?;
19949 Ok((targets, canonical_plan))
19950 }
19951
19952 fn local_purge_base_row_ids(
19953 &self,
19954 targets: &[CompiledLocalDataPurgeTarget],
19955 ) -> Result<BTreeMap<String, BTreeSet<String>>, String> {
19956 let mut by_table: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
19957 for target in targets {
19958 let table = self
19959 .schema
19960 .table(&target.table)
19961 .ok_or_else(|| format!("sync.invalid_request: unknown table {:?}", target.table))?;
19962 let mut clauses = Vec::with_capacity(target.selectors.len());
19963 let mut params = Vec::new();
19964 for (column, values) in &target.selectors {
19965 clauses.push(format!(
19966 "{} IN ({})",
19967 quote_ident(column),
19968 values.iter().map(|_| "?").collect::<Vec<_>>().join(", ")
19969 ));
19970 params.extend(values.iter().cloned().map(SqlValue::Text));
19971 }
19972 let sql = format!(
19973 "SELECT CAST({} AS TEXT) FROM {} WHERE {}",
19974 quote_ident(&table.primary_key),
19975 base_table(&target.table),
19976 clauses.join(" AND ")
19977 );
19978 let mut statement = self
19979 .conn
19980 .prepare(&sql)
19981 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19982 let rows = statement
19983 .query_map(rusqlite::params_from_iter(params), |row| {
19984 let value = sql_ref_to_json(&table.columns[table.pk_index], row.get_ref(0)?);
19985 render_row_id_json(Some(&value))
19986 .map_err(|message| rusqlite::Error::ToSqlConversionFailure(message.into()))
19987 })
19988 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
19989 let ids = by_table.entry(target.table.clone()).or_default();
19990 for row in rows {
19991 ids.insert(
19992 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?,
19993 );
19994 }
19995 }
19996 Ok(by_table)
19997 }
19998
19999 fn local_purge_values_match(
20000 target: &CompiledLocalDataPurgeTarget,
20001 values: &Map<String, Value>,
20002 ) -> bool {
20003 target.selectors.iter().all(|(column, allowed)| {
20004 values
20005 .get(column)
20006 .and_then(Value::as_str)
20007 .is_some_and(|value| allowed.iter().any(|candidate| candidate == value))
20008 })
20009 }
20010
20011 pub fn purge_local_data(
20015 &mut self,
20016 input: &LocalDataPurgeInput,
20017 ) -> Result<LocalDataPurgeResult, String> {
20018 let (targets, canonical_plan) = self.compile_local_data_purge(input)?;
20019 let meta_key = format!("localPurge:{}", input.purge_id);
20020 let applied_plan = self
20021 .conn
20022 .query_row(
20023 "SELECT value FROM _syncular_meta WHERE key = ?1",
20024 rusqlite::params![meta_key],
20025 |row| row.get::<_, String>(0),
20026 )
20027 .optional()
20028 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20029 if let Some(applied_plan) = applied_plan {
20030 if applied_plan != canonical_plan {
20031 return Err(format!(
20032 "sync.invalid_request: local purge id {:?} was already used with a different plan",
20033 input.purge_id
20034 ));
20035 }
20036 return Ok(LocalDataPurgeResult {
20037 already_applied: true,
20038 purged_rows: 0,
20039 dropped_commits: 0,
20040 });
20041 }
20042
20043 let _ = self.drop_previous_version();
20047
20048 let prior_outbox = self.outbox.clone();
20049 let prior_failed = self.failed_commits.clone();
20050 let prior_rejections = self.rejections.clone();
20051 let prior_conflicts = self.conflicts.clone();
20052 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20053 self.begin_observation("syncular_local_purge")?;
20054 let applied = (|| -> Result<(ChangeAccumulator, LocalDataPurgeResult), String> {
20055 let row_ids = self.local_purge_base_row_ids(&targets)?;
20056 let retained_dropped = self.drop_failed_commits(|table, values| {
20057 targets.iter().any(|target| {
20058 target.table == table && Self::local_purge_values_match(target, values)
20059 })
20060 })?;
20061 let doomed = self
20062 .outbox
20063 .iter()
20064 .filter(|commit| {
20065 commit.ops.iter().any(|operation| {
20066 let matching_targets = targets
20067 .iter()
20068 .filter(|target| target.table == operation.table)
20069 .collect::<Vec<_>>();
20070 if matching_targets.is_empty() {
20071 return false;
20072 }
20073 if row_ids
20074 .get(&operation.table)
20075 .is_some_and(|ids| ids.contains(&operation.row_id))
20076 {
20077 return true;
20078 }
20079 operation.values.as_ref().is_some_and(|values| {
20080 matching_targets
20081 .iter()
20082 .any(|target| Self::local_purge_values_match(target, values))
20083 })
20084 })
20085 })
20086 .cloned()
20087 .collect::<Vec<_>>();
20088 let doomed_ids = doomed
20089 .iter()
20090 .map(|commit| commit.client_commit_id.clone())
20091 .collect::<BTreeSet<_>>();
20092 let mut batch = ChangeAccumulator::default();
20093 for commit in &retained_dropped {
20094 batch.conflicts = true;
20095 batch.rejections = true;
20096 batch.outcomes = true;
20097 for operation in &commit.ops {
20098 batch.table(&operation.table);
20099 }
20100 }
20101 let mut rejections = Vec::new();
20102 for commit in &doomed {
20103 for operation in &commit.ops {
20104 batch.table(&operation.table);
20105 }
20106 let results = commit
20107 .ops
20108 .iter()
20109 .enumerate()
20110 .map(|(op_index, operation)| {
20111 let rejection = RejectionRecord {
20112 client_commit_id: commit.client_commit_id.clone(),
20113 op_index: op_index as i32,
20114 code: "client.local_data_purged".to_owned(),
20115 message: "the commit was dropped by an application-authorized local data purge".to_owned(),
20116 retryable: false,
20117 details: None,
20118 operation: Some(CommitOperation::from(operation)),
20119 };
20120 rejections.push(rejection.clone());
20121 CommitOperationOutcome::Error { rejection }
20122 })
20123 .collect::<Vec<_>>();
20124 self.persist_commit_outcome(
20125 &commit.client_commit_id,
20126 CommitOutcomeStatus::Rejected,
20127 &results,
20128 Some(&commit.ops),
20129 )?;
20130 self.delete_outbox_persisted(&commit.client_commit_id)?;
20131 }
20132 if !doomed.is_empty() {
20133 self.outbox
20134 .retain(|commit| !doomed_ids.contains(&commit.client_commit_id));
20135 self.rejections.extend(rejections);
20136 self.prune_commit_outcomes()?;
20137 batch.status = true;
20138 batch.rejections = true;
20139 batch.outcomes = true;
20140 }
20141
20142 let mut purged_rows = 0usize;
20143 for (table, ids) in &row_ids {
20144 if ids.is_empty() {
20145 continue;
20146 }
20147 batch.table(table);
20148 for row_id in ids {
20149 self.delete_base_row(table, row_id)?;
20150 purged_rows += 1;
20151 }
20152 }
20153 self.rebuild_overlay_if_dirty()?;
20154 self.delete_unreferenced_cached_blobs()?;
20155 self.conn
20156 .execute(
20157 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20158 rusqlite::params![meta_key, canonical_plan],
20159 )
20160 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20161 Ok((
20162 batch,
20163 LocalDataPurgeResult {
20164 already_applied: false,
20165 purged_rows,
20166 dropped_commits: doomed.len(),
20167 },
20168 ))
20169 })();
20170 let (batch, result) = match applied {
20171 Ok(value) => value,
20172 Err(error) => {
20173 self.rollback_observation("syncular_local_purge");
20174 self.outbox = prior_outbox;
20175 self.failed_commits = prior_failed;
20176 self.rejections = prior_rejections;
20177 self.conflicts = prior_conflicts;
20178 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20179 return Err(error);
20180 }
20181 };
20182 if let Err(error) = self.finish_observation("syncular_local_purge", batch) {
20183 self.rollback_observation("syncular_local_purge");
20184 self.outbox = prior_outbox;
20185 self.failed_commits = prior_failed;
20186 self.rejections = prior_rejections;
20187 self.conflicts = prior_conflicts;
20188 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20189 return Err(error);
20190 }
20191 self.cancel_sync_round();
20192 Ok(result)
20193 }
20194
20195 pub fn rebootstrap_local_data(
20201 &mut self,
20202 input: &LocalDataRebootstrapInput,
20203 ) -> Result<LocalDataRebootstrapResult, String> {
20204 if input.rebootstrap_id.is_empty()
20205 || input.rebootstrap_id.len() > 128
20206 || !is_local_operation_code_like(&input.rebootstrap_id)
20207 {
20208 return Err(
20209 "sync.invalid_request: local rebootstrap rebootstrapId must be a 1–128 character code-like identifier"
20210 .to_owned(),
20211 );
20212 }
20213 let meta_key = format!("localRebootstrap:{}", input.rebootstrap_id);
20214 if let Some(persisted) = self.get_meta_strict(&meta_key)? {
20215 let (retained_commits, reset_subscriptions) =
20216 decode_local_rebootstrap_receipt(&persisted)?;
20217 return Ok(LocalDataRebootstrapResult {
20218 already_applied: true,
20219 retained_commits,
20220 reset_subscriptions,
20221 });
20222 }
20223 if self.stopped || self.schema_floor.is_some() {
20224 return Err(
20225 "sync.invalid_request: local rebootstrap cannot bypass an active schema-floor stop; update the application first"
20226 .to_owned(),
20227 );
20228 }
20229
20230 let retained_commits = self.outbox.len();
20231 let reset_subscriptions = self.subs.len();
20232 let prior_subs = self.subs.clone();
20233 let prior_upgrading = self.upgrading;
20234 let prior_stopped = self.stopped;
20235 let prior_schema_floor = self.schema_floor.clone();
20236 let prior_overlay_dirty = self.overlay_dirty.snapshot();
20237 let prior_sync_needed = self.sync_needed;
20238 let prior_sync_intents = self.sync_intent_queue.clone();
20239 let receipt = encode_local_rebootstrap_receipt(retained_commits, reset_subscriptions)?;
20240
20241 let prior_active_round = self.active_round;
20242 self.begin_observation("syncular_local_rebootstrap")?;
20243 let mut batch = ChangeAccumulator::default();
20244 let applied = (|| -> Result<(), String> {
20245 self.run_schema_reset_observed(&mut batch, false)?;
20246 self.conn
20247 .execute(
20248 "INSERT INTO _syncular_meta(key, value) VALUES (?1, ?2)",
20249 rusqlite::params![meta_key, receipt],
20250 )
20251 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20252 self.sync_needed = true;
20253 self.sync_intent_queue.push_back(SyncIntent::Interactive);
20254 batch.status = true;
20255 Ok(())
20256 })();
20257 if let Err(error) = applied {
20258 self.rollback_observation("syncular_local_rebootstrap");
20259 self.active_round = prior_active_round;
20260 self.subs = prior_subs;
20261 self.upgrading = prior_upgrading;
20262 self.stopped = prior_stopped;
20263 self.schema_floor = prior_schema_floor;
20264 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20265 self.sync_needed = prior_sync_needed;
20266 self.sync_intent_queue = prior_sync_intents;
20267 return Err(error);
20268 }
20269 if let Err(error) = self.finish_observation("syncular_local_rebootstrap", batch) {
20270 self.rollback_observation("syncular_local_rebootstrap");
20271 self.active_round = prior_active_round;
20272 self.subs = prior_subs;
20273 self.upgrading = prior_upgrading;
20274 self.stopped = prior_stopped;
20275 self.schema_floor = prior_schema_floor;
20276 self.overlay_dirty.restore(prior_overlay_dirty.clone());
20277 self.sync_needed = prior_sync_needed;
20278 self.sync_intent_queue = prior_sync_intents;
20279 return Err(error);
20280 }
20281
20282 Ok(LocalDataRebootstrapResult {
20283 already_applied: false,
20284 retained_commits,
20285 reset_subscriptions,
20286 })
20287 }
20288
20289 fn purge_scope_rows(
20294 &mut self,
20295 table_name: &str,
20296 effective: &[(String, Vec<String>)],
20297 ) -> Result<(), ()> {
20298 if effective.is_empty() {
20299 return Ok(());
20300 }
20301 let table = self.schema.table(table_name).ok_or(())?.clone();
20302 let mut clauses = Vec::new();
20303 let mut params: Vec<SqlValue> = Vec::new();
20304 for (variable, values) in effective {
20305 let column = table.scope_column(variable).ok_or(())?;
20306 let placeholders: Vec<String> = values
20307 .iter()
20308 .map(|v| {
20309 params.push(SqlValue::Text(v.clone()));
20310 "?".to_owned()
20311 })
20312 .collect();
20313 clauses.push(format!(
20314 "{} IN ({})",
20315 quote_ident(column),
20316 placeholders.join(", ")
20317 ));
20318 }
20319 let sql = format!(
20320 "DELETE FROM {} WHERE {}",
20321 base_table(table_name),
20322 clauses.join(" AND ")
20323 );
20324 self.overlay_dirty.table(table_name);
20325 self.conn
20326 .execute(&sql, rusqlite::params_from_iter(¶ms))
20327 .map_err(|_| ())?;
20328 self.conn
20329 .execute(
20330 &format!(
20331 "DELETE FROM {} WHERE {}",
20332 visible_table(table_name),
20333 clauses.join(" AND ")
20334 ),
20335 rusqlite::params_from_iter(¶ms),
20336 )
20337 .map_err(|_| ())?;
20338 Ok(())
20339 }
20340
20341 fn drop_doomed_outbox(
20344 &mut self,
20345 table_name: &str,
20346 effective: &[(String, Vec<String>)],
20347 ) -> Result<bool, String> {
20348 if effective.is_empty() {
20349 return Ok(false);
20350 }
20351 let Some(table) = self.schema.table(table_name).cloned() else {
20352 return Ok(false);
20353 };
20354 let mut mappings: Vec<(&str, &Vec<String>)> = Vec::new();
20355 for (variable, values) in effective {
20356 match table.scope_column(variable) {
20357 Some(column) => mappings.push((column, values)),
20358 None => return Ok(false), }
20360 }
20361 let doomed: Vec<OutboxCommit> = self
20362 .outbox
20363 .iter()
20364 .filter(|commit| {
20365 commit.ops.iter().any(|op| {
20366 op.upsert
20367 && op.table == table_name
20368 && op.values.as_ref().is_some_and(|values| {
20369 mappings
20370 .iter()
20371 .all(|(column, allowed)| match values.get(*column) {
20372 Some(Value::String(s)) => allowed.contains(s),
20373 Some(Value::Number(n)) => allowed.contains(&n.to_string()),
20374 Some(Value::Bool(b)) => allowed.contains(&b.to_string()),
20375 _ => false,
20376 })
20377 })
20378 })
20379 })
20380 .cloned()
20381 .collect();
20382 if doomed.is_empty() {
20383 return Ok(false);
20384 }
20385 let mut rejections = Vec::new();
20386 for commit in &doomed {
20387 let results = commit
20388 .ops
20389 .iter()
20390 .enumerate()
20391 .map(|(op_index, operation)| {
20392 let rejection = RejectionRecord {
20393 client_commit_id: commit.client_commit_id.clone(),
20394 op_index: op_index as i32,
20395 code: "sync.scope_revoked".to_owned(),
20396 message: "the commit was dropped because its effective scope was revoked"
20397 .to_owned(),
20398 retryable: false,
20399 details: None,
20400 operation: Some(CommitOperation::from(operation)),
20401 };
20402 rejections.push(rejection.clone());
20403 CommitOperationOutcome::Error { rejection }
20404 })
20405 .collect::<Vec<_>>();
20406 self.persist_commit_outcome(
20407 &commit.client_commit_id,
20408 CommitOutcomeStatus::Rejected,
20409 &results,
20410 Some(&commit.ops),
20411 )?;
20412 self.delete_outbox_persisted(&commit.client_commit_id)?;
20413 }
20414 self.prune_commit_outcomes()?;
20415 let doomed_ids = doomed
20416 .iter()
20417 .map(|commit| commit.client_commit_id.as_str())
20418 .collect::<BTreeSet<_>>();
20419 self.outbox
20420 .retain(|commit| !doomed_ids.contains(commit.client_commit_id.as_str()));
20421 self.rejections.extend(rejections);
20422 Ok(true)
20423 }
20424
20425 pub fn upload_blob(
20432 &mut self,
20433 bytes: &[u8],
20434 media_type: Option<String>,
20435 name: Option<String>,
20436 ) -> Result<Value, String> {
20437 let blob_id = blob_id_for(bytes);
20438 let now = self.clock_now_ms();
20439 let tx = self
20440 .conn
20441 .transaction()
20442 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20443 tx.execute(
20444 "INSERT INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,?,?)
20445 ON CONFLICT(blob_id) DO NOTHING",
20446 rusqlite::params![blob_id, bytes, bytes.len() as i64, media_type, now],
20447 )
20448 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20449 tx.execute(
20450 "INSERT OR IGNORE INTO _syncular_blob_uploads(blob_id, media_type, created_at_ms) VALUES (?,?,?)",
20451 rusqlite::params![blob_id, media_type, now],
20452 )
20453 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20454 tx.commit()
20455 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20456 self.enforce_blob_cache_cap();
20457 let mut obj = Map::new();
20458 obj.insert("blobId".to_owned(), Value::from(blob_id));
20459 obj.insert("byteLength".to_owned(), Value::from(bytes.len() as i64));
20460 if let Some(mt) = media_type {
20461 obj.insert("mediaType".to_owned(), Value::from(mt));
20462 }
20463 if let Some(n) = name {
20464 obj.insert("name".to_owned(), Value::from(n));
20465 }
20466 Ok(Value::Object(obj))
20467 }
20468
20469 pub fn fetch_blob_bytes(
20473 &mut self,
20474 transport: &mut dyn Transport,
20475 blob_id_or_ref: &str,
20476 ) -> Result<FetchedBlob, (String, String)> {
20477 let simple = |m: String| ("client.failed".to_owned(), m);
20478 let blob_id = if blob_id_or_ref.starts_with("sha256:") {
20479 blob_id_or_ref.to_owned()
20480 } else {
20481 let value: Value = serde_json::from_str(blob_id_or_ref)
20482 .map_err(|_| simple("blob ref is not JSON".to_owned()))?;
20483 value
20484 .get("blobId")
20485 .and_then(Value::as_str)
20486 .ok_or_else(|| simple("blob ref has no blobId".to_owned()))?
20487 .to_owned()
20488 };
20489 if let Some(cached) = self.get_cached_blob_bytes(&blob_id).map_err(simple)? {
20490 return Ok(cached);
20491 }
20492 if !self.transport_enabled {
20493 return Err(("sync.offline".into(), "transport gate is closed".into()));
20494 }
20495 #[cfg(feature = "bench-internals")]
20501 let download_phase = self.benchmark_phases.start(Phase::BlobDownload);
20502 let bytes = match transport
20503 .blob_download(&blob_id)
20504 .map_err(|e| (e.code, e.message))?
20505 {
20506 BlobDownload::Bytes(bytes) => bytes,
20507 BlobDownload::Url {
20508 url,
20509 url_expires_at_ms,
20510 } => {
20511 if url_expires_at_ms.is_some_and(|exp| exp <= self.clock_now_ms()) {
20513 return Err((
20514 "sync.segment_expired".to_owned(),
20515 format!(
20516 "blob url for {blob_id} expired before fetch — re-request mints a fresh url (§5.9.5)"
20517 ),
20518 ));
20519 }
20520 transport
20521 .fetch_blob_url(&url)
20522 .map_err(|e| (e.code, e.message))?
20523 }
20524 };
20525 #[cfg(feature = "bench-internals")]
20526 drop(download_phase);
20527 #[cfg(feature = "bench-internals")]
20528 let validate_phase = self.benchmark_phases.start(Phase::BlobValidate);
20529 if blob_id_for(&bytes) != blob_id {
20531 return Err(simple(format!(
20532 "blob content address mismatch for {blob_id}"
20533 )));
20534 }
20535 #[cfg(feature = "bench-internals")]
20536 drop(validate_phase);
20537 #[cfg(feature = "bench-internals")]
20538 let insert_phase = self.benchmark_phases.start(Phase::BlobCacheInsert);
20539 let now = self.clock_now_ms();
20540 self.conn
20541 .execute(
20542 "INSERT OR IGNORE INTO _syncular_blobs(blob_id, bytes, byte_length, media_type, created_at_ms) VALUES (?,?,?,NULL,?)",
20543 rusqlite::params![blob_id, bytes, bytes.len() as i64, now],
20544 )
20545 .map_err(|e| simple(e.to_string()))?;
20546 self.conn
20547 .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |_| Ok(()))
20548 .map_err(|e| simple(e.to_string()))?;
20549 self.enforce_blob_cache_cap();
20550 #[cfg(feature = "bench-internals")]
20551 drop(insert_phase);
20552 self.get_cached_blob_bytes(&blob_id)
20553 .map_err(simple)?
20554 .ok_or_else(|| simple("blob cache write failed".to_owned()))
20555 }
20556
20557 fn get_cached_blob_bytes(&self, blob_id: &str) -> Result<Option<FetchedBlob>, String> {
20558 #[cfg(feature = "bench-internals")]
20559 let _phase = self.benchmark_phases.start(Phase::BlobCacheRead);
20560 let mut stmt = self
20561 .conn
20562 .prepare("SELECT bytes, byte_length, media_type FROM _syncular_blobs WHERE blob_id = ?")
20563 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20564 let mut rows = stmt
20565 .query(rusqlite::params![blob_id])
20566 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20567 if let Some(row) = rows
20568 .next()
20569 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
20570 {
20571 let bytes: Vec<u8> = row
20572 .get(0)
20573 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20574 let byte_length: i64 = row
20575 .get(1)
20576 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20577 let media_type: Option<String> = row
20578 .get(2)
20579 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20580 return Ok(Some(FetchedBlob {
20581 blob_id: blob_id.to_owned(),
20582 byte_length,
20583 bytes,
20584 media_type,
20585 }));
20586 }
20587 Ok(None)
20588 }
20589
20590 fn pending_blob_uploads(&self) -> Result<Vec<crate::round::PendingUpload>, TransportError> {
20592 let pending: Vec<(String, Option<String>)> = {
20593 let mut stmt = self
20594 .conn
20595 .prepare(
20596 "SELECT blob_id, media_type FROM (
20597 SELECT blob_id, media_type, created_at_ms FROM _syncular_blob_uploads
20598 UNION ALL
20599 SELECT DISTINCT r.blob_id, b.media_type, 9223372036854775807 AS created_at_ms
20600 FROM _syncular_blob_commit_refs r LEFT JOIN _syncular_blobs b ON b.blob_id = r.blob_id
20601 WHERE NOT EXISTS (SELECT 1 FROM _syncular_blob_uploads u WHERE u.blob_id = r.blob_id)
20602 ) ORDER BY created_at_ms, blob_id",
20603 )
20604 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20605 let rows = stmt
20606 .query_map([], |row| {
20607 Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
20608 })
20609 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20610 rows.collect::<Result<_, _>>()
20611 .map_err(|error| match error {
20612 rusqlite::Error::InvalidColumnType(..)
20613 | rusqlite::Error::FromSqlConversionFailure(..) => TransportError::new(
20614 "sync.local_corrupt",
20615 "Pending blob upload metadata is invalid",
20616 ),
20617 _ => TransportError::new("client.failed", error.to_string()),
20618 })?
20619 };
20620 let mut uploads = Vec::new();
20621 for (blob_id, media_type) in pending {
20622 let bytes = (|| {
20623 let stored: Option<(SqlValue, SqlValue)> = self
20624 .conn
20625 .query_row(
20626 "SELECT bytes, byte_length FROM _syncular_blobs WHERE blob_id = ?",
20627 rusqlite::params![blob_id],
20628 |row| Ok((row.get(0)?, row.get(1)?)),
20629 )
20630 .optional()
20631 .map_err(|e| TransportError::new("client.failed", e.to_string()))?;
20632 let Some((SqlValue::Blob(bytes), SqlValue::Integer(byte_length))) = stored else {
20633 return Err(TransportError::new(
20634 "sync.local_corrupt",
20635 "Pending blob upload body is missing or corrupt",
20636 ));
20637 };
20638 if byte_length != bytes.len() as i64 || blob_id_for(&bytes) != blob_id {
20639 return Err(TransportError::new(
20640 "sync.local_corrupt",
20641 "Pending blob upload body is missing or corrupt",
20642 ));
20643 }
20644 Ok(bytes)
20645 })();
20646 let failed = bytes.is_err();
20647 uploads.push(crate::round::PendingUpload {
20648 id: blob_id,
20649 bytes,
20650 media_type,
20651 });
20652 if failed {
20653 break;
20654 }
20655 }
20656 Ok(uploads)
20657 }
20658
20659 fn visible_blob_ids_sql(&self) -> String {
20660 let mut selects = Vec::new();
20661 for table in &self.schema.tables {
20662 for column in table
20663 .columns
20664 .iter()
20665 .filter(|column| column.ty == ColumnType::BlobRef)
20666 {
20667 let identifier = quote_ident(&column.name);
20668 selects.push(format!(
20669 "SELECT json_extract({identifier}, '$.blobId') AS blob_id FROM {} \
20670 WHERE typeof({identifier}) = 'text' AND json_valid({identifier}) \
20671 AND json_type({identifier}, '$.blobId') = 'text'",
20672 quote_ident(&table.name),
20673 ));
20674 }
20675 }
20676 if selects.is_empty() {
20677 "SELECT NULL AS blob_id WHERE 0".to_owned()
20678 } else {
20679 selects.join(" UNION ")
20680 }
20681 }
20682
20683 fn enforce_blob_cache_cap(&self) {
20686 let Some(max_bytes) = self.limits.blob_cache_max_bytes else {
20687 return;
20688 };
20689 let mut total: i64 = self
20690 .conn
20691 .query_row(
20692 "SELECT COALESCE(SUM(byte_length), 0) FROM _syncular_blobs",
20693 [],
20694 |row| row.get(0),
20695 )
20696 .unwrap_or(0);
20697 if total <= max_bytes {
20698 return;
20699 }
20700 let query = format!(
20701 "SELECT blob_id, byte_length FROM _syncular_blobs
20702 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20703 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20704 AND blob_id NOT IN ({})
20705 ORDER BY created_at_ms ASC, blob_id ASC",
20706 self.visible_blob_ids_sql(),
20707 );
20708 let candidates: Vec<(String, i64)> = {
20709 let Ok(mut stmt) = self.conn.prepare(&query) else {
20710 return;
20711 };
20712 let Ok(rows) = stmt.query_map([], |row| {
20713 Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
20714 }) else {
20715 return;
20716 };
20717 rows.filter_map(Result::ok).collect()
20718 };
20719 for (blob_id, byte_length) in candidates {
20720 if total <= max_bytes {
20721 break;
20722 }
20723 let _ = self.conn.execute(
20724 "DELETE FROM _syncular_blobs WHERE blob_id = ?",
20725 rusqlite::params![blob_id],
20726 );
20727 total -= byte_length;
20728 }
20729 }
20730
20731 fn delete_unreferenced_cached_blobs(&mut self) -> Result<(), String> {
20732 #[cfg(feature = "bench-internals")]
20733 let _phase = self.benchmark_phases.start(Phase::BlobRetention);
20734 if !self.schema_has_blobs() {
20735 return Ok(());
20736 }
20737 self.rebuild_overlay_if_dirty()?;
20738 self.conn
20739 .execute(
20740 &format!(
20741 "DELETE FROM _syncular_blobs
20742 WHERE blob_id NOT IN (SELECT blob_id FROM _syncular_blob_uploads)
20743 AND blob_id NOT IN (SELECT blob_id FROM _syncular_blob_commit_refs)
20744 AND blob_id NOT IN ({})",
20745 self.visible_blob_ids_sql(),
20746 ),
20747 [],
20748 )
20749 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20750 Ok(())
20751 }
20752
20753 fn insert_row_sql(&self, full_table: &str, table: &crate::schema::TableSchema) -> String {
20758 if let Some(sql) = self.insert_sql.borrow().get(full_table) {
20759 return sql.clone();
20760 }
20761 let mut columns: Vec<String> = table.columns.iter().map(|c| quote_ident(&c.name)).collect();
20762 columns.push(quote_ident("_syncular_version"));
20763 let placeholders: Vec<&str> = columns.iter().map(|_| "?").collect();
20764 let primary_key = quote_ident(&table.primary_key);
20765 let updates = columns
20766 .iter()
20767 .filter(|column| **column != primary_key)
20768 .map(|column| format!("{column}=excluded.{column}"))
20769 .collect::<Vec<_>>()
20770 .join(", ");
20771 let sql = format!(
20772 "INSERT INTO {full_table} ({}) VALUES ({}) ON CONFLICT ({primary_key}) DO UPDATE SET {updates}",
20773 columns.join(", "),
20774 placeholders.join(", ")
20775 );
20776 self.insert_sql
20777 .borrow_mut()
20778 .insert(full_table.to_owned(), sql.clone());
20779 sql
20780 }
20781
20782 fn write_base_row(&self, table_name: &str, row: &Row, version: i64) -> Result<(), String> {
20783 self.overlay_dirty.table(table_name);
20784 self.write_row(&base_table(table_name), table_name, row, version)
20785 }
20786
20787 fn write_row(
20788 &self,
20789 full_table: &str,
20790 table_name: &str,
20791 row: &Row,
20792 version: i64,
20793 ) -> Result<(), String> {
20794 #[cfg(feature = "bench-internals")]
20795 let _phase = self.benchmark_phases.start(Phase::RowWrite);
20796 let table = self
20797 .schema
20798 .table(table_name)
20799 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20800 let sql = self.insert_row_sql(full_table, table);
20801 let mut stmt = self
20802 .conn
20803 .prepare_cached(&sql)
20804 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20805 let params = row
20806 .iter()
20807 .map(RowParam::Cell)
20808 .chain(std::iter::once(RowParam::Version(version)));
20809 stmt.execute(rusqlite::params_from_iter(params))
20810 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20811 Ok(())
20812 }
20813
20814 fn delete_base_row(&self, table_name: &str, row_id: &str) -> Result<(), String> {
20815 let table = self
20816 .schema
20817 .table(table_name)
20818 .ok_or_else(|| format!("unknown table {table_name:?}"))?;
20819 self.overlay_dirty.table(table_name);
20820 let sql = format!(
20821 "DELETE FROM {} WHERE {}",
20822 base_table(table_name),
20823 row_id_predicate(table)
20824 );
20825 let mut stmt = self
20826 .conn
20827 .prepare_cached(&sql)
20828 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20829 stmt.execute(rusqlite::params![row_id])
20830 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20831 Ok(())
20832 }
20833
20834 fn has_acknowledged_rows(&self) -> bool {
20835 self.conn
20836 .query_row(
20837 "SELECT EXISTS(SELECT 1 FROM _syncular_acked_rows)",
20838 [],
20839 |row| row.get(0),
20840 )
20841 .expect("read acknowledged intent")
20842 }
20843
20844 fn acknowledged_operations(
20845 &self,
20846 rows: Option<&HashSet<(&str, &str)>>,
20847 tables: Option<&BTreeSet<&str>>,
20848 ) -> Result<Vec<OutboxOp>, String> {
20849 let keys = rows.map(|rows| {
20850 serde_json::to_string(
20851 &rows
20852 .iter()
20853 .map(|(table, id)| serde_json::to_string(&(table, id)).expect("row key"))
20854 .collect::<Vec<_>>(),
20855 )
20856 .expect("row keys")
20857 });
20858 let mut statement = self
20859 .conn
20860 .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")
20861 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20862 let rows = statement
20863 .query_map(
20864 rusqlite::params![
20865 keys,
20866 tables.map(|tables| serde_json::to_string(tables).expect("table keys"))
20867 ],
20868 |row| {
20869 #[cfg(test)]
20870 self.acknowledged_replay_count
20871 .set(self.acknowledged_replay_count.get() + 1);
20872 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
20873 },
20874 )
20875 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20876 rows.map(|row| {
20877 let (op, intent) =
20878 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20879 let op: CommitOperation =
20880 serde_json::from_str(&op).map_err(|_| "sync.local_corrupt".to_owned())?;
20881 let table = self
20882 .schema
20883 .table(&op.table)
20884 .ok_or_else(|| "sync.unknown_table".to_owned())?;
20885 let values = if op.op == "upsert" && self.visible_row(table, &op.row_id)?.is_none() {
20886 Some(serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?)
20887 } else {
20888 op.values
20889 };
20890 Ok(OutboxOp {
20891 table: op.table,
20892 row_id: op.row_id,
20893 upsert: op.op == "upsert",
20894 base_version: op.base_version,
20895 values,
20896 insert: op.insert,
20897 })
20898 })
20899 .collect()
20900 }
20901
20902 fn drop_acknowledged_rows(
20903 &self,
20904 matches: impl Fn(&str, &Map<String, Value>) -> bool,
20905 ) -> Result<Vec<OutboxCommit>, String> {
20906 let mut statement = self
20907 .conn
20908 .prepare("SELECT commit_id,tbl,intent_json,id FROM _syncular_acked_rows")
20909 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20910 let rows = statement
20911 .query_map([], |row| {
20912 Ok((
20913 row.get::<_, String>(0)?,
20914 row.get::<_, String>(1)?,
20915 row.get::<_, String>(2)?,
20916 row.get::<_, String>(3)?,
20917 ))
20918 })
20919 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20920 let mut doomed = HashSet::new();
20921 for row in rows {
20922 let (id, table, intent, row_id) =
20923 row.map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20924 let values =
20925 serde_json::from_str(&intent).map_err(|_| "sync.local_corrupt".to_owned())?;
20926 let base_matches = self
20927 .schema
20928 .table(&table)
20929 .map(|schema| self.stored_row(schema, &row_id, true))
20930 .transpose()?
20931 .flatten()
20932 .is_some_and(|(base, _)| {
20933 let schema = self.schema.table(&table).expect("acknowledged table");
20934 let values = schema
20935 .columns
20936 .iter()
20937 .zip(base.iter())
20938 .map(|(column, value)| (column.name.clone(), column_value_to_json(value)))
20939 .collect();
20940 matches(&table, &values)
20941 });
20942 if matches(&table, &values) || base_matches {
20943 doomed.insert(id);
20944 }
20945 }
20946 let mut dropped = Vec::new();
20947 for id in doomed {
20948 let mut statement = self
20949 .conn
20950 .prepare("SELECT op_json FROM _syncular_acked_rows WHERE commit_id=?1 ORDER BY idx")
20951 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20952 let operations = statement
20953 .query_map([&id], |row| row.get::<_, String>(0))
20954 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20955 let mut ops = Vec::new();
20956 for operation in operations {
20957 let json = operation
20958 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20959 let op: CommitOperation =
20960 serde_json::from_str(&json).map_err(|_| "sync.local_corrupt".to_owned())?;
20961 self.overlay_dirty.table(&op.table);
20962 ops.push(OutboxOp {
20963 table: op.table,
20964 row_id: op.row_id,
20965 upsert: op.op == "upsert",
20966 base_version: op.base_version,
20967 values: op.values,
20968 insert: op.insert,
20969 });
20970 }
20971 dropped.push(OutboxCommit {
20972 client_commit_id: id.clone(),
20973 ops,
20974 });
20975 self.conn
20976 .execute("DELETE FROM _syncular_acked_rows WHERE commit_id=?1", [id])
20977 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
20978 }
20979 Ok(dropped)
20980 }
20981
20982 fn rebuild_overlay_if_dirty(&mut self) -> Result<(), String> {
20985 if self.overlay_dirty.get() {
20986 self.rebuild_overlay()?;
20987 }
20988 Ok(())
20989 }
20990
20991 fn rebuild_overlay(&mut self) -> Result<(), String> {
20995 #[cfg(feature = "bench-internals")]
20996 let _phase = self.benchmark_phases.start(Phase::OverlayRebuild);
20997 #[cfg(test)]
20998 self.overlay_rebuild_count
20999 .set(self.overlay_rebuild_count.get() + 1);
21000 let dirty = self.overlay_dirty.snapshot();
21001 let tables: Vec<_> = self
21002 .schema
21003 .tables
21004 .iter()
21005 .filter(|table| {
21006 dirty
21007 .as_ref()
21008 .is_none_or(|names| names.is_empty() || names.contains(&table.name))
21009 })
21010 .cloned()
21011 .collect();
21012 let names: BTreeSet<_> = tables.iter().map(|table| table.name.as_str()).collect();
21013 self.begin_observation("syncular_overlay")?;
21014 let rebuilt = (|| {
21015 for table in &tables {
21016 for index in &table.fts_indexes {
21017 self.drop_fts_triggers(index)?;
21018 }
21019 let visible = visible_table(&table.name);
21020 let base = base_table(&table.name);
21021 self.conn
21022 .execute_batch(&format!(
21023 "DELETE FROM {visible}; INSERT INTO {visible} SELECT * FROM {base}"
21024 ))
21025 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21026 }
21027 let acknowledged = self.acknowledged_operations(None, Some(&names))?;
21028 self.apply_outbox_ops(
21029 acknowledged
21030 .iter()
21031 .filter(|op| names.contains(op.table.as_str())),
21032 )?;
21033 for (commit, initial) in &self.failed_commits {
21034 for operation in &commit.ops {
21035 if !names.contains(operation.table.as_str()) || !operation.upsert {
21036 continue;
21037 }
21038 if let Some(table) = self.schema.table(&operation.table) {
21039 if self.visible_row(table, &operation.row_id)?.is_none()
21040 && operation.presented_values(table).is_some_and(|values| {
21041 table
21042 .columns
21043 .iter()
21044 .all(|column| values.contains_key(&column.name))
21045 })
21046 {
21047 let key = serde_json::to_string(&(
21048 operation.table.clone(),
21049 operation.row_id.clone(),
21050 ))
21051 .expect("row key serialization");
21052 if let Some(Value::Object(values)) = initial.get(&key) {
21053 self.apply_outbox_ops(&[OutboxOp {
21054 values: Some(values.clone()),
21055 ..operation.clone()
21056 }])?;
21057 }
21058 }
21059 }
21060 }
21061 self.apply_outbox_ops(
21062 commit
21063 .ops
21064 .iter()
21065 .filter(|op| names.contains(op.table.as_str())),
21066 )?;
21067 }
21068 for commit in &self.outbox {
21069 self.apply_outbox_ops(
21070 commit
21071 .ops
21072 .iter()
21073 .filter(|op| names.contains(op.table.as_str())),
21074 )?;
21075 }
21076 for table in &tables {
21077 for index in &table.fts_indexes {
21078 self.rebuild_fts_projection(table, index)?;
21079 self.create_fts_triggers(table, index)?;
21080 }
21081 }
21082 self.conn
21083 .execute_batch("RELEASE syncular_overlay")
21084 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21085 Ok(())
21086 })();
21087 if rebuilt.is_err() {
21088 self.conn
21089 .execute_batch("ROLLBACK TO syncular_overlay; RELEASE syncular_overlay")
21090 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21091 } else {
21092 self.overlay_dirty.set(false);
21093 }
21094 rebuilt
21095 }
21096
21097 fn apply_outbox_ops<'a>(
21102 &self,
21103 ops: impl IntoIterator<Item = &'a OutboxOp>,
21104 ) -> Result<(), String> {
21105 #[cfg(feature = "bench-internals")]
21106 let mut phase = self.benchmark_phases.start(Phase::PendingReplay);
21107 for op in ops {
21108 #[cfg(feature = "bench-internals")]
21109 if let Some(phase) = &mut phase {
21110 phase.unit();
21111 }
21112 if op.upsert {
21113 if let Some((table, values)) = self
21114 .schema
21115 .table(&op.table)
21116 .filter(|table| table.indexes.iter().any(|index| index.unique))
21117 .and_then(|table| Some((table, op.presented_values(table)?)))
21118 {
21119 let mut intended: Map<String, Value> = self
21120 .visible_row(table, &op.row_id)?
21121 .map(|(row, _)| {
21122 table
21123 .columns
21124 .iter()
21125 .zip(&row)
21126 .map(|(column, value)| {
21127 (column.name.clone(), column_value_to_json(value))
21128 })
21129 .collect()
21130 })
21131 .unwrap_or_default();
21132 intended.extend(values.into_owned());
21133 if !self.unique_conflicts(table, &intended, false)?.is_empty() {
21134 continue;
21135 }
21136 }
21137 }
21138 self.apply_outbox_op(op)?;
21139 }
21140 Ok(())
21141 }
21142
21143 fn apply_outbox_op(&self, op: &OutboxOp) -> Result<(), String> {
21144 let Some(table) = self.schema.table(&op.table) else {
21145 return Ok(());
21146 };
21147 let visible = visible_table(&table.name);
21148 if !op.upsert {
21149 let sql = format!("DELETE FROM {visible} WHERE {}", row_id_predicate(table));
21150 self.conn
21151 .prepare_cached(&sql)
21152 .and_then(|mut statement| statement.execute(rusqlite::params![op.row_id]))
21153 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21154 return Ok(());
21155 }
21156 let Some(values) = op.presented_values(table) else {
21160 return Ok(());
21161 };
21162 let full = table
21168 .columns
21169 .iter()
21170 .all(|column| values.contains_key(&column.name));
21171 let local = self.visible_row(table, &op.row_id)?;
21172 if local.is_none() && !full {
21173 return Ok(());
21174 }
21175 let mut row: Row = Vec::with_capacity(table.columns.len());
21176 for (index, column) in table.columns.iter().enumerate() {
21177 let incoming = match values.get(&column.name) {
21178 Some(raw) => json_to_column_value(
21179 if raw.get("$bytes").is_some()
21180 && table
21181 .encrypted_columns
21182 .iter()
21183 .any(|encrypted| encrypted.index == index)
21184 {
21185 &table.wire_columns[index]
21186 } else {
21187 column
21188 },
21189 Some(raw),
21190 ),
21191 None => Ok(local
21192 .as_ref()
21193 .and_then(|(values, _)| values.get(index).cloned())
21194 .flatten()),
21195 };
21196 row.push(incoming.map_err(|_| {
21197 "sync.local_corrupt: persisted optimistic row value is invalid".to_owned()
21198 })?);
21199 }
21200 let version = local.as_ref().map_or(-1, |(_, version)| *version);
21201 self.write_row(&visible, &table.name, &row, version)
21202 .map_err(|error| {
21203 if error.starts_with("UNIQUE constraint failed:") {
21204 "sync.constraint_violation: local write violates a unique constraint".to_owned()
21205 } else {
21206 error
21207 }
21208 })
21209 }
21210
21211 fn visible_row_exists(&self, table: &TableSchema, row_id: &str) -> Result<bool, String> {
21215 self.conn
21216 .query_row(
21217 &format!(
21218 "SELECT EXISTS(SELECT 1 FROM {} WHERE {})",
21219 visible_table(&table.name),
21220 row_id_predicate(table)
21221 ),
21222 rusqlite::params![row_id],
21223 |row| row.get(0),
21224 )
21225 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))
21226 }
21227
21228 fn visible_row(&self, table: &TableSchema, row_id: &str) -> Result<Option<(Row, i64)>, String> {
21231 self.stored_row(table, row_id, false)
21232 }
21233
21234 fn stored_row(
21235 &self,
21236 table: &TableSchema,
21237 row_id: &str,
21238 base: bool,
21239 ) -> Result<Option<(Row, i64)>, String> {
21240 let sql = format!(
21241 "SELECT * FROM {} WHERE {}",
21242 if base {
21243 base_table(&table.name)
21244 } else {
21245 visible_table(&table.name)
21246 },
21247 row_id_predicate(table)
21248 );
21249 let mut stmt = self
21250 .conn
21251 .prepare_cached(&sql)
21252 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21253 let mut rows = stmt
21254 .query(rusqlite::params![row_id])
21255 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21256 let Some(row) = rows
21257 .next()
21258 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?
21259 else {
21260 return Ok(None);
21261 };
21262 let mut values = Vec::with_capacity(table.columns.len());
21263 for (index, column) in table.columns.iter().enumerate() {
21264 let raw = row
21265 .get_ref(index)
21266 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21267 if let rusqlite::types::ValueRef::Blob(bytes) = raw {
21268 if table
21269 .encrypted_columns
21270 .iter()
21271 .any(|encrypted| encrypted.index == index)
21272 {
21273 values.push(Some(ColumnValue::Bytes(bytes.to_vec())));
21274 continue;
21275 }
21276 }
21277 let json = sql_ref_to_json(column, raw);
21278 values.push(json_to_column_value(column, Some(&json)).map_err(|_| {
21279 "sync.local_corrupt: persisted local row value is invalid".to_owned()
21280 })?);
21281 }
21282 let version: i64 = row
21283 .get(table.columns.len())
21284 .map_err(|error| Self::sqlite_failure(&self.storage_failure, error))?;
21285 Ok(Some((values, version)))
21286 }
21287
21288 #[must_use]
21292 pub fn realtime_policy(&self) -> RealtimePolicy {
21293 self.realtime_policy
21294 }
21295
21296 #[must_use]
21298 pub fn realtime_state(&self) -> RealtimeState {
21299 self.realtime_state
21300 }
21301
21302 pub fn set_realtime_policy(&mut self, policy: RealtimePolicy) -> Result<(), String> {
21305 if self.realtime_state == RealtimeState::Connected && policy != self.realtime_policy {
21306 return Err(
21307 "sync.invalid_request: disconnect realtime before changing the realtime policy"
21308 .to_owned(),
21309 );
21310 }
21311 self.realtime_policy = policy;
21312 match policy {
21313 RealtimePolicy::Off => self.set_realtime_state(RealtimeState::Disabled, None),
21314 RealtimePolicy::Required | RealtimePolicy::Optional
21315 if self.realtime_state == RealtimeState::Disabled =>
21316 {
21317 self.set_realtime_state(RealtimeState::Disconnected, None);
21318 }
21319 RealtimePolicy::Required | RealtimePolicy::Optional => {}
21320 }
21321 Ok(())
21322 }
21323
21324 fn set_realtime_state(&mut self, state: RealtimeState, reason_code: Option<&str>) {
21327 self.realtime_state = state;
21328 self.realtime_reason_code = reason_code.map(Self::diagnostic_code);
21329 if matches!(state, RealtimeState::Connected | RealtimeState::Disabled) {
21330 self.realtime_reason_code = None;
21331 self.realtime_retry_delay_ms = None;
21332 }
21333 }
21334
21335 #[must_use]
21336 fn realtime_connected(&self) -> bool {
21337 self.realtime_state == RealtimeState::Connected
21338 }
21339
21340 pub fn connect_realtime(&mut self, transport: &mut dyn Transport) -> Result<(), String> {
21341 if !self.transport_enabled {
21342 return Err("sync.offline: transport gate is closed".into());
21343 }
21344 if self.realtime_policy == RealtimePolicy::Off {
21345 return Err(
21346 "sync.invalid_request: realtime is off; realtime connect is refused".to_owned(),
21347 );
21348 }
21349 if self.realtime_connected() {
21350 return Ok(());
21351 }
21352 self.realtime_state = RealtimeState::Connecting;
21353 match transport.realtime_connect_for_client(&self.client_id) {
21354 Ok(()) => {
21355 self.set_realtime_state(RealtimeState::Connected, None);
21356 Ok(())
21357 }
21358 Err(error) => {
21359 self.set_realtime_state(RealtimeState::Refused, Some(&error.code));
21360 Err(format!("{}: {}", error.code, error.message))
21361 }
21362 }
21363 }
21364
21365 pub fn disconnect_realtime(&mut self, transport: &mut dyn Transport) {
21366 self.cancel_sync_round();
21367 if self.realtime_state == RealtimeState::Disconnected
21370 || self.realtime_state == RealtimeState::Disabled
21371 {
21372 return;
21373 }
21374 let _ = transport.realtime_close();
21375 self.set_realtime_state(
21376 if self.realtime_policy == RealtimePolicy::Off {
21377 RealtimeState::Disabled
21378 } else {
21379 RealtimeState::Disconnected
21380 },
21381 None,
21382 );
21383 self.presence.clear(); }
21385
21386 pub fn realtime_lost(&mut self, transport: &mut dyn Transport, error: &TransportError) {
21392 if self.realtime_state != RealtimeState::Connected {
21393 return;
21394 }
21395 self.cancel_sync_round();
21396 let _ = transport.realtime_close();
21397 self.set_realtime_state(RealtimeState::Lost, Some(&error.code));
21398 self.realtime_retry_delay_ms = Some(self.schedule_background_retry());
21399 self.presence.clear();
21400 }
21401
21402 pub fn set_presence(
21408 &mut self,
21409 transport: &mut dyn Transport,
21410 scope_key: &str,
21411 doc: Option<&Value>,
21412 ) -> Result<(), String> {
21413 if !self.transport_enabled {
21414 return Err("sync.offline: transport gate is closed".into());
21415 }
21416 if !self.realtime_connected() {
21417 return Err("setPresence requires a connected realtime socket (§8.6)".to_string());
21418 }
21419 let text = encode_presence_publish(scope_key, doc);
21420 transport
21421 .realtime_send(&text)
21422 .map_err(|e| format!("{}: {}", e.code, e.message))
21423 }
21424
21425 pub fn presence(&self, scope_key: &str) -> Vec<PresencePeer> {
21427 self.presence
21428 .get(scope_key)
21429 .map(|peers| peers.values().cloned().collect())
21430 .unwrap_or_default()
21431 }
21432
21433 fn apply_presence(
21435 &mut self,
21436 scope_key: String,
21437 kind: Option<PresenceKind>,
21438 actor_id: Option<String>,
21439 client_id: Option<String>,
21440 doc: Option<Value>,
21441 error: Option<String>,
21442 ) {
21443 if error.is_some() {
21446 return;
21447 }
21448 let (Some(kind), Some(actor_id), Some(client_id)) = (kind, actor_id, client_id) else {
21449 return;
21450 };
21451 let peer_key = format!("{actor_id} {client_id}");
21452 match kind {
21453 PresenceKind::Leave => {
21454 if let Some(peers) = self.presence.get_mut(&scope_key) {
21455 peers.remove(&peer_key);
21456 if peers.is_empty() {
21457 self.presence.remove(&scope_key);
21458 }
21459 }
21460 }
21461 _ => {
21462 let doc = match doc {
21463 Some(Value::Object(_)) => doc.unwrap(),
21464 _ => return,
21465 };
21466 self.presence.entry(scope_key).or_default().insert(
21467 peer_key,
21468 PresencePeer {
21469 actor_id,
21470 client_id,
21471 doc,
21472 },
21473 );
21474 }
21475 }
21476 }
21477
21478 pub fn on_realtime_text(&mut self, text: &str) {
21480 if self.stopped || !self.transport_enabled || self.security_preflight {
21481 return;
21482 }
21483 match parse_control(text) {
21484 Ok(ControlMessage::Hello { requires_sync, .. }) => {
21485 if requires_sync {
21486 self.set_sync_needed(true, true);
21488 }
21489 }
21490 Ok(ControlMessage::Presence {
21491 scope_key,
21492 kind,
21493 actor_id,
21494 client_id,
21495 doc,
21496 error,
21497 ..
21498 }) => {
21499 self.apply_presence(scope_key, kind, actor_id, client_id, doc, error);
21500 }
21501 Ok(ControlMessage::Wake { .. }) => {
21502 self.set_sync_needed(true, true);
21504 }
21505 _ => {}
21506 }
21507 }
21508
21509 pub fn on_realtime_binary_queued(&mut self, bytes: &[u8]) -> Vec<String> {
21513 let mut transport = crate::round::DownloadResults::default();
21514 self.on_realtime_binary(&mut transport, bytes);
21515 transport.controls
21516 }
21517
21518 pub fn on_realtime_binary(&mut self, transport: &mut dyn Transport, bytes: &[u8]) {
21519 if self.stopped || !self.transport_enabled || self.security_preflight {
21520 return;
21521 }
21522 #[cfg(feature = "bench-internals")]
21523 let decode_phase = self.benchmark_phases.start(Phase::ResponseDecode);
21524 let message = match decode_message(bytes) {
21525 Ok(m) if m.msg_kind == MsgKind::Response => m,
21526 _ => {
21527 self.set_sync_needed(true, true);
21528 return;
21529 }
21530 };
21531 #[cfg(feature = "bench-internals")]
21532 drop(decode_phase);
21533 let coalesced_deletes =
21534 coalesced_delete_keys(&message.frames, |id, status, next_cursor| {
21535 if status != SubStatus::Active {
21536 return false;
21537 }
21538 let Some(sub) = self.subs.iter().find(|sub| sub.id == id) else {
21539 return false;
21540 };
21541 sub.state == SubState::Active
21542 && sub.bootstrap_state.is_none()
21543 && sub.synced_once
21544 && next_cursor.is_some_and(|cursor| cursor > sub.cursor)
21545 });
21546 let mut frames = message.frames.into_iter();
21547 let mut applied_cursor: Option<i64> = None;
21548 let mut any_covered = false;
21549 let mut dropped = self.begin_coalesced_apply().is_err();
21550 while let Some(frame) = frames.next() {
21551 let Frame::SubStart {
21552 id,
21553 status,
21554 effective_scopes,
21555 ..
21556 } = frame
21557 else {
21558 continue;
21559 };
21560 let mut body = Vec::new();
21561 let mut next_cursor: Option<i64> = None;
21562 for inner in frames.by_ref() {
21563 match inner {
21564 Frame::SubEnd {
21565 next_cursor: nc, ..
21566 } => {
21567 next_cursor = Some(nc);
21568 break;
21569 }
21570 Frame::Unknown { .. } => {}
21571 other => body.push(other),
21572 }
21573 }
21574 let Some(sub_index) = self.subs.iter().position(|s| s.id == id) else {
21575 dropped = true;
21576 continue;
21577 };
21578 let sub = &self.subs[sub_index];
21579 if status != SubStatus::Active
21582 || sub.state != SubState::Active
21583 || sub.bootstrap_state.is_some()
21584 || !sub.synced_once
21585 {
21586 dropped = true;
21587 continue;
21588 }
21589 if next_cursor.is_some_and(|cursor| cursor <= sub.cursor) {
21590 any_covered = true;
21592 continue;
21593 }
21594 let previous_effective = self.subs[sub_index].effective.clone();
21595 let previous_cursor = self.subs[sub_index].cursor;
21596 self.subs[sub_index].effective = Some(effective_scopes);
21597 let mut failed = false;
21598 for inner in body {
21599 match inner {
21600 Frame::Commit {
21601 tables,
21602 changes,
21603 commit_seq,
21604 ..
21605 } => {
21606 if self
21607 .apply_commit_frame_coalesced(
21608 &tables,
21609 changes,
21610 commit_seq,
21611 &coalesced_deletes,
21612 )
21613 .is_err()
21614 {
21615 failed = true;
21616 break;
21617 }
21618 }
21619 _ => {
21620 failed = true;
21621 break;
21622 }
21623 }
21624 }
21625 let committed = next_cursor.filter(|_| !failed).and_then(|cursor| {
21626 self.apply_sub_end(sub_index, cursor, None)
21627 .ok()
21628 .map(|()| cursor)
21629 });
21630 let Some(next_cursor) = committed else {
21631 self.subs[sub_index].effective = previous_effective;
21632 self.subs[sub_index].cursor = previous_cursor;
21633 dropped = true;
21634 continue;
21635 };
21636 applied_cursor = Some(applied_cursor.map_or(next_cursor, |c| c.max(next_cursor)));
21637 }
21638 if self.finish_coalesced_apply().is_err() {
21641 applied_cursor = None;
21642 dropped = true;
21643 }
21644 if let Some(cursor) = applied_cursor {
21645 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21647 let _ = transport.realtime_send(&ack);
21648 } else if !any_covered || dropped {
21649 self.set_sync_needed(true, true);
21651 }
21652 }
21653
21654 fn ack_after_pull(&mut self, transport: &mut dyn Transport) {
21658 if !self.realtime_connected() {
21659 return;
21660 }
21661 let floor = self
21662 .subs
21663 .iter()
21664 .filter(|s| {
21665 s.state == SubState::Active
21666 && s.bootstrap_state.is_none()
21667 && s.synced_once
21668 && s.cursor >= 0
21669 })
21670 .map(|s| s.cursor)
21671 .min();
21672 if let Some(cursor) = floor {
21673 let ack = format!("{{\"type\":\"ack\",\"cursor\":{cursor}}}");
21674 let _ = transport.realtime_send(&ack);
21675 }
21676 }
21677}
21678
21679#[cfg(test)]
21680mod previous_version_wiring_tests {
21681 use super::*;
21682 use serde_json::{json, Value};
21683
21684 fn previous_version_schema(version: i32) -> Value {
21685 json!({
21686 "version": version,
21687 "tables": [{
21688 "name": "tasks",
21689 "primaryKey": "id",
21690 "columns": [
21691 { "name": "id", "type": "string", "nullable": false },
21692 { "name": "note", "type": "string", "nullable": true }
21693 ],
21694 "scopes": []
21695 }]
21696 })
21697 }
21698
21699 fn previous_version_limits() -> crate::api::ClientLimits {
21700 crate::api::ClientLimits {
21701 previous_version_context: Some(crate::previous_version::PreviousVersionContextConfig {
21702 enabled: true,
21703 ..Default::default()
21704 }),
21705 ..Default::default()
21706 }
21707 }
21708
21709 fn previous_version_config() -> crate::previous_version::PreviousVersionContextConfig {
21711 previous_version_limits()
21712 .previous_version_context
21713 .expect("configured")
21714 }
21715
21716 fn recapture_previous_version(client: &SyncClient) {
21719 let replica_path = client
21720 .previous_version_replica_path()
21721 .expect("file-backed replica");
21722 let outcome = crate::previous_version::capture_previous_version_from_replica(
21723 &client.conn,
21724 &replica_path,
21725 2,
21726 2,
21727 &previous_version_config(),
21728 1,
21729 )
21730 .expect("capture");
21731 assert!(matches!(
21732 outcome,
21733 crate::previous_version::CaptureOutcome::Captured(_)
21734 ));
21735 }
21736
21737 fn subscription(state: SubState, cursor: i64, bootstrap_state: Option<&str>) -> Subscription {
21738 Subscription {
21739 id: "wiring-sub".to_owned(),
21740 table: "tasks".to_owned(),
21741 requested: Vec::new(),
21742 params: None,
21743 cursor,
21744 bootstrap_state: bootstrap_state.map(str::to_owned),
21745 state,
21746 reason_code: None,
21747 effective: None,
21748 synced_once: false,
21749 }
21750 }
21751
21752 #[test]
21753 fn schema_downgrade_refuses_all_opens_and_recreate_without_mutation() {
21754 let path =
21755 std::env::temp_dir().join(format!("syncular-downgrade-{}.db", uuid::Uuid::new_v4()));
21756 let path_str = path.to_str().expect("temp path");
21757 let schema = previous_version_schema(3);
21758 let mut older = previous_version_schema(2);
21759 older["tables"][0]["columns"] =
21760 json!([{ "name": "id", "type": "string", "nullable": false }]);
21761 let mut client = SyncClient::open_path(
21762 "downgrade".into(),
21763 &previous_version_schema(1),
21764 previous_version_limits(),
21765 path_str,
21766 )
21767 .expect("fresh");
21768 client
21769 .mutate(vec![Mutation::Upsert {
21770 table: "tasks".into(),
21771 values: Map::from_iter([
21772 ("id".into(), json!("accepted")),
21773 ("note".into(), json!("old")),
21774 ]),
21775 base_version: None,
21776 }])
21777 .expect("v1 write");
21778 client
21779 .recreate_with_schema(&schema)
21780 .expect("upgrade captures context");
21781 let commit = client
21782 .mutate(vec![Mutation::Upsert {
21783 table: "tasks".into(),
21784 values: Map::from_iter([
21785 ("id".into(), json!("queued")),
21786 ("note".into(), json!("v3-only")),
21787 ]),
21788 base_version: None,
21789 }])
21790 .expect("queued v3 write");
21791 let changes = client.conn.total_changes();
21792 let outbox = client.pending_commit_ids();
21793 assert!(outbox.contains(&commit));
21794 let descriptor = client.get_meta("localSchemaDescriptor");
21795 let container = format!(
21796 "{path_str}{}",
21797 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
21798 );
21799 let context = std::fs::read(&container).expect("captured context");
21800 assert!(client
21801 .recreate_with_schema(&older)
21802 .expect_err("refuse recreate")
21803 .starts_with("client.schema_downgrade:"));
21804 assert_eq!(client.schema.version, 3);
21805 assert_eq!(client.conn.total_changes(), changes);
21806 assert_eq!(client.pending_commit_ids(), outbox);
21807 assert_eq!(client.get_meta("localSchemaDescriptor"), descriptor);
21808 assert_eq!(
21809 client
21810 .conn
21811 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21812 row.get::<_, String>(0)
21813 })
21814 .expect("queued row"),
21815 "v3-only"
21816 );
21817 assert_eq!(std::fs::read(&container).expect("context remains"), context);
21818 drop(client);
21819 let before = std::fs::read(&path).expect("replica bytes");
21820 for entry in 0..3 {
21823 let error = match entry {
21824 0 => SyncClient::open_path(
21825 "downgrade".into(),
21826 &older,
21827 previous_version_limits(),
21828 path_str,
21829 ),
21830 1 => SyncClient::open_path_with_identity(
21831 None,
21832 &older,
21833 previous_version_limits(),
21834 path_str,
21835 ),
21836 _ => SyncClient::with_connection(
21837 "downgrade".into(),
21838 &older,
21839 previous_version_limits(),
21840 Connection::open(&path).expect("connection"),
21841 ),
21842 }
21843 .err()
21844 .expect("refuse open");
21845 assert!(error.starts_with("client.schema_downgrade:"));
21846 let conn = Connection::open(&path).expect("inspect");
21847 assert_eq!(
21848 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
21849 .expect("journal mode"),
21850 "delete"
21851 );
21852 drop(conn);
21853 assert_eq!(std::fs::read(&path).expect("replica unchanged"), before);
21854 assert_eq!(
21855 std::fs::read(&container).expect("context unchanged"),
21856 context
21857 );
21858 }
21859 let reopened = SyncClient::open_path(
21860 "downgrade".into(),
21861 &schema,
21862 previous_version_limits(),
21863 path_str,
21864 )
21865 .expect("compatible reopen");
21866 assert_eq!(reopened.pending_commit_ids(), outbox);
21867 assert_eq!(
21868 reopened
21869 .conn
21870 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21871 row.get::<_, String>(0)
21872 })
21873 .expect("queued row remains"),
21874 "v3-only"
21875 );
21876 reopened.delete_meta(LOCAL_SCHEMA_VERSION_KEY);
21877 reopened.delete_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
21878 drop(reopened);
21879 let legacy = SyncClient::open_path(
21880 "downgrade".into(),
21881 &schema,
21882 previous_version_limits(),
21883 path_str,
21884 )
21885 .expect("legacy no-marker reopen");
21886 assert_eq!(legacy.pending_commit_ids(), outbox);
21887 assert!(!legacy.upgrading());
21888 assert_eq!(
21889 legacy
21890 .conn
21891 .query_row("SELECT note FROM tasks WHERE id = 'queued'", [], |row| {
21892 row.get::<_, String>(0)
21893 })
21894 .expect("legacy row remains"),
21895 "v3-only"
21896 );
21897 drop(legacy);
21898 std::fs::remove_file(path).expect("remove replica");
21899 let _ = std::fs::remove_file(container);
21900 }
21901
21902 #[test]
21903 fn schema_marker_errors_refuse_open_and_recreate_without_mutation() {
21904 for marker in [
21905 "''",
21906 "'bad'",
21907 "'0'",
21908 "'-1'",
21909 "'2.5'",
21910 "'03'",
21911 "'3\n'",
21912 "' 3'",
21913 "'+3'",
21914 "'3e0'",
21915 "'2147483648'",
21916 "NULL",
21917 "X'33'",
21918 ] {
21919 let conn = Connection::open_in_memory().expect("connection");
21920 conn.execute_batch(&format!("CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY, value); INSERT INTO _syncular_meta VALUES ('localSchemaVersion', {marker});")).expect("invalid marker fixture");
21921 let error = SyncClient::with_connection(
21922 "marker".into(),
21923 &previous_version_schema(3),
21924 ClientLimits::default(),
21925 conn,
21926 )
21927 .err()
21928 .expect("refuse marker");
21929 assert!(
21930 error.starts_with("sync.local_corrupt:"),
21931 "{marker}: {error}"
21932 );
21933 }
21934 for fault in [
21935 "UPDATE _syncular_meta SET value = 'bad' WHERE key = 'localSchemaVersion'",
21936 "ALTER TABLE _syncular_meta RENAME COLUMN value TO unreadable_value",
21937 ] {
21938 let mut client = SyncClient::new(
21939 "marker".into(),
21940 &previous_version_schema(3),
21941 ClientLimits::default(),
21942 )
21943 .expect("fresh");
21944 let commit = client
21945 .mutate(vec![Mutation::Upsert {
21946 table: "tasks".into(),
21947 values: Map::from_iter([("id".into(), json!("queued"))]),
21948 base_version: None,
21949 }])
21950 .expect("queued write");
21951 client.conn.execute_batch(fault).expect("marker fault");
21952 let changes = client.conn.total_changes();
21953 let error = client
21954 .recreate_with_schema(&previous_version_schema(4))
21955 .expect_err("refuse corrupt marker");
21956 assert!(error.starts_with("sync.local_corrupt:"));
21957 assert_eq!(client.conn.total_changes(), changes);
21958 assert_eq!(client.schema.version, 3);
21959 assert_eq!(client.pending_commit_ids(), vec![commit]);
21960 }
21961 for setup in [
21962 "CREATE TABLE _syncular_meta(key TEXT PRIMARY KEY)",
21963 "CREATE VIEW _syncular_meta AS SELECT 'localSchemaVersion' AS key, '3' AS value",
21964 ] {
21965 let path =
21966 std::env::temp_dir().join(format!("syncular-marker-{}.db", uuid::Uuid::new_v4()));
21967 let conn = Connection::open(&path).expect("connection");
21968 conn.execute_batch(setup)
21969 .expect("unreadable metadata fixture");
21970 drop(conn);
21971 let before = std::fs::read(&path).expect("before");
21972 let error = SyncClient::open_path_with_identity(
21973 None,
21974 &previous_version_schema(3),
21975 ClientLimits::default(),
21976 path.to_str().expect("path"),
21977 )
21978 .err()
21979 .expect("refuse unreadable marker");
21980 assert!(error.starts_with("sync.local_corrupt:"));
21981 assert_eq!(std::fs::read(&path).expect("after"), before);
21982 std::fs::remove_file(path).expect("remove fixture");
21983 }
21984 }
21985
21986 std::thread_local! {
21987 static CONCURRENT_UPGRADE: RefCell<Option<Connection>> =
21990 const { RefCell::new(None) };
21991 }
21992
21993 fn commit_concurrent_upgrade(attempts: i32) -> bool {
21994 CONCURRENT_UPGRADE.with(|slot| {
21995 if let Some(banner) = slot.borrow_mut().take() {
21996 banner
21997 .execute_batch(
21998 "UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion'; COMMIT",
21999 )
22000 .expect("commit the concurrent upgrade");
22001 }
22002 });
22003 attempts < 10
22004 }
22005
22006 fn journal_mode(conn: &Connection) -> String {
22007 conn.query_row("PRAGMA journal_mode", [], |row| row.get::<_, String>(0))
22008 .expect("journal mode")
22009 }
22010
22011 fn temp_replica(label: &str) -> std::path::PathBuf {
22012 std::env::temp_dir().join(format!("syncular-{label}-{}.db", uuid::Uuid::new_v4()))
22013 }
22014
22015 #[test]
22016 fn file_identity_constructor_requires_the_returned_wal_mode() {
22017 for path in [":memory:", ""] {
22018 let error = SyncClient::open_path_with_identity(
22019 None,
22020 &previous_version_schema(1),
22021 ClientLimits::default(),
22022 path,
22023 )
22024 .err()
22025 .expect("temporary SQLite databases cannot enter WAL mode");
22026 assert_eq!(
22027 error,
22028 "sync.invalid_request: file-backed client requires WAL journal mode"
22029 );
22030 }
22031 let path = temp_replica("confirmed-wal");
22032 let client = SyncClient::open_path_with_identity(
22033 None,
22034 &previous_version_schema(1),
22035 ClientLimits::default(),
22036 path.to_str().unwrap(),
22037 )
22038 .unwrap();
22039 assert_eq!(journal_mode(&client.conn), "wal");
22040 drop(client);
22041 std::fs::remove_file(path).unwrap();
22042 }
22043
22044 #[test]
22045 fn non_wal_identity_open_is_accepted_when_wal_is_not_required() {
22046 for path in [":memory:", ""] {
22047 let client = SyncClient::open_path_with_identity_journal(
22048 None,
22049 &previous_version_schema(1),
22050 ClientLimits::default(),
22051 path,
22052 false,
22053 )
22054 .expect("a host without WAL can still open the identity constructor");
22055 assert_ne!(journal_mode(&client.conn), "wal");
22056 assert_eq!(
22057 client
22058 .conn
22059 .query_row("PRAGMA synchronous", [], |row| row.get::<_, i64>(0))
22060 .expect("synchronous"),
22061 2,
22062 "every identity open pins FULL durability"
22063 );
22064 }
22065 }
22066
22067 #[test]
22068 fn reset_replica_deletes_the_database_and_its_sidecars_through_the_vfs() {
22069 let path = temp_replica("reset");
22070 let path_str = path.to_str().expect("path");
22071 let client = SyncClient::open_path(
22072 "reset-replica".into(),
22073 &previous_version_schema(1),
22074 ClientLimits::default(),
22075 path_str,
22076 )
22077 .expect("replica");
22078 let original_id = client.client_id().to_owned();
22079 drop(client);
22080 for suffix in ["-journal", "-wal", "-shm"] {
22081 std::fs::write(format!("{path_str}{suffix}"), b"stale sidecar").expect("sidecar");
22082 }
22083
22084 SyncClient::reset_replica(path_str).expect("reset through the VFS");
22085
22086 for suffix in ["", "-journal", "-wal", "-shm"] {
22087 assert!(
22088 !std::path::Path::new(&format!("{path_str}{suffix}")).exists(),
22089 "reset removes the replica file {suffix:?}"
22090 );
22091 }
22092 let reopened = SyncClient::open_path_with_identity(
22093 None,
22094 &previous_version_schema(1),
22095 ClientLimits::default(),
22096 path_str,
22097 )
22098 .expect("fresh replica");
22099 assert_ne!(reopened.client_id(), original_id);
22100 assert!(reopened
22101 .query("SELECT id FROM tasks", &[])
22102 .expect("empty")
22103 .is_empty());
22104 drop(reopened);
22105 assert_eq!(
22106 SyncClient::reset_replica(":memory:").expect_err("memory has nothing to reset"),
22107 "sync.invalid_request: a memory replica has nothing to reset"
22108 );
22109 }
22110
22111 #[test]
22112 fn non_positive_generated_schema_versions_are_refused_before_storage() {
22113 for version in [json!(0), json!(-1), json!(i32::MIN), json!(2147483648i64)] {
22114 let mut schema = previous_version_schema(1);
22115 schema["version"] = version.clone();
22116 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22117 .err()
22118 .expect("refuse an out-of-range generated version");
22119 assert_eq!(
22120 error,
22121 "sync.invalid_request: generated schema version must be an integer in 1..=2147483647"
22122 );
22123 let path = temp_replica("bad-version");
22125 let path_str = path.to_str().expect("path");
22126 for identity in [false, true] {
22127 let error = if identity {
22128 SyncClient::open_path_with_identity(
22129 None,
22130 &schema,
22131 ClientLimits::default(),
22132 path_str,
22133 )
22134 } else {
22135 SyncClient::open_path(
22136 "bad-version".into(),
22137 &schema,
22138 ClientLimits::default(),
22139 path_str,
22140 )
22141 }
22142 .err()
22143 .expect("refuse before storage");
22144 assert!(
22145 error.starts_with("sync.invalid_request:"),
22146 "{version}: {error}"
22147 );
22148 assert!(
22149 !path.exists(),
22150 "{version}: a refused constructor creates no replica file"
22151 );
22152 }
22153 }
22154 let mut schema = previous_version_schema(1);
22156 schema
22157 .as_object_mut()
22158 .expect("schema object")
22159 .remove("version");
22160 let error = SyncClient::new("bad-version".into(), &schema, ClientLimits::default())
22161 .err()
22162 .expect("refuse a missing version");
22163 assert!(error.starts_with("sync.invalid_request:"), "{error}");
22164 }
22165
22166 #[test]
22167 fn a_duplicated_schema_marker_is_refused_without_a_reset() {
22168 let path = temp_replica("duplicate-marker");
22169 let path_str = path.to_str().expect("path");
22170 {
22171 let conn = Connection::open(path_str).expect("fixture");
22174 conn.execute_batch(
22175 "CREATE TABLE _syncular_meta(key TEXT, value);
22176 INSERT INTO _syncular_meta VALUES
22177 ('localSchemaVersion', '1'),
22178 ('localSchemaVersion', '2'),
22179 ('clientId', 'duplicate');",
22180 )
22181 .expect("duplicate marker fixture");
22182 }
22183 let error = SyncClient::open_path_with_identity(
22184 None,
22185 &previous_version_schema(3),
22186 ClientLimits::default(),
22187 path_str,
22188 )
22189 .err()
22190 .expect("refuse a duplicated marker");
22191 assert!(error.starts_with("sync.local_corrupt:"), "{error}");
22192 let probe = Connection::open(path_str).expect("probe");
22193 assert_eq!(
22194 probe
22195 .query_row(
22196 "SELECT COUNT(*) FROM _syncular_meta WHERE key = 'localSchemaVersion'",
22197 [],
22198 |row| row.get::<_, i64>(0),
22199 )
22200 .expect("marker rows"),
22201 2,
22202 "the duplicated rows are left for the operator, not collapsed"
22203 );
22204 assert_eq!(
22205 probe
22206 .query_row(
22207 "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name NOT LIKE '_syncular_%' AND name NOT LIKE 'sqlite_%'",
22208 [],
22209 |row| row.get::<_, i64>(0),
22210 )
22211 .expect("synced tables"),
22212 0,
22213 "a duplicated marker never drives the reset that recreates v3 tables"
22214 );
22215 assert_eq!(
22216 journal_mode(&probe),
22217 "delete",
22218 "the refusal leaves the journal mode alone"
22219 );
22220 drop(probe);
22221 std::fs::remove_file(path).expect("remove replica");
22222 }
22223
22224 #[test]
22225 fn a_refused_open_does_not_persist_wal_or_mutate_the_replica() {
22226 let path = temp_replica("refused-wal");
22227 let path_str = path.to_str().expect("path");
22228 drop(
22231 SyncClient::open_path(
22232 "owner".into(),
22233 &previous_version_schema(2),
22234 ClientLimits::default(),
22235 path_str,
22236 )
22237 .expect("v2 replica"),
22238 );
22239 let before = std::fs::read(&path).expect("before");
22240 let error = SyncClient::open_path_with_identity(
22241 Some("intruder".into()),
22242 &previous_version_schema(2),
22243 ClientLimits::default(),
22244 path_str,
22245 )
22246 .err()
22247 .expect("refuse the identity mismatch");
22248 assert!(error.starts_with("client.identity_mismatch:"), "{error}");
22249 let probe = Connection::open(path_str).expect("probe");
22250 assert_eq!(
22251 journal_mode(&probe),
22252 "delete",
22253 "a refused open must not persist WAL"
22254 );
22255 assert_eq!(std::fs::read(&path).expect("after"), before);
22256 assert_eq!(
22257 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22258 Some("2")
22259 );
22260 drop(probe);
22261 std::fs::remove_file(path).expect("remove replica");
22262 }
22263
22264 #[test]
22265 fn a_concurrent_marker_upgrade_refuses_a_stale_open() {
22266 let path = temp_replica("marker-race");
22267 let path_str = path.to_str().expect("path");
22268 drop(
22269 SyncClient::open_path_with_identity(
22270 Some("stale".into()),
22271 &previous_version_schema(1),
22272 ClientLimits::default(),
22273 path_str,
22274 )
22275 .expect("v1 replica"),
22276 );
22277 let banner = Connection::open(path_str).expect("banner");
22281 banner
22282 .execute_batch(
22283 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22284 )
22285 .expect("pending upgrade");
22286 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22287 let conn = Connection::open(path_str).expect("stale connection");
22288 conn.busy_handler(Some(commit_concurrent_upgrade))
22289 .expect("busy handler");
22290 let error = SyncClient::with_connection(
22291 "stale".into(),
22292 &previous_version_schema(1),
22293 ClientLimits::default(),
22294 conn,
22295 )
22296 .err()
22297 .expect("a concurrent upgrade must refuse the stale open");
22298 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22299 let probe = Connection::open(path_str).expect("probe");
22300 assert_eq!(
22301 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22302 Some("2"),
22303 "the concurrent upgrade survives"
22304 );
22305 drop(probe);
22306 std::fs::remove_file(path).expect("remove replica");
22307 }
22308
22309 #[test]
22310 fn a_concurrent_marker_upgrade_refuses_a_stale_recreate() {
22311 let path = temp_replica("recreate-race");
22312 let path_str = path.to_str().expect("path");
22313 let mut client = SyncClient::open_path_with_identity(
22314 Some("stale".into()),
22315 &previous_version_schema(1),
22316 ClientLimits::default(),
22317 path_str,
22318 )
22319 .expect("v1 replica");
22320 client
22321 .mutate(vec![Mutation::Upsert {
22322 table: "tasks".into(),
22323 values: Map::from_iter([("id".into(), json!("queued"))]),
22324 base_version: None,
22325 }])
22326 .expect("queued write");
22327 let commit = client.pending_commit_ids();
22328 let banner = Connection::open(path_str).expect("banner");
22329 banner
22330 .execute_batch(
22331 "BEGIN IMMEDIATE; UPDATE _syncular_meta SET value = '2' WHERE key = 'localSchemaVersion';",
22332 )
22333 .expect("pending upgrade");
22334 CONCURRENT_UPGRADE.with(|slot| *slot.borrow_mut() = Some(banner));
22335 client
22336 .conn
22337 .busy_handler(Some(commit_concurrent_upgrade))
22338 .expect("busy handler");
22339 let error = client
22340 .recreate_with_schema(&previous_version_schema(1))
22341 .expect_err("a concurrent upgrade must refuse the stale recreate");
22342 assert!(error.starts_with("client.schema_downgrade:"), "{error}");
22343 assert_eq!(client.schema.version, 1, "the compiled schema is restored");
22344 assert_eq!(
22345 client.pending_commit_ids(),
22346 commit,
22347 "the outbox is preserved"
22348 );
22349 let probe = Connection::open(path_str).expect("probe");
22350 assert_eq!(
22351 meta_get(&probe, LOCAL_SCHEMA_VERSION_KEY).as_deref(),
22352 Some("2")
22353 );
22354 drop(probe);
22355 drop(client);
22356 std::fs::remove_file(path).expect("remove replica");
22357 }
22358
22359 #[test]
22360 fn failed_schema_record_writes_roll_back_recreation() {
22361 for key in [
22362 LOCAL_SCHEMA_VERSION_KEY,
22363 crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY,
22364 ] {
22365 let mut client = SyncClient::new(
22366 "schema-record".into(),
22367 &previous_version_schema(1),
22368 ClientLimits::default(),
22369 )
22370 .expect("v1");
22371 let marker = client.get_meta(LOCAL_SCHEMA_VERSION_KEY);
22372 let descriptor = client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY);
22373 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");
22374 client
22375 .recreate_with_schema(&previous_version_schema(2))
22376 .expect_err("both record writes must propagate failure");
22377 assert_eq!(client.schema.version, 1);
22378 assert_eq!(client.get_meta(LOCAL_SCHEMA_VERSION_KEY), marker);
22379 assert_eq!(
22380 client.get_meta(crate::previous_version::LOCAL_SCHEMA_DESCRIPTOR_KEY),
22381 descriptor
22382 );
22383 client
22384 .conn
22385 .execute_batch("DROP TRIGGER refuse_schema_record")
22386 .expect("remove fault");
22387 client
22388 .recreate_with_schema(&previous_version_schema(2))
22389 .expect("retry");
22390 assert_eq!(client.schema.version, 2);
22391 }
22392 }
22393
22394 #[test]
22395 fn a_failed_outer_commit_restores_every_reset_side_effect() {
22396 let path = temp_replica("outer-commit");
22397 let path_str = path.to_str().expect("path");
22398 let conn = Connection::open(path_str).expect("replica connection");
22401 let mut client = SyncClient::with_connection(
22402 "outer-commit".into(),
22403 &previous_version_schema(1),
22404 previous_version_limits(),
22405 conn,
22406 )
22407 .expect("v1 replica");
22408 assert_eq!(journal_mode(&client.conn), "delete");
22409 let commit = client
22410 .mutate(vec![Mutation::Upsert {
22411 table: "tasks".into(),
22412 values: Map::from_iter([
22413 ("id".into(), json!("queued")),
22414 ("note".into(), json!("dropped-by-v2")),
22415 ]),
22416 base_version: None,
22417 }])
22418 .expect("queued write");
22419 client.drain_change_batches();
22420 let mut dropped = previous_version_schema(2);
22422 dropped["tables"][0]["columns"] =
22423 json!([{ "name": "id", "type": "string", "nullable": false }]);
22424 let marker = meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY);
22427 let revision = client.local_revision();
22428 let rows = client.query("SELECT * FROM tasks", &[]).expect("rows");
22429 let columns: i64 = client
22430 .conn
22431 .query_row(
22432 "SELECT count(*) FROM pragma_table_info('tasks')",
22433 [],
22434 |row| row.get(0),
22435 )
22436 .expect("column count");
22437 let rebuilds = client.overlay_rebuild_count.get();
22438 let prior_subs = client.subs.len();
22439 let prior_changes = client.change_queue.len();
22440 let prior_intents = client.sync_intent_queue.len();
22441 let prior_last_change = client.last_change.clone();
22442 let prior_overlay_dirty = client.overlay_dirty.get();
22443 let prior_sync_needed = client.sync_needed;
22444 let prior_upgrading = client.upgrading;
22445 let prior_stopped = client.stopped;
22446 let prior_schema_floor = client.schema_floor.clone();
22447 let prior_active_round = client.active_round;
22448 let reader = Connection::open(path_str).expect("reader");
22451 reader
22452 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22453 .expect("reader snapshot");
22454 client
22455 .conn
22456 .busy_timeout(std::time::Duration::ZERO)
22457 .expect("busy timeout");
22458 let error = client
22459 .recreate_with_schema(&dropped)
22460 .expect_err("a blocked COMMIT must refuse the recreation");
22461 assert!(error.starts_with("client.storage_busy:"), "{error}");
22462 assert!(
22463 client.overlay_rebuild_count.get() > rebuilds,
22464 "the reset ran to its overlay rebuild inside the transaction"
22465 );
22466 assert!(client.conn.is_autocommit(), "the transaction is released");
22467 assert_eq!(meta_get(&client.conn, LOCAL_SCHEMA_VERSION_KEY), marker);
22469 assert_eq!(client.local_revision(), revision);
22470 assert_eq!(
22471 client.query("SELECT * FROM tasks", &[]).expect("rows"),
22472 rows
22473 );
22474 assert_eq!(
22475 client
22476 .conn
22477 .query_row(
22478 "SELECT count(*) FROM pragma_table_info('tasks')",
22479 [],
22480 |row| { row.get::<_, i64>(0) }
22481 )
22482 .expect("column count"),
22483 columns,
22484 "the previous table layout survives the rollback"
22485 );
22486 assert_eq!(client.pending_commit_ids(), vec![commit.clone()]);
22487 assert_eq!(client.schema.version, 1, "compiled schema");
22490 assert_eq!(client.subs.len(), prior_subs);
22491 assert_eq!(client.change_queue.len(), prior_changes);
22492 assert_eq!(client.sync_intent_queue.len(), prior_intents);
22493 assert_eq!(client.last_change, prior_last_change);
22494 assert_eq!(client.overlay_dirty.get(), prior_overlay_dirty);
22495 assert_eq!(client.sync_needed, prior_sync_needed);
22496 assert_eq!(client.upgrading, prior_upgrading);
22497 assert_eq!(client.stopped, prior_stopped);
22498 assert_eq!(client.schema_floor, prior_schema_floor);
22499 assert_eq!(client.active_round, prior_active_round);
22500 assert!(
22501 client.rejections.is_empty(),
22502 "the dropped-column rejection is rolled back too"
22503 );
22504 let container = crate::previous_version::previous_version_container_path(path_str);
22507 assert!(std::path::Path::new(&container).exists());
22508 drop(reader);
22511 let snapshot = client
22512 .previous_version_snapshot(&PreviousVersionReadSpec {
22513 table: "tasks".into(),
22514 ..Default::default()
22515 })
22516 .expect("refuse stale capture");
22517 assert!(!snapshot.available);
22518 assert_eq!(snapshot.current_version, 1);
22519 assert!(!std::path::Path::new(&container).exists());
22520 client
22525 .conn
22526 .busy_timeout(std::time::Duration::from_secs(1))
22527 .expect("busy timeout");
22528 client
22529 .recreate_with_schema(&dropped)
22530 .expect("the recreation succeeds once the reader releases");
22531 assert_eq!(client.schema.version, 2);
22532 assert_eq!(
22533 client.pending_commit_ids(),
22534 vec![commit.clone()],
22535 "the reset kept the incompatible commit pending"
22536 );
22537 assert!(
22538 client.rejections.is_empty(),
22539 "no rejection exists before the send-time prepass"
22540 );
22541 client
22543 .prepare_sync_round(false)
22544 .expect("the send prepass runs during round preparation");
22545 assert!(client.pending_commit_ids().is_empty());
22546 assert!(
22547 client
22548 .rejections
22549 .iter()
22550 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE),
22551 "the dropped-column commit surfaced as a rejection at send time"
22552 );
22553 client.previous_version_discard();
22554 drop(client);
22555 std::fs::remove_file(path).expect("remove replica");
22556 }
22557
22558 #[test]
22559 fn the_send_time_incompatible_drop_is_atomic_and_publishes_once() {
22560 let v1 = previous_version_schema(1);
22561 let mut v2 = previous_version_schema(2);
22562 v2["tables"][0]["columns"] = json!([{ "name": "id", "type": "string", "nullable": false }]);
22564 let mut client = SyncClient::new("drop".to_string(), &v1, ClientLimits::default()).unwrap();
22565 let dropped = client
22566 .mutate(vec![Mutation::Upsert {
22567 table: "tasks".into(),
22568 values: Map::from_iter([
22569 ("id".into(), json!("dropped")),
22570 ("note".into(), json!("gone")),
22571 ]),
22572 base_version: None,
22573 }])
22574 .unwrap();
22575 let survivor = client
22579 .mutate(vec![Mutation::Delete {
22580 table: "tasks".into(),
22581 row_id: "gone".into(),
22582 base_version: None,
22583 }])
22584 .unwrap();
22585 client
22586 .recreate_with_schema(&v2)
22587 .expect("the reset preserves the outbox");
22588 assert_eq!(
22589 client.pending_commit_ids(),
22590 vec![dropped.clone(), survivor.clone()],
22591 "the reset preserves both commits"
22592 );
22593 assert!(client.rejections.is_empty(), "the reset classifies nothing");
22594 client.drain_change_batches();
22595 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22596 assert_eq!(rows.len(), 1, "only the dropped upsert has a visible row");
22597
22598 for fault in [
22604 "outcome-insert",
22605 "outbox-delete",
22606 "overlay-rebuild",
22607 "publish-revision",
22608 ] {
22609 client.set_sync_needed(false, false);
22610 client.drain_change_batches();
22611 let revision = client.local_revision();
22612 let trigger = match fault {
22613 "outcome-insert" => {
22614 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22615 }
22616 "outbox-delete" => {
22617 "CREATE TRIGGER fail_drop BEFORE DELETE ON _syncular_outbox BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22618 }
22619 "publish-revision" => {
22624 "CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_meta WHEN NEW.key = 'localRevision' BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22625 }
22626 _ => {
22627 "CREATE TRIGGER fail_drop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT,'drop fault'); END"
22628 }
22629 };
22630 client.conn.execute_batch(trigger).unwrap();
22631 let outcome = client
22632 .prepare_sync_round(false)
22633 .err()
22634 .unwrap_or_else(|| panic!("{fault}: a fault in the drop must fail the round"));
22635 assert!(
22636 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "storage.failed"),
22637 "{fault}: {outcome:?}"
22638 );
22639 client.conn.execute_batch("DROP TRIGGER fail_drop").unwrap();
22640 assert!(client.conn.is_autocommit(), "{fault}: no leaked savepoint");
22641 assert_eq!(
22642 client.pending_commit_ids(),
22643 vec![dropped.clone(), survivor.clone()],
22644 "{fault}: the outbox is restored"
22645 );
22646 assert!(
22647 client.rejections.is_empty(),
22648 "{fault}: no in-memory rejection survives"
22649 );
22650 assert!(
22651 client.commit_outcome(&dropped).unwrap().is_none(),
22652 "{fault}: the durable outcome rolled back"
22653 );
22654 assert_eq!(client.local_revision(), revision, "{fault}: revision");
22655 assert_eq!(
22656 client.query("SELECT * FROM tasks", &[]).unwrap(),
22657 rows,
22658 "{fault}: the visible projection is unchanged"
22659 );
22660 assert!(
22661 client.drain_change_batches().is_empty(),
22662 "{fault}: nothing is published"
22663 );
22664 }
22665
22666 client
22669 .conn
22670 .execute_batch(
22671 "BEGIN; INSERT INTO _syncular_meta(key,value) VALUES ('outerProbe','kept');
22672 CREATE TRIGGER fail_drop BEFORE INSERT ON _syncular_commit_outcomes
22673 BEGIN SELECT RAISE(ABORT,'nested drop fault'); END;",
22674 )
22675 .unwrap();
22676 assert!(client.drop_incompatible_outbox().is_err());
22677 assert!(!client.conn.is_autocommit());
22678 assert_eq!(client.get_meta("outerProbe").as_deref(), Some("kept"));
22679 assert_eq!(
22680 client.pending_commit_ids(),
22681 vec![dropped.clone(), survivor.clone()]
22682 );
22683 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22684 client
22685 .conn
22686 .execute_batch("DROP TRIGGER fail_drop; COMMIT")
22687 .unwrap();
22688
22689 client.set_meta(LOG_EPOCH_KEY, "epoch");
22694 client.set_sync_needed(false, false);
22695 client.drain_change_batches();
22696 let revision = client.local_revision();
22697 let prepared = client.prepare_sync_round(false).expect("the drop succeeds");
22698 assert_eq!(client.pending_commit_ids(), vec![survivor.clone()]);
22699 assert_eq!(
22700 prepared.meta.pushed_ids,
22701 vec![survivor.clone()],
22702 "the incompatible commit is classified before any operation is sent"
22703 );
22704 let rejection = client
22705 .rejections
22706 .iter()
22707 .find(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE)
22708 .expect("a sync.outbox_incompatible rejection");
22709 assert_eq!(rejection.client_commit_id, dropped);
22710 assert!(
22711 !rejection.retryable,
22712 "a schema-incompatible commit is final"
22713 );
22714 assert_eq!(
22715 client
22716 .commit_outcome(&dropped)
22717 .unwrap()
22718 .expect("a durable rejected outcome")
22719 .status,
22720 CommitOutcomeStatus::Rejected
22721 );
22722 assert_eq!(client.local_revision(), revision + 1, "one revision");
22723 let batches = client.drain_change_batches();
22724 assert_eq!(batches.len(), 1, "one change batch");
22725 assert!(
22726 batches[0].rejections_changed
22727 && batches[0].outcomes_changed
22728 && batches[0].status.is_some(),
22729 "{:?}",
22730 batches[0]
22731 );
22732 assert!(
22733 batches[0].tables.iter().any(|entry| entry.table == "tasks"),
22734 "{:?}",
22735 batches[0]
22736 );
22737 let visible = client.query("SELECT * FROM tasks", &[]).unwrap();
22738 assert!(
22739 visible.is_empty(),
22740 "the dropped projection is undone: {visible:?}"
22741 );
22742
22743 let path = temp_replica("outbox-drop-commit");
22747 let path_str = path.to_str().expect("path");
22748 let conn = Connection::open(path_str).expect("replica connection");
22749 let mut client =
22750 SyncClient::with_connection("drop-commit".into(), &v1, ClientLimits::default(), conn)
22751 .expect("v1 replica");
22752 let dropped = client
22753 .mutate(vec![Mutation::Upsert {
22754 table: "tasks".into(),
22755 values: Map::from_iter([
22756 ("id".into(), json!("dropped")),
22757 ("note".into(), json!("gone")),
22758 ]),
22759 base_version: None,
22760 }])
22761 .unwrap();
22762 client
22763 .recreate_with_schema(&v2)
22764 .expect("the reset preserves the outbox");
22765 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22766 let rows = client.query("SELECT * FROM tasks", &[]).unwrap();
22767 assert_eq!(rows.len(), 1, "the optimistic row is visible");
22768 client.set_sync_needed(false, false);
22769 client.drain_change_batches();
22770 let revision = client.local_revision();
22771 let reader = Connection::open(path_str).expect("reader");
22772 reader
22773 .execute_batch("BEGIN; SELECT count(*) FROM _syncular_meta;")
22774 .expect("reader snapshot");
22775 client
22776 .conn
22777 .busy_timeout(std::time::Duration::ZERO)
22778 .expect("busy timeout");
22779 let outcome = client
22780 .prepare_sync_round(false)
22781 .err()
22782 .expect("a blocked COMMIT must fail the round");
22783 assert!(
22784 matches!(*outcome, SyncOutcome::Failed { ref error_code, .. } if error_code == "client.storage_busy"),
22785 "{outcome:?}"
22786 );
22787 assert!(client.conn.is_autocommit(), "the savepoint is released");
22788 assert_eq!(client.pending_commit_ids(), vec![dropped.clone()]);
22789 assert!(client.rejections.is_empty());
22790 assert!(client.commit_outcome(&dropped).unwrap().is_none());
22791 assert_eq!(client.local_revision(), revision);
22792 assert_eq!(client.query("SELECT * FROM tasks", &[]).unwrap(), rows);
22793 assert!(client.drain_change_batches().is_empty());
22794 drop(reader);
22796 client
22797 .conn
22798 .busy_timeout(std::time::Duration::from_secs(1))
22799 .expect("busy timeout");
22800 client
22801 .prepare_sync_round(false)
22802 .expect("the drop succeeds after the reader releases");
22803 assert!(client.pending_commit_ids().is_empty());
22804 assert!(client
22805 .rejections
22806 .iter()
22807 .any(|rejection| rejection.code == OUTBOX_INCOMPATIBLE_CODE));
22808 assert!(client.query("SELECT * FROM tasks", &[]).unwrap().is_empty());
22809 drop(client);
22810 std::fs::remove_file(path).expect("remove replica");
22811 }
22812
22813 #[test]
22814 fn previous_version_read_reasons_come_from_live_client_state() {
22815 use crate::previous_version::{
22816 PreviousVersionReadSpec, PreviousVersionReason, PREVIOUS_VERSION_CONTAINER_SUFFIX,
22817 };
22818 let path = std::env::temp_dir()
22819 .join(format!("syncular-prev-wiring-{}.db", uuid::Uuid::new_v4()))
22820 .to_string_lossy()
22821 .into_owned();
22822 let container = format!("{path}{PREVIOUS_VERSION_CONTAINER_SUFFIX}");
22823 {
22824 let mut setup = SyncClient::open_path(
22825 "wiring".to_owned(),
22826 &previous_version_schema(1),
22827 previous_version_limits(),
22828 &path,
22829 )
22830 .expect("v1 install");
22831 setup
22832 .mutate(vec![Mutation::Upsert {
22833 table: "tasks".to_owned(),
22834 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22835 base_version: None,
22836 }])
22837 .expect("seed a row");
22838 }
22839 let mut client = SyncClient::open_path(
22840 "wiring".to_owned(),
22841 &previous_version_schema(2),
22842 previous_version_limits(),
22843 &path,
22844 )
22845 .expect("v2 bump captures");
22846 assert!(std::path::Path::new(&container).exists());
22847 let spec = PreviousVersionReadSpec {
22848 table: "tasks".to_owned(),
22849 ..Default::default()
22850 };
22851
22852 recapture_previous_version(&client);
22854 client.lease_state = Some(LeaseState {
22855 lease_id: None,
22856 expires_at_ms: None,
22857 error_code: Some("lease.expired".to_owned()),
22858 });
22859 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22860 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22861 assert!(!snapshot.available);
22862 assert!(!std::path::Path::new(&container).exists());
22863
22864 recapture_previous_version(&client);
22866 client.set_now_ms(1_000);
22867 client.lease_state = Some(LeaseState {
22868 lease_id: Some("lease".to_owned()),
22869 expires_at_ms: Some(1_000),
22870 error_code: None,
22871 });
22872 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22873 assert_eq!(snapshot.reason, Some(PreviousVersionReason::LeaseInactive));
22874 assert!(!std::path::Path::new(&container).exists());
22875 client.lease_state = None;
22876
22877 recapture_previous_version(&client);
22879 client.subs.push(subscription(SubState::Revoked, 0, None));
22880 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22881 assert_eq!(snapshot.reason, Some(PreviousVersionReason::ScopeRevoked));
22882 assert!(!std::path::Path::new(&container).exists());
22883 client.subs.clear();
22884
22885 recapture_previous_version(&client);
22888 client
22889 .subs
22890 .push(subscription(SubState::Active, 0, Some("resume")));
22891 assert!(!client.previous_version_coverage_complete());
22892 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22893 assert!(snapshot.available);
22894 assert_eq!(snapshot.previous_version, Some(2));
22895 assert_eq!(snapshot.rows.len(), 1);
22896
22897 recapture_previous_version(&client);
22898 client.subs[0].bootstrap_state = None;
22899 assert!(client.previous_version_coverage_complete());
22900 let snapshot = client.previous_version_snapshot(&spec).expect("snapshot");
22901 assert_eq!(
22902 snapshot.reason,
22903 Some(PreviousVersionReason::CoverageComplete)
22904 );
22905 assert!(!std::path::Path::new(&container).exists());
22906 client.subs.clear();
22907
22908 assert!(!client.previous_version_coverage_complete());
22910
22911 drop(client);
22912 let _ = std::fs::remove_file(&path);
22913 }
22914
22915 #[test]
22922 fn previous_version_lifetime_check_drops_on_lease_coverage_and_revocation() {
22923 let path = std::env::temp_dir()
22924 .join(format!(
22925 "syncular-prev-lifetime-{}.db",
22926 uuid::Uuid::new_v4()
22927 ))
22928 .to_string_lossy()
22929 .into_owned();
22930 let container = format!(
22931 "{path}{}",
22932 crate::previous_version::PREVIOUS_VERSION_CONTAINER_SUFFIX
22933 );
22934 let exists = || std::path::Path::new(&container).exists();
22935 {
22936 let mut setup = SyncClient::open_path(
22937 "lifetime".to_owned(),
22938 &previous_version_schema(1),
22939 previous_version_limits(),
22940 &path,
22941 )
22942 .expect("v1 install");
22943 setup
22944 .mutate(vec![Mutation::Upsert {
22945 table: "tasks".to_owned(),
22946 values: Map::from_iter([("id".to_owned(), json!("t1"))]),
22947 base_version: None,
22948 }])
22949 .expect("seed a row");
22950 }
22951 let mut client = SyncClient::open_path(
22952 "lifetime".to_owned(),
22953 &previous_version_schema(2),
22954 previous_version_limits(),
22955 &path,
22956 )
22957 .expect("v2 bump captures");
22958 client.set_now_ms(1_000);
22961 assert!(exists());
22962
22963 client.lease_state = Some(LeaseState {
22965 lease_id: None,
22966 expires_at_ms: None,
22967 error_code: Some("lease.expired".to_owned()),
22968 });
22969 client.previous_version_lifetime_check();
22970 assert!(!exists());
22971 client.lease_state = None;
22972
22973 recapture_previous_version(&client);
22976 client.subs.push(subscription(SubState::Active, 0, None));
22977 client.previous_version_lifetime_check();
22978 assert!(!exists());
22979 client.subs.clear();
22980
22981 recapture_previous_version(&client);
22983 client.subs.push(subscription(SubState::Revoked, 0, None));
22984 client.previous_version_lifetime_check();
22985 assert!(!exists());
22986 client.subs.clear();
22987
22988 recapture_previous_version(&client);
22991 client
22992 .subs
22993 .push(subscription(SubState::Active, 0, Some("resume")));
22994 client.previous_version_lifetime_check();
22995 assert!(exists());
22996 client.subs.clear();
22997
22998 drop(client);
22999 let _ = std::fs::remove_file(&path);
23000 let _ = std::fs::remove_file(&container);
23001 let _ = std::fs::remove_file(format!("{container}-wal"));
23002 let _ = std::fs::remove_file(format!("{container}-shm"));
23003 }
23004}
23005
23006#[cfg(test)]
23007mod scope_move_delivery_tests {
23008 use super::*;
23009 use crate::native_transport::HostTransport;
23010 use crate::values::encode_row_json;
23011 use serde_json::json;
23012
23013 fn client() -> SyncClient {
23014 SyncClient::new(
23015 "scope-move".to_owned(),
23016 &json!({
23017 "version": 1,
23018 "tables": [{
23019 "name": "tasks",
23020 "primaryKey": "id",
23021 "columns": [
23022 { "name": "id", "type": "string", "nullable": false },
23023 { "name": "project_id", "type": "string", "nullable": false }
23024 ],
23025 "scopes": [{ "pattern": "project:{project_id}" }]
23026 }]
23027 }),
23028 ClientLimits::default(),
23029 )
23030 .expect("test client")
23031 }
23032
23033 fn upsert(client: &SyncClient, project: &str) -> ssp2::model::Change {
23034 ssp2::model::Change {
23035 table_index: 0,
23036 row_id: "t1".to_owned(),
23037 op: Op::Upsert,
23038 row_version: Some(1),
23039 scopes: vec![("project_id".to_owned(), project.to_owned())],
23040 row: Some(
23041 encode_row_json(
23042 client.schema.table("tasks").unwrap(),
23043 "t1",
23044 &Map::from_iter([
23045 ("id".to_owned(), Value::from("t1")),
23046 ("project_id".to_owned(), Value::from(project)),
23047 ]),
23048 &client.encryption,
23049 )
23050 .unwrap(),
23051 ),
23052 }
23053 }
23054
23055 fn delete(project: &str) -> ssp2::model::Change {
23056 ssp2::model::Change {
23057 table_index: 0,
23058 row_id: "t1".to_owned(),
23059 op: Op::Delete,
23060 row_version: None,
23061 scopes: vec![("project_id".to_owned(), project.to_owned())],
23062 row: None,
23063 }
23064 }
23065
23066 #[test]
23067 fn a_scope_move_keeps_the_row_in_either_section_order() {
23068 for order in [["old", "new"], ["new", "old"]] {
23069 let mut client = client();
23070 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23071 client
23072 .subscribe(
23073 "old".into(),
23074 "tasks".into(),
23075 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23076 None,
23077 )
23078 .unwrap();
23079 client
23080 .subscribe(
23081 "new".into(),
23082 "tasks".into(),
23083 vec![("project_id".to_owned(), vec!["p2".to_owned()])],
23084 None,
23085 )
23086 .unwrap();
23087 let seed = upsert(&client, "p1");
23088 client
23089 .apply_commit_frame(&["tasks".into()], &[seed], Some(1))
23090 .unwrap();
23091 assert_eq!(
23092 client.query("SELECT project_id FROM tasks", &[]).unwrap()[0]["project_id"],
23093 json!("p1")
23094 );
23095
23096 let mut frames = vec![Frame::RespHeader {
23097 required_schema_version: None,
23098 latest_schema_version: None,
23099 log_epoch: Some("epoch-1".to_owned()),
23100 reset_required: Some(false),
23101 }];
23102 for id in order {
23103 let project = if id == "old" { "p1" } else { "p2" };
23104 frames.push(Frame::SubStart {
23105 id: id.to_owned(),
23106 status: SubStatus::Active,
23107 reason_code: String::new(),
23108 effective_scopes: vec![("project_id".to_owned(), vec![project.to_owned()])],
23109 bootstrap: false,
23110 });
23111 frames.push(Frame::Commit {
23112 commit_seq: 2,
23113 created_at_ms: 0,
23114 actor_id: "actor".to_owned(),
23115 tables: vec!["tasks".to_owned()],
23116 changes: vec![if id == "old" {
23117 delete("p1")
23118 } else {
23119 upsert(&client, "p2")
23120 }],
23121 });
23122 frames.push(Frame::SubEnd {
23123 next_cursor: 2,
23124 bootstrap_state: None,
23125 });
23126 }
23127 let (_, meta) = client.build_request(false).unwrap();
23128 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23129 let outcome = client.process_response(
23130 &mut transport,
23131 Message {
23132 wire_version: WIRE_VERSION,
23133 msg_kind: MsgKind::Response,
23134 frames,
23135 },
23136 &meta,
23137 );
23138 assert!(
23139 matches!(outcome, SyncOutcome::Ok(_)),
23140 "{order:?}: {outcome:?}"
23141 );
23142 let rows = client
23143 .query("SELECT id, project_id FROM tasks", &[])
23144 .unwrap();
23145 assert_eq!(rows.len(), 1, "{order:?}: the row was lost");
23146 assert_eq!(rows[0]["project_id"], json!("p2"), "{order:?}");
23147 }
23148 }
23149
23150 struct RefusalTransport;
23151
23152 impl Transport for RefusalTransport {
23153 fn sync(&mut self, _request: &[u8]) -> Result<Vec<u8>, TransportError> {
23154 let mut error = TransportError::new(
23155 "sync.invalid_client_id",
23156 "clientId is bound to a different actor in this partition",
23157 );
23158 error.details = Some(json!({
23159 "causeKind": "status",
23160 "httpStatus": 400,
23161 "category": "invalid-request",
23162 "retryable": false,
23163 "recommendedAction": "resetClientId"
23164 }));
23165 Err(error)
23166 }
23167
23168 fn realtime_sync(&mut self, request: &[u8]) -> Result<Vec<u8>, TransportError> {
23169 self.sync(request)
23170 }
23171
23172 fn download_segment(
23173 &mut self,
23174 _request: &SegmentRequest,
23175 _on_progress: &mut dyn FnMut(u64),
23176 ) -> Result<Vec<u8>, TransportError> {
23177 Err(TransportError::new("sync.transport_failed", "offline"))
23178 }
23179
23180 fn realtime_connect(&mut self) -> Result<(), TransportError> {
23181 Ok(())
23182 }
23183
23184 fn realtime_send(&mut self, _text: &str) -> Result<(), TransportError> {
23185 Ok(())
23186 }
23187
23188 fn realtime_close(&mut self) -> Result<(), TransportError> {
23189 Ok(())
23190 }
23191 }
23192
23193 #[test]
23194 fn an_http_refusal_carries_its_code_and_recommended_action() {
23195 let mut client = client();
23196 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23197 let outcome = client.sync(&mut RefusalTransport);
23198 let SyncOutcome::Failed {
23199 error_code,
23200 recommended_action,
23201 details,
23202 ..
23203 } = outcome
23204 else {
23205 panic!("expected a failed round");
23206 };
23207 assert_eq!(error_code, "sync.invalid_client_id");
23208 assert_eq!(recommended_action.as_deref(), Some("resetClientId"));
23209 assert_eq!(
23210 details
23211 .as_ref()
23212 .and_then(|details| details.get("httpStatus")),
23213 Some(&json!(400))
23214 );
23215 }
23216
23217 #[test]
23218 fn reset_client_id_persists_and_changes_the_request_identity() {
23219 let mut client = client();
23220 client.set_meta(LOG_EPOCH_KEY, "epoch-1");
23221 let previous = client.client_id().to_owned();
23222 let mut transport = HostTransport::new_from_config(&json!({})).unwrap();
23223 let next = client.reset_client_id(&mut transport);
23224 assert_ne!(next, previous);
23225 assert_eq!(client.client_id(), next);
23226 assert_eq!(client.get_meta(CLIENT_ID_KEY), Some(next.clone()));
23227
23228 client
23229 .subscribe(
23230 "tasks".into(),
23231 "tasks".into(),
23232 vec![("project_id".to_owned(), vec!["p1".to_owned()])],
23233 None,
23234 )
23235 .unwrap();
23236 let (message, _) = client.build_request(false).unwrap();
23237 assert!(matches!(
23238 message.frames.first(),
23239 Some(Frame::ReqHeader { client_id, .. }) if client_id == &next
23240 ));
23241 }
23242}
23243
23244#[cfg(test)]
23245mod insert_intent_tests {
23246 use super::*;
23247 use serde_json::{json, Value};
23248
23249 fn tasks_schema(version: i32, extra: Value) -> Value {
23251 let mut columns = vec![
23252 json!({ "name": "id", "type": "string", "nullable": false }),
23253 json!({ "name": "title", "type": "string", "nullable": true }),
23254 ];
23255 columns.extend(extra.as_array().cloned().unwrap_or_default());
23256 json!({
23257 "version": version,
23258 "tables": [{ "name": "tasks", "primaryKey": "id", "columns": columns, "scopes": [] }]
23259 })
23260 }
23261
23262 fn with_note() -> Value {
23263 tasks_schema(
23264 2,
23265 json!([{ "name": "note", "type": "string", "nullable": true }]),
23266 )
23267 }
23268
23269 fn upsert(id: &str, title: &str) -> Mutation {
23270 Mutation::Upsert {
23271 table: "tasks".into(),
23272 values: Map::from_iter([("id".into(), json!(id)), ("title".into(), json!(title))]),
23273 base_version: None,
23274 }
23275 }
23276
23277 fn persisted_markers(client: &SyncClient) -> Vec<Vec<Option<Value>>> {
23278 let mut statement = client
23279 .conn
23280 .prepare("SELECT ops_json FROM _syncular_outbox ORDER BY seq")
23281 .unwrap();
23282 statement
23283 .query_map([], |row| row.get::<_, String>(0))
23284 .unwrap()
23285 .map(|raw| {
23286 serde_json::from_str::<Vec<Value>>(&raw.unwrap())
23287 .unwrap()
23288 .iter()
23289 .map(|entry| entry.get("insert").cloned())
23290 .collect()
23291 })
23292 .collect()
23293 }
23294
23295 fn queued_insert_after_bump(next: &Value, strip_marker: bool) -> SyncClient {
23299 let mut client = SyncClient::new(
23300 "insert-intent".into(),
23301 &tasks_schema(1, json!([])),
23302 ClientLimits::default(),
23303 )
23304 .unwrap();
23305 client.mutate(vec![upsert("q", "new")]).unwrap();
23306 if strip_marker {
23307 client
23308 .conn
23309 .execute_batch(
23310 "UPDATE _syncular_outbox SET ops_json = json_remove(ops_json, '$[0].insert')",
23311 )
23312 .unwrap();
23313 client.outbox[0].ops[0].insert = false;
23314 }
23315 client.recreate_with_schema(next).unwrap();
23316 client
23317 }
23318
23319 fn seed_base_row(client: &mut SyncClient, row: Row) {
23322 client
23323 .write_row(&base_table("tasks"), "tasks", &row, 1)
23324 .unwrap();
23325 client.overlay_dirty.set(true);
23326 client.rebuild_overlay().unwrap();
23327 }
23328
23329 fn sparse_payload(client: &SyncClient, values: Value) -> Vec<u8> {
23330 let table = client.schema.table("tasks").unwrap();
23331 encode_sparse_row_json(
23332 table,
23333 "q",
23334 values.as_object().unwrap(),
23335 &client.encryption,
23336 None,
23337 )
23338 .unwrap()
23339 }
23340
23341 #[test]
23342 fn mutate_records_insert_intent_only_for_rows_absent_at_call_time() {
23343 let mut client = SyncClient::new(
23344 "insert-intent".into(),
23345 &tasks_schema(1, json!([])),
23346 ClientLimits::default(),
23347 )
23348 .unwrap();
23349 client
23351 .mutate(vec![upsert("a", "first"), upsert("a", "second")])
23352 .unwrap();
23353 client.mutate(vec![upsert("a", "visible")]).unwrap();
23354 client
23355 .patch(
23356 "tasks",
23357 "a",
23358 Map::from_iter([("title".into(), json!("patched"))]),
23359 None,
23360 )
23361 .unwrap();
23362 client
23363 .mutate(vec![Mutation::Delete {
23364 table: "tasks".into(),
23365 row_id: "a".into(),
23366 base_version: None,
23367 }])
23368 .unwrap();
23369
23370 let marker = Some(Value::Bool(true));
23371 assert_eq!(
23372 persisted_markers(&client),
23373 vec![
23374 vec![marker.clone(), marker],
23375 vec![None],
23376 vec![None],
23377 vec![None]
23378 ]
23379 );
23380 let flags = |commits: &[OutboxCommit]| {
23381 commits
23382 .iter()
23383 .map(|commit| commit.ops.iter().map(|op| op.insert).collect::<Vec<_>>())
23384 .collect::<Vec<_>>()
23385 };
23386 let expected = vec![vec![true, true], vec![false], vec![false], vec![false]];
23387 assert_eq!(flags(&client.outbox), expected);
23388 assert_eq!(
23389 flags(&client.load_outbox().unwrap()),
23390 expected,
23391 "reload keeps it"
23392 );
23393
23394 let insert = serde_json::to_value(CommitOperation::from(&client.outbox[0].ops[0])).unwrap();
23396 assert_eq!(insert["insert"], json!(true));
23397 assert!(
23398 serde_json::from_value::<CommitOperation>(insert)
23399 .unwrap()
23400 .insert
23401 );
23402 let update = serde_json::to_value(CommitOperation::from(&client.outbox[1].ops[0])).unwrap();
23403 assert!(update.get("insert").is_none());
23404 }
23405
23406 #[test]
23407 fn insert_intent_replays_and_encodes_an_appended_nullable_column_as_null() {
23408 let mut client = queued_insert_after_bump(&with_note(), false);
23409 assert_eq!(
23410 client
23411 .query("SELECT id, title, note FROM tasks", &[])
23412 .unwrap(),
23413 vec![Map::from_iter([
23414 ("id".into(), json!("q")),
23415 ("title".into(), json!("new")),
23416 ("note".into(), Value::Null),
23417 ])],
23418 "the replayed insert is visible with the appended column NULL"
23419 );
23420 assert_eq!(
23421 client.pending_payloads(),
23422 vec![sparse_payload(
23423 &client,
23424 json!({ "id": "q", "title": "new", "note": null })
23425 )],
23426 "the insert presents the appended column as NULL"
23427 );
23428
23429 seed_base_row(
23431 &mut client,
23432 vec![
23433 Some(ColumnValue::String("q".into())),
23434 Some(ColumnValue::String("base".into())),
23435 Some(ColumnValue::String("server".into())),
23436 ],
23437 );
23438 assert_eq!(
23439 client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23440 vec![Map::from_iter([
23441 ("title".into(), json!("new")),
23442 ("note".into(), Value::Null),
23443 ])]
23444 );
23445 }
23446
23447 #[test]
23448 fn an_operation_without_the_marker_keeps_its_presence_set() {
23449 let mut client = queued_insert_after_bump(&with_note(), true);
23450 assert!(
23451 client
23452 .query("SELECT id FROM tasks", &[])
23453 .unwrap()
23454 .is_empty(),
23455 "a partial operation over an absent row stays invisible"
23456 );
23457 assert_eq!(
23458 client.pending_payloads(),
23459 vec![sparse_payload(
23460 &client,
23461 json!({ "id": "q", "title": "new" })
23462 )],
23463 "the appended column stays absent"
23464 );
23465
23466 seed_base_row(
23467 &mut client,
23468 vec![
23469 Some(ColumnValue::String("q".into())),
23470 Some(ColumnValue::String("base".into())),
23471 Some(ColumnValue::String("server".into())),
23472 ],
23473 );
23474 assert_eq!(
23475 client.query("SELECT title, note FROM tasks", &[]).unwrap(),
23476 vec![Map::from_iter([
23477 ("title".into(), json!("new")),
23478 ("note".into(), json!("server")),
23479 ])],
23480 "the stored appended value survives"
23481 );
23482 }
23483
23484 #[test]
23485 fn insert_intent_missing_a_non_nullable_column_is_outbox_incompatible() {
23486 let with_rank = tasks_schema(
23487 2,
23488 json!([{ "name": "rank", "type": "integer", "nullable": false }]),
23489 );
23490 let mut client = SyncClient::new(
23491 "insert-intent".into(),
23492 &tasks_schema(1, json!([])),
23493 ClientLimits::default(),
23494 )
23495 .unwrap();
23496 let insert = client.mutate(vec![upsert("q", "new")]).unwrap();
23497 let update = client.mutate(vec![upsert("q", "edited")]).unwrap();
23500 client.recreate_with_schema(&with_rank).unwrap();
23501 assert_eq!(
23502 client.pending_commit_ids(),
23503 vec![insert.clone(), update.clone()],
23504 "the reset classifies nothing"
23505 );
23506 assert!(client.rejections.is_empty());
23507 assert!(
23508 client
23509 .query("SELECT id FROM tasks", &[])
23510 .unwrap()
23511 .is_empty(),
23512 "the unencodable insert stays invisible"
23513 );
23514
23515 assert!(client.drop_incompatible_outbox().unwrap());
23516 assert_eq!(client.pending_commit_ids(), vec![update]);
23517 assert_eq!(client.rejections.len(), 1);
23518 let rejection = &client.rejections[0];
23519 assert_eq!(rejection.client_commit_id, insert);
23520 assert_eq!(rejection.code, OUTBOX_INCOMPATIBLE_CODE);
23521 assert!(rejection.operation.as_ref().is_some_and(|op| op.insert));
23522 assert!(
23523 client.commit_outcome(&insert).unwrap().is_some(),
23524 "the journal accepts the marked operation"
23525 );
23526 }
23527
23528 #[test]
23529 fn the_stored_operation_validator_accepts_the_marker_only_on_upserts() {
23530 let upsert =
23531 json!({ "table": "tasks", "rowId": "q", "op": "upsert", "values": { "id": "q" } });
23532 let delete = json!({ "table": "tasks", "rowId": "q", "op": "delete" });
23533 let with = |base: &Value, insert: Value| {
23534 let mut value = base.clone();
23535 value["insert"] = insert;
23536 value
23537 };
23538 assert!(SyncClient::stored_operation_is_representable(&upsert));
23539 assert!(SyncClient::stored_operation_is_representable(&with(
23540 &upsert,
23541 json!(true)
23542 )));
23543 assert!(!SyncClient::stored_operation_is_representable(&with(
23544 &upsert,
23545 json!(false)
23546 )));
23547 assert!(!SyncClient::stored_operation_is_representable(&with(
23548 &upsert,
23549 json!("yes")
23550 )));
23551 assert!(SyncClient::stored_operation_is_representable(&delete));
23552 assert!(!SyncClient::stored_operation_is_representable(&with(
23553 &delete,
23554 json!(true)
23555 )));
23556 }
23557}